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 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
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.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) - 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 fromALLOWED_HOSTSsetting - 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 likeExAmPlE.cOm.attacker.com - Efficient lookup:
ALLOWED_DOMAINSset 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=Falseensures 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:
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
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
LoginViewincludes built-in open redirect protection - Automatic validation: Automatically validates redirect URLs against
ALLOWED_HOSTSsetting - Internal validation:
get_success_url()usesurl_has_allowed_host_and_scheme()internally - Customization support: Respects
REDIRECT_FIELD_NAMEsetting for customization - HTTPS enforcement: Enforces HTTPS for secure sites with
require_httpsparameter
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