Skip to content

CWE-601: Open Redirect - Python

Overview

Open redirect vulnerabilities in Python web applications occur when user-controlled input is used to redirect users without proper validation, enabling phishing attacks and credential theft. Flask, Django, and FastAPI each have framework-specific redirect mechanisms that require careful handling to prevent exploitation.

Primary Defence: 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

from flask import Flask, request, redirect, url_for
from urllib.parse import urlparse, urljoin

app = Flask(__name__)

def is_safe_url(target):
    """
    Validate that a URL is safe to redirect to.
    Only allows relative URLs (no external domains).
    """
    if not target:
        return False

    # Parse the URL
    parsed = urlparse(target)

    # 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 target.startswith('/') or target.startswith('//'):
        return False

    return True

@app.route('/login')
def login():
    # Authenticate user...

    next_url = request.args.get('next')

    if next_url and is_safe_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() properly parses URLs into components (scheme, netloc, path, etc.), handling edge cases like encoded characters and malformed URLs
  • 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)
  • 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 is_safe_url()

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:

  • Battle-tested function: url_has_allowed_host_and_scheme() is Django's battle-tested URL validation function, handling complex edge cases
  • 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)
  • Comprehensive validation: Validates against protocol-relative URLs, JavaScript URLs, and null bytes
  • Trusted host validation: request.get_host() uses Django's trusted host validation from ALLOWED_HOSTS setting
  • Fail-closed default: Fail-closed with redirect('/') when validation fails

Allowlist External Domains

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

    parsed = urlparse(target)

    # Allow relative URLs (no netloc)
    if not parsed.netloc:
        # Must be valid relative path
        return target.startswith('/') and not target.startswith('//')

    # For absolute URLs, check against allowlist
    # Require https scheme for external domains
    if parsed.scheme not in ('http', 'https'):
        return False

    # Exact domain match (case-insensitive)
    return parsed.netloc.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'))

Why this works:

  • Flexible validation: Combines local URL validation (relative paths) with external allowlist for flexibility
  • Case-insensitive matching: parsed.netloc.lower() performs case-insensitive exact host matching, preventing bypasses like ExAmPlE.cOm.attacker.com
  • Efficient lookup: ALLOWED_DOMAINS set provides O(1) lookup and prevents duplicates
  • Protocol restrictions: scheme not in ('http', 'https') blocks JavaScript, data, file, and other dangerous protocols
  • Proper separation: Separate validation for relative vs absolute URLs ensures proper handling of each case
  • Protocol-relative rejection: Rejects protocol-relative URLs with not startswith('//') check; clear separation of concerns between relative path validation vs external domain allowlist

Indirect Redirects (Best Practice)

from flask import Flask, request, redirect, url_for

app = Flask(__name__)

# Map safe IDs to redirect destinations
REDIRECT_MAP = {
    'dashboard': url_for('dashboard', _external=False),
    'profile': url_for('profile', _external=False),
    'settings': url_for('settings', _external=False),
}

@app.route('/goto')
def safe_redirect():
    destination_id = request.args.get('dest')

    # Look up URL from mapping
    redirect_url = REDIRECT_MAP.get(destination_id)

    if redirect_url:
        return redirect(redirect_url)

    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: Eliminates URL injection entirely - users provide string IDs, not URLs
  • Safe lookup: REDIRECT_MAP.get() performs safe dictionary lookup with no injection risk
  • Invalid IDs handled: Invalid IDs (like '<script>alert(1)</script>' or '../../../etc/passwd') simply won't exist in mapping
  • Flask URL generation: url_for() generates URLs using Flask's routing system, ensuring they're valid internal routes; _external=False ensures generated URLs are relative paths, not absolute
  • Fail-closed default: Fail-closed default redirects to homepage for invalid/missing IDs
  • Immune to bypasses: Immune to encoding bypasses, protocol tricks, and domain manipulation; easiest pattern to security audit - just review the REDIRECT_MAP dictionary

FastAPI: 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

    parsed = urlparse(url)

    # Must have no scheme or netloc
    if parsed.scheme or parsed.netloc:
        return False

    # Must start with / but not //
    return url.startswith('/') and not url.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:

  • Async-safe parsing: urlparse() handles FastAPI's async context correctly, parsing URLs safely
  • Type validation: Type hints (url: str) provide FastAPI validation that parameter is a string
  • Safe fallback: Default parameter next: str = "/" provides safe fallback when 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

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: Django's LoginView includes built-in open redirect protection
  • Automatic validation: Automatically validates redirect URLs against ALLOWED_HOSTS setting
  • Internal validation: get_success_url() uses url_has_allowed_host_and_scheme() internally
  • Customization support: Respects REDIRECT_FIELD_NAME setting for customization
  • HTTPS enforcement: Enforces HTTPS for secure sites with require_https parameter

Warning Page for External URLs

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('/')

    parsed = urlparse(target_url)

    # Check if local
    if not parsed.netloc:
        if target_url.startswith('/') and not target_url.startswith('//'):
            return redirect(target_url)
        return redirect('/')

    # Check if in allowlist
    if parsed.scheme in ('http', 'https') and parsed.netloc.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 }}">Continue to external site</a>
<a href="/">Stay here</a>
"""

Why this works:

  • Breaks phishing chain: Interstitial warning breaks automatic phishing redirect chain
  • User inspection: Displays full destination URL for user inspection
  • Explicit action required: Requires explicit user action (clicking "Continue") to proceed
  • Safe escape: Provides escape hatch ("Stay here") for suspicious redirects
  • Seamless for local: Only shown for external URLs - local redirects are seamless
  • Defense-in-depth: Combines validation with user awareness for defense-in-depth

Additional Resources