In the world of web security, vulnerabilities often lurk in the seemingly innocuous corners of your code—like a simple redirect that trusts user input a little too much. Open Redirects are one such "simple" issue, but their impact can ripple into phishing epidemics, credential theft, and OAuth hijackings. In this detailed writeup, we'll dissect the vulnerability step by step: what it is, why it happens in Flask apps, how attackers weaponize it, and—most importantly—how to fortify your code against it. I'll include expanded code examples, real-world attack vectors, and even a quick lab setup to test it yourself.
If you're a developer dipping into security or a pentester honing your skills, this is your hands-on guide. Let's build, break, and bulletproof a Flask app together.
What is an Open Redirect Vulnerability?
At its core, an Open Redirect (also known as an unvalidated redirect) happens when a web application allows user-supplied input to dictate where a user gets redirected without proper validation. This input is typically passed via query parameters (e.g., ?next=...), headers, or form data.
Why It's Dangerous
-
Phishing Superpower: Attackers craft links that masquerade as trusted (e.g.,
https://yourbank.com/login?next=evil-phish-site.com). Victims see the legit domain in their address bar, log in, and boom—redirected to a fake site harvesting credentials. - Chained Attacks: It's a gateway drug for bigger exploits, like stealing OAuth tokens (e.g., tricking a user into authorizing a malicious app) or bypassing security filters (e.g., redirecting past login walls).
- Low Barrier to Entry: No fancy exploits needed—just social engineering and a URL shortener.
According to OWASP, Open Redirects rank in the Top 10 for good reason: they're easy to find (via automated scanners like Burp Suite) and exploit, but often overlooked in code reviews.
Common Triggers in Web Apps
Redirects pop up everywhere: login "next" pages, error handlers, email verification links. In Flask, the redirect() function from flask is the usual suspect—it's convenient but naive if fed raw user input.
Building a Vulnerable Flask App: The Setup
Let's start by creating a minimal vulnerable app. I'll assume you're running Python 3.8+ with Flask installed (pip install flask). Save this as vulnerable_app.py:
from flask import Flask, request, redirect, url_for, render_template_string
app = Flask(__name__)
# Simple in-memory "users" for demo purposes
USERS = {"admin": "password123"}
@app.route("/")
def index():
return "Welcome to the Vulnerable App! <a href='/login?next=/dashboard'>Login</a>"
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "GET":
# Render a basic login form
return render_template_string("""
<form method="POST">
Username: <input type="text" name="username"><br>
Password: <input type="password" name="password"><br>
<input type="submit" value="Login">
</form>
""")
username = request.form.get("username")
password = request.form.get("password")
# Dummy auth: always succeed for demo (NEVER DO THIS IN PROD!)
if username in USERS and password == USERS[username]:
next_url = request.args.get("next") # Vulnerable: Trusts user input blindly
return redirect(next_url or url_for("dashboard"))
else:
return "Invalid credentials. Try again."
@app.route("/dashboard")
def dashboard():
return "Welcome to the Dashboard! You're logged in."
if __name__ == "__main__":
app.run(debug=True)
Run it with python vulnerable_app.py and visit http://localhost:5000. Click the login link—after "logging in," it redirects to /dashboard harmlessly. But notice the ?next= parameter? That's our entry point for chaos.
The Core Flaw Dissected
-
request.args.get("next")pulls the query param without sanitization. -
redirect(next_url)sends an HTTP 302 withLocation: <user_input>, letting the browser follow anywhere. - No checks for scheme (http/https), domain, or even if it's a URL at all.
Pro tip: In production, always log redirects for auditing—Flask's logger can help: app.logger.warning(f"Redirecting to: {next_url}").
Exploiting Open Redirect: Attack Vectors in Action
Exploitation is straightforward but sneaky. Attackers distribute the malicious link via email, SMS, or social media. Here's how it plays out.
Basic Exploitation
-
Craft the Payload:
http://localhost:5000/login?next=https://evil.com/phish -
Victim Interaction:
- Victim clicks → Sees legit login page (address bar shows
localhost:5000/login?...). - Enters creds → Submits form.
- Server responds with
Location: https://evil.com/phish→ Browser jumps there.
- Victim clicks → Sees legit login page (address bar shows
- Attacker Wins: Victim is now on a phishing page that looks like your app, primed to steal more data.
Test it: Swap evil.com for https://example.com (harmless) and watch the redirect in your browser's Network tab.
Advanced Scenarios
Open Redirects shine in combos. Let's explore:
-
OAuth Token Theft:
- In apps using OAuth (e.g., "Login with Google"), the callback might redirect via
?redirect_uri=.... - Attacker:
yourapp.com/oauth/callback?redirect_uri=https://evil.com/steal-token. - Flow: User authorizes → Token sent to callback → Redirect leaks token to evil.com.
- Real-world: Facebook's 2018 OAuth bug let attackers steal access tokens this way.
- In apps using OAuth (e.g., "Login with Google"), the callback might redirect via
-
Bypassing Content Security Policy (CSP):
- CSP blocks inline scripts, but redirects can load external resources indirectly.
- Attacker embeds the open redirect in an iframe or link, smuggling malicious JS.
-
Phishing with URL Obfuscation:
- Use data URLs:
?next=javascript:alert('XSS!')(if JS execution is allowed post-redirect). - Or protocol-relative:
?next=//evil.com(adapts to http/https seamlessly).
- Use data URLs:
-
SSRF (Server-Side Request Forgery) Twist:
- If your redirect fetches the URL server-side (rare, but possible), it could probe internal networks.
To simulate: Use tools like curl -v http://localhost:5000/login?next=https://evil.com -d "username=admin&password=password123" and inspect the Location header.
Detection Tools
-
Burp Suite/ZAP: Intercept requests and fuzz the
nextparam. -
Nuclei/Yarn: Scan for open redirects with templates like
{{BaseURL}}/login?next=http://{{interactsh-url}}.
Fixing It: From Basic to Battle-Hardened
The fix? Validate, validate, validate. Never redirect to untrusted turf. Here's an evolving approach.
Step 1: Basic Internal-Only Check
Use urlparse to whitelist your domain. Update the login route:
from urllib.parse import urlparse
from flask import url_for
def is_safe_url(target, allowed_hosts=None):
if not allowed_hosts:
allowed_hosts = {request.host} # e.g., 'localhost:5000'
if not target:
return False
parsed = urlparse(target if target.startswith(('http://', 'https://', '/')) else f"http://{target}")
return parsed.netloc in allowed_hosts or parsed.netloc == '' # Relative paths OK
@app.route("/login", methods=["GET", "POST"])
def login():
# ... (auth logic same as before)
if username in USERS and password == USERS[username]:
next_url = request.args.get("next")
if next_url and is_safe_url(next_url):
return redirect(next_url)
return redirect(url_for("dashboard")) # Fallback to safe internal page
# ... (else clause)
-
How it Works: Strips to netloc (domain:port). Relative URLs (e.g.,
/dashboard) parse to empty netloc—safe. -
Test:
?next=https://evil.com→ Blocked, falls back to dashboard.?next=/profile→ Allowed.
Step 2: Advanced Whitelisting and Edge Cases
For production, level up:
import re
from urllib.parse import urlparse, urljoin
def is_safe_url(target, base_url=request.url_root, allowed_domains=None):
if not allowed_domains:
allowed_domains = ['yourdomain.com', 'www.yourdomain.com'] # Your allowlist
# Reject non-URLs or suspicious schemes
if not re.match(r'^https?://', target) and not target.startswith('/'):
return False
parsed = urlparse(target)
# Block javascript:, data:, etc.
if parsed.scheme not in ('http', 'https'):
return False
# Relative URL? Resolve against base
if parsed.netloc == '':
target = urljoin(base_url, target)
parsed = urlparse(target)
# Check domain
return parsed.netloc.replace('www.', '') in allowed_domains # Normalize www.
# In login route:
next_url = request.args.get("next")
if next_url and is_safe_url(next_url):
return redirect(next_url)
-
Extras:
-
Allowlist Over Denylist: Explicitly permit
yourdomain.comvariants. - Scheme Enforcement: Force HTTPS to avoid downgrade attacks.
-
Path Validation: Add
and '/admin' not in parsed.pathto block sensitive areas. - Rate Limiting: Use Flask-Limiter on login to thwart brute-force link spam.
-
Allowlist Over Denylist: Explicitly permit
Step 3: Framework Best Practices
-
Flask-Specific: Use
url_for()for all internal links—it's safer than hardcoding. - Session-Based Redirects: Store intended URL in session pre-login, retrieve post-auth (avoids query param altogether).
-
HTTP Headers: Set
X-Frame-Options: DENYand CSP to limit embedding. - OWASP Cheat Sheet: Follow their Unvalidated Redirects for more.
Real-World Examples and Lessons
-
Google (2014): A
?continue=param in Google Accounts allowed redirects to arbitrary sites, fixed via domain checks. -
Facebook (Ongoing): Repeated OAuth redirect bugs led to token leaks; now they enforce strict
redirect_urivalidation. - Stats: Veracode's 2023 report shows Open Redirects in 5% of scanned apps—low severity, but 20% lead to high-impact chains.
Big takeaway: Even giants slip up. Audit your redirects with grep -r "redirect(" . in your codebase.
Hands-On Lab: Test and Tweak
- Run the vulnerable app.
- Exploit with a phishing sim (use
http://httpbin.org/redirect/3as "evil"). - Apply the fix, re-test.
- Bonus: Add WTForms for input validation or integrate with Flask-Security for auth. ## Result Screenshots
Key Takeaways
- Trust No Input: User data is a liar—always parse and validate.
- Layer Defenses: Basic checks + allowlists + logging = robust.
- Proactive Hunting: Scan early (e.g., via GitHub Actions with Semgrep).
- Learn by Breaking: Build this lab, then hunt for similar vulns in open-source repos.
Open Redirects remind us: Security isn't about complexity; it's about vigilance.





Top comments (0)