Skip to content

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 parameters
  • redirect() 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 validate
  • HttpResponseRedirect() 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 in can be bypassed with mixed case HTTP:// 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.netloc check 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.scheme check 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.com would be interpreted as https://evil.com by browsers)
  • Backslash normalization: Browsers convert \ to / while resolving a URL, so /\evil.com and \\evil.com both reach https://evil.com. Python's urlparse() leaves them as paths with an empty netloc, so a check that skips the replace() accepts them. Django's url_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.com is //evil.com to them. urlparse() strips those three characters too (Python 3.10 and later), which is why the tab form fails the netloc test on its own - but the same stripping turns /p\r\nSet-Cookie: a=b into 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 with ValueError, 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 called is_safe_url() until Django 3.0, deprecated there, and removed in Django 4.0 - confirmed absent from django.utils.http on Django 6.1 - so guidance or code still naming is_safe_url needs 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 from ALLOWED_HOSTS setting
  • 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 True for /p\r\nSet-Cookie: a=b, and it is HttpResponseRedirect that then percent-encodes the pair into Location: /p%0D%0ASet-Cookie:%20a=b - a path on the site, not a second header. It compares the host case-sensitively, so https://EXAMPLE.COM/x is refused against example.com, which is fail-closed. And redirect() treats a value with no / or . in it, such as dashboard, as a view name and raises NoReverseMatch when 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 like ExAmPlE.cOm.attacker.com
  • Efficient lookup: ALLOWED_DOMAINS is 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 plain http - narrow the tuple to ('https',) if every allowlisted host is HTTPS-only, which they normally are
  • Port parsed inside a try: urlparse() accepts https://example.com:99999 and https://example.com:notaport without complaint and raises ValueError only when .port is 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 an index endpoint. Without one, Flask raises BuildError on 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 returns None
  • 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 raises RuntimeError: 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: str hint 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: RedirectResponse is 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_HOSTS setting
  • Internal validation: get_success_url() uses url_has_allowed_host_and_scheme() internally
  • Customization support: Respects REDIRECT_FIELD_NAME for the name of the redirect parameter
  • HTTPS enforcement: Passes require_https so a secure site does not redirect to http

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_url through urljoin(request.host_url, next_url) and redirecting to the joined result without separately comparing the result's netloc/scheme to the app's own host. urljoin() resolves relative references against a base, but when next_url is 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_HOSTS as the allowed_hosts argument to url_has_allowed_host_and_scheme() because it's already defined and looks like the right list. ALLOWED_HOSTS exists 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/AnyUrl field type on a FastAPI next parameter 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 accept https://evil.com exactly as readily as https://trusted-site.com, since host allowlisting was never part of what that type validates.

Additional Resources