CWE-601: Open Redirect - Python
Overview
Open redirect vulnerabilities in Python web applications occur when user-controlled input reaches a redirect without validation, so an attacker can send a user from your domain to a phishing page that harvests their credentials. Flask, Django, and FastAPI each have their own redirect mechanism, and the validation each one gives you differs.
Primary Defence: Where the destination does not have to come from the request at all, use the indirect pattern below - a server-side map from an opaque key to an endpoint removes the weakness rather than constraining it, and no parser disagreement can apply to a value that is never parsed.
Where it does: for local redirects, validate that user-supplied URLs are relative paths (starting with / but not //) using urlparse().netloc == '' or framework-specific helpers. For external redirects, use an explicit allowlist of permitted domains with exact host matching after parsing with urllib.parse.urlparse(). Reject protocol-relative URLs (//evil.com), JavaScript URLs (javascript:), and data URLs (data:). Always fail-closed with a safe default redirect (e.g., homepage) when validation fails.
Common Vulnerable Patterns
Unvalidated Flask Redirect
from flask import Flask, request, redirect
app = Flask(__name__)
# VULNERABLE - No validation
@app.route('/login')
def login():
# Authenticate user...
next_url = request.args.get('next')
return redirect(next_url) # Dangerous!
# Attack: /login?next=https://evil.com/phishing
Why this is vulnerable:
request.args.get('next')retrieves user-controlled input directly from URL parametersredirect()accepts any URL without validation, including absolute URLs to attacker domains- No check for protocol-relative URLs (
//evil.com), JavaScript URLs, or data URLs - Missing null/empty validation allows errors when parameter is omitted
Unvalidated Django Redirect
from django.shortcuts import redirect
from django.http import HttpResponseRedirect
# VULNERABLE - Direct redirect from GET parameter
def login_view(request):
# Authenticate user...
next_url = request.GET.get('next', '/')
return HttpResponseRedirect(next_url) # Vulnerable!
# Attack: /login?next=https://evil.com/fake-login
Why this is vulnerable:
request.GET.get('next', '/')retrieves user input with default but doesn't validateHttpResponseRedirect()accepts absolute URLs without restriction- No validation that the URL is local to the application
- Default of
/is safe, but provided values can be malicious
String-Based URL Validation
# VULNERABLE - Insufficient string checking
def unsafe_redirect(redirect_url):
if 'http://' not in redirect_url and 'https://' not in redirect_url:
return redirect(redirect_url) # Still vulnerable!
return redirect('/')
# Attack: redirect_url = "//evil.com/phishing"
# Protocol-relative URL bypasses the check
Why this is vulnerable:
- String containment check misses protocol-relative URLs like
//evil.com 'http://' not incan be bypassed with mixed caseHTTP://or encoding- Doesn't prevent JavaScript URLs (
javascript:alert(1)) or data URLs - No validation of URL structure or components
Secure Patterns
Flask: Validate Local URLs
# SECURE - Flask: reject any value that is not a same-site path
from flask import Flask, request, redirect, url_for
from urllib.parse import urlparse, urljoin
app = Flask(__name__)
def is_local_url(target):
"""
Validate that a URL is safe to redirect to.
Only allows relative URLs (no external domains).
Named is_local_url rather than is_safe_url: Django had a helper by the
latter name, it was removed in Django 4.0, and Flask has no equivalent
of its own - so this is a helper you write, not one you import.
"""
if not target:
return False
# Browsers delete tab, CR and LF from a URL before resolving it, so
# "/<tab>/evil.com" is "//evil.com" to them, and a CR or LF that reaches
# the Location header is a header injection. Reject control characters
# outright rather than relying on the parser to strip them.
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in target):
return False
# Browsers treat a backslash as a path separator when they resolve a
# Location header, so /\evil.com is protocol-relative to them.
# urlparse() does not, and reports it as an ordinary path - so
# normalize first and validate what the browser will see.
normalized = target.replace('\\', '/')
parsed = urlparse(normalized)
# Reject if netloc (domain) is present - must be relative
# Reject if scheme is present - must be relative
if parsed.netloc or parsed.scheme:
return False
# Must start with / but not //
if not normalized.startswith('/') or normalized.startswith('//'):
return False
return True
@app.route('/login')
def login():
# Authenticate user...
next_url = request.args.get('next')
if next_url and is_local_url(next_url):
return redirect(next_url)
return redirect(url_for('index')) # Safe default
@app.route('/')
def index():
return "Home page"
Why this works:
- Proper URL parsing:
urlparse()separates URLs into components (scheme, netloc, path, etc.) so validation can reason about structure instead of substrings - Domain validation:
parsed.netloccheck ensures no domain is present - rejects absolute URLs (https://evil.com), subdomains (//sub.example.com), and auth-based bypasses (http://user@attacker.com) - Scheme blocking:
parsed.schemecheck blocks JavaScript URLs (javascript:alert(1)), data URLs (data:text/html,...), and file URLs (file:///etc/passwd) - Relative path validation:
startswith('/')ensures URL is a valid relative path within the application;not startswith('//')prevents protocol-relative URL bypass (//evil.comwould be interpreted ashttps://evil.comby browsers) - Backslash normalization: Browsers convert
\to/while resolving a URL, so/\evil.comand\\evil.comboth reachhttps://evil.com. Python'surlparse()leaves them as paths with an emptynetloc, so a check that skips thereplace()accepts them. Django'surl_has_allowed_host_and_scheme()runs its own check twice for the same reason - once on the raw value and once on the backslash-replaced copy - Control characters rejected: browsers delete tab, CR and LF before resolving a URL, so
/<tab>/evil.comis//evil.comto them.urlparse()strips those three characters too (Python 3.10 and later), which is why the tab form fails thenetloctest on its own - but the same stripping turns/p\r\nSet-Cookie: a=binto the plain path/pSet-Cookie: a=b, so without the explicit check a header-injection payload passes validation. Measured on Flask 3.1: Werkzeug then refuses the newline in the header value withValueError, a 500 in place of the safe default. Rejecting control characters in the validator keeps that decision out of the sink - Safe defaults:
url_for('index')generates safe internal URL as fail-closed default when validation fails; null/empty check prevents errors and rejects missing parameters
Django: Use url_has_allowed_host_and_scheme()
# SECURE - Django: url_has_allowed_host_and_scheme() restricts host and scheme
from django.shortcuts import redirect
from django.utils.http import url_has_allowed_host_and_scheme
def login_view(request):
# Authenticate user...
next_url = request.GET.get('next', '/')
# Django's built-in validation
if url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure()
):
return redirect(next_url)
return redirect('/') # Safe default
Why this works:
- Framework helper:
url_has_allowed_host_and_scheme()is Django's URL validation helper and should be preferred over custom string checks for Django redirects. It is the current name: the helper was calledis_safe_url()until Django 3.0, deprecated there, and removed in Django 4.0 - confirmed absent fromdjango.utils.httpon Django 6.1 - so guidance or code still namingis_safe_urlneeds updating rather than importing - Host restriction:
allowed_hosts={request.get_host()}restricts redirects to the current domain only, rejecting external domains - Downgrade protection:
require_https=request.is_secure()ensures HTTPS sites don't redirect to HTTP URLs (downgrade attack prevention) - Scheme and host validation: Rejects disallowed schemes and hosts, including protocol-relative and JavaScript-style redirect targets
- Trusted host validation:
request.get_host()uses Django's trusted host validation fromALLOWED_HOSTSsetting - Fail-closed default:
redirect('/')when validation fails, so a rejected value still lands somewhere safe - What the helper does not check, measured on Django 6.1: its subject is host and scheme. It returns
Truefor/p\r\nSet-Cookie: a=b, and it isHttpResponseRedirectthat then percent-encodes the pair intoLocation: /p%0D%0ASet-Cookie:%20a=b- a path on the site, not a second header. It compares the host case-sensitively, sohttps://EXAMPLE.COM/xis refused againstexample.com, which is fail-closed. Andredirect()treats a value with no/or.in it, such asdashboard, as a view name and raisesNoReverseMatchwhen there is none - a 500 an attacker can trigger with?next=dashboard- so reject bare words before the call if the view must never fail loudly
Allowlist External Domains
# SECURE - allowlist of external hosts, exact match after parsing
from flask import Flask, request, redirect, url_for
from urllib.parse import urlparse
app = Flask(__name__)
ALLOWED_DOMAINS = {
'example.com',
'www.example.com',
'partner.example.org'
}
def is_allowed_url(target):
"""
Validate URL is either local or in allowed domain list.
"""
if not target:
return False
# Browsers delete tab, CR and LF before resolving a URL, and CR or LF
# in a Location header is a header injection - reject control characters
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in target):
return False
# Browsers read a backslash as a separator, so /\evil.com is
# protocol-relative to them - normalize before parsing
normalized = target.replace('\\', '/')
parsed = urlparse(normalized)
# Allow relative URLs (no netloc)
if not parsed.netloc:
# Must be valid relative path
return normalized.startswith('/') and not normalized.startswith('//')
# For absolute URLs, check against allowlist.
# Narrow this to ('https',) if the allowlisted hosts are HTTPS-only
if parsed.scheme not in ('http', 'https'):
return False
# urlparse() defers port validation to attribute access, so .port raises
# ValueError for 'https://example.com:99999' and for a non-numeric port.
# Reading it without catching that turns an attacker-supplied string into
# an unhandled 500 rather than a rejection.
try:
port = parsed.port
except ValueError:
return False
# Exact hostname match (case-insensitive); reject userinfo and custom ports
if parsed.username or parsed.password or port is not None:
return False
return (parsed.hostname or '').lower() in ALLOWED_DOMAINS
@app.route('/external')
def external_redirect():
target_url = request.args.get('url')
if target_url and is_allowed_url(target_url):
return redirect(target_url)
return redirect(url_for('index'))
@app.route('/')
def index():
return "Home page"
Why this works:
- Flexible validation: Combines local URL validation (relative paths) with an allowlist for the external destinations the application genuinely needs
- Case-insensitive matching:
parsed.hostname.lower()performs case-insensitive exact host matching after parsing, preventing bypasses likeExAmPlE.cOm.attacker.com - Efficient lookup:
ALLOWED_DOMAINSis a set, so membership is O(1) and the same host cannot be listed twice - Protocol restrictions:
scheme not in ('http', 'https')blocks JavaScript, data, file, and other dangerous protocols. It permits plainhttp- narrow the tuple to('https',)if every allowlisted host is HTTPS-only, which they normally are - Port parsed inside a
try:urlparse()acceptshttps://example.com:99999andhttps://example.com:notaportwithout complaint and raisesValueErroronly when.portis read. Verified on Python 3.13: reading it unguarded turns both strings into an unhandled exception, so the endpoint answers 500 instead of rejecting the redirect - Proper separation: Relative and absolute URLs take separate branches, each checked against the rule that applies to it rather than one test trying to cover both
- Protocol-relative rejection:
not startswith('//')rejects protocol-relative URLs on the relative branch - Control characters rejected before parsing, for the reason given under the Flask example above
- A default that exists:
url_for('index')needs anindexendpoint. Without one, Flask raisesBuildErroron every rejected value - measured, a 500 in place of the safe default - so the route is part of the example, not decoration
Indirect Redirects (Best Practice)
# SECURE - indirect redirect: the request carries a key, never a URL
from flask import Flask, request, redirect, url_for
app = Flask(__name__)
# Map safe IDs to endpoint names, not to URLs. url_for() needs an
# application or request context, so building the URLs here at import
# time raises RuntimeError before the app serves anything.
REDIRECT_MAP = {
'1': 'dashboard',
'2': 'profile',
'3': 'settings',
}
@app.route('/goto')
def safe_redirect():
destination_id = request.args.get('dest')
# Look up the endpoint from the mapping
endpoint = REDIRECT_MAP.get(destination_id)
if endpoint:
return redirect(url_for(endpoint))
return redirect(url_for('index'))
@app.route('/dashboard')
def dashboard():
return "Dashboard"
@app.route('/profile')
def profile():
return "Profile"
@app.route('/settings')
def settings():
return "Settings"
@app.route('/')
def index():
return "Home"
Why this works:
- Eliminates injection: The request carries a string ID, never a URL, so there is no URL for an attacker to supply
- Safe lookup:
REDIRECT_MAP.get()is a dictionary lookup, and a key that is not in the map returnsNone - Invalid IDs handled: Values such as
'<script>alert(1)</script>'or'../../../etc/passwd'are not keys in the mapping - Flask URL generation:
url_for()generates URLs using Flask's routing system, ensuring they're valid internal routes, and returns a relative path by default. It resolves the endpoint at request time, which is also the only time it can run - called at import time it raisesRuntimeError: Working outside of application context, so the map holds endpoint names and the view builds the URL - Fail-closed default: An invalid or missing ID redirects to the homepage
- Immune to bypasses: Encoding bypasses, protocol tricks and domain manipulation have nothing to act on, and auditing the pattern means reading the REDIRECT_MAP dictionary
FastAPI: Path Validation
# SECURE - FastAPI: same-site path validation
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from urllib.parse import urlparse
app = FastAPI()
def is_local_url(url: str) -> bool:
"""Validate URL is relative (local to application)."""
if not url:
return False
# Browsers delete tab, CR and LF before resolving a URL, and CR or LF
# in a Location header is a header injection - reject control characters
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in url):
return False
# Browsers read a backslash as a separator, so /\evil.com is
# protocol-relative to them - normalize before parsing
normalized = url.replace('\\', '/')
parsed = urlparse(normalized)
# Must have no scheme or netloc
if parsed.scheme or parsed.netloc:
return False
# Must start with / but not //
return normalized.startswith('/') and not normalized.startswith('//')
@app.get("/redirect")
async def redirect_endpoint(next: str = "/"):
if is_local_url(next):
return RedirectResponse(url=next)
# Invalid redirect - return to home
return RedirectResponse(url="/")
Why this works:
- Framework-independent parsing:
urlparse()is synchronous and safe to use inside FastAPI handlers because it does not perform blocking I/O - Type validation: The endpoint's
next: strhint is what FastAPI validates the query parameter against - Safe fallback: The default in
next: str = "/"supplies a local destination when the parameter is missing - Consistent logic: Same validation logic as Flask: rejects scheme/netloc, validates relative path format
- Framework integration:
RedirectResponseis FastAPI's redirect mechanism, works in async handlers; fail-closed behavior returns/for invalid URLs instead of raising errors
Django-Specific Pattern
Using Django's Safe Redirect View
# SECURE - Django LoginView validates the redirect itself
from django.contrib.auth.views import LoginView
class SafeLoginView(LoginView):
# Django's LoginView automatically validates redirect URLs
# Uses ALLOWED_HOSTS from settings for domain validation
template_name = 'login.html'
def get_success_url(self):
# Django's default: validates against ALLOWED_HOSTS
# Rejects external URLs automatically
return super().get_success_url()
Why this works:
- Built-in protection: Open redirect protection is already in
LoginView, so a subclass inherits it without writing a check - Automatic validation: Redirect URLs are checked against the
ALLOWED_HOSTSsetting - Internal validation:
get_success_url()usesurl_has_allowed_host_and_scheme()internally - Customization support: Respects
REDIRECT_FIELD_NAMEfor the name of the redirect parameter - HTTPS enforcement: Passes
require_httpsso a secure site does not redirect tohttp
Warning Page for External URLs
# SECURE - interstitial for allowlisted external destinations
from flask import Flask, request, redirect, render_template
from urllib.parse import urlparse
app = Flask(__name__)
ALLOWED_DOMAINS = {'example.com', 'partner.example.org'}
@app.route('/external')
def external_link():
target_url = request.args.get('url')
if not target_url:
return redirect('/')
# Browsers delete tab, CR and LF before resolving a URL, and CR or LF
# in a Location header is a header injection - reject control characters
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in target_url):
return redirect('/')
# Browsers read a backslash as a separator, so /\evil.com is
# protocol-relative to them - normalize before parsing
normalized = target_url.replace('\\', '/')
parsed = urlparse(normalized)
# Check if local
if not parsed.netloc:
if normalized.startswith('/') and not normalized.startswith('//'):
return redirect(target_url)
return redirect('/')
# urlparse() raises ValueError from .port for an out-of-range or
# non-numeric port, so read it before the allowlist test
try:
port = parsed.port
except ValueError:
return redirect('/')
# Check if in allowlist
if (parsed.scheme == 'https' and
not parsed.username and
not parsed.password and
port is None and
(parsed.hostname or '').lower() in ALLOWED_DOMAINS):
# Show warning page for external redirects
return render_template('external_warning.html', destination=target_url)
# Invalid - go home
return redirect('/')
# external_warning.html template:
"""
<h2>You are leaving our site</h2>
<p>You are about to visit: {{ destination }}</p>
<a href="{{ destination }}" rel="noopener noreferrer">Continue to external site</a>
<a href="/">Stay here</a>
"""
Why this works:
- Breaks phishing chain: The interstitial interrupts the automatic hop a phishing link depends on
- Control characters rejected before anything is parsed, for the reason given under the Flask example above
- User inspection: Displays the full destination URL before the user commits to it
- Explicit action required: Nothing leaves the site until the user clicks "Continue"
- Safe escape: "Stay here" lets the user back out of a destination that looks wrong
- Seamless for local: Only shown for external URLs; local redirects are unaffected
- Defense-in-depth: Combines validation with user awareness rather than relying on either alone
Common Pitfalls
- Passing
next_urlthroughurljoin(request.host_url, next_url)and redirecting to the joined result without separately comparing the result'snetloc/schemeto the app's own host.urljoin()resolves relative references against a base, but whennext_urlis already an absolute external URL,urljoin()returns that external URL essentially unchanged rather than anchoring it to the base - the join step provides no restriction at all in that case. - Passing
settings.ALLOWED_HOSTSas theallowed_hostsargument tourl_has_allowed_host_and_scheme()because it's already defined and looks like the right list.ALLOWED_HOSTSexists to validate the incoming Host header for a different purpose, and can legitimately include wildcard entries (.example.com) for multi-subdomain deployments - reusing it here widens the redirect allowlist far beyond "the current request's own host." - Using a Pydantic
HttpUrl/AnyUrlfield type on a FastAPInextparameter as the validation step. Pydantic's URL types check that the value is a syntactically valid, well-formed absolute URL with an allowed scheme - they accepthttps://evil.comexactly as readily ashttps://trusted-site.com, since host allowlisting was never part of what that type validates.