Open Redirect - Offene Weiterleitung
An open redirect is a web application vulnerability in which an attacker exploits the redirect functionality of a legitimate website to redirect users to an external attacker-controlled website. Although it does not technically involve direct code execution, Open Redirect is used as a catalyst for phishing (legitimate URL misleads about the destination), OAuth/OIDC token theft (redirect_uri manipulation), and as an SSRF tool. In OWASP API Security, Open Redirect is relevant to A3:2023 (Broken Object Property Level Authorization).
Open Redirect sounds harmless—and is often dismissed by many developers as a low-severity finding. But when combined with OAuth 2.0, a "harmless" redirect turns into a critical token theft scenario. And as a phishing vector, https://legitime-bank.de/redirect?url=https://evil.com/login is nearly impossible for victims to detect. Open Redirect regularly appears in bug bounty reports—often with substantial bounties when combined with OAuth.
Open Redirect: Basic Principle
Typical Implementation with Vulnerability:
Legitimate Use:
# After Login: Redirect user back to the requested page
GET /login?redirect=/dashboard
→ After Login: 302 Location: /dashboard ✓
Vulnerability:
GET /login?redirect=https://evil.com/fake-login
→ After login: 302 Location: https://evil.com/fake-login ✗
Or:
GET /logout?next=https://phishing.com/
GET /redirect?url=https://evil.com/
Common parameter names for Open Redirect:
url=, redirect=, next=, return=, returnTo=, redirect_uri=
dest=, destination=, target=, rurl=, forward=, goto=
continue=, back=, from=, origin=, to=, out=
Attack Scenarios
Phishing Attack via Open Redirect:
# Normal phishing link:
https://evil.com/fake-banking-login/
→ Security-conscious user: suspicious domain detected!
# With open redirect on a legitimate bank domain:
https://real-bank.de/logout?redirect=https://evil.com/fake-banking-login/
→ URL starts with a real bank domain!
→ Email filter: real domain in the link – less suspicious
→ User sees: "https://real-bank.de/..." → clicks!
→ Lands on phishing page
---
OAuth/OIDC Token Theft:
# Normal OAuth flow:
GET /authorize?client_id=myapp&redirect_uri=https://myapp.com/callback&...
→ After auth: Code sent to https://myapp.com/callback (valid!)
# With Open Redirect to Authorization Server:
# If Authorization Server itself has Open Redirect:
https://auth.example.com/authorize?client_id=myapp&
redirect_uri=https://auth.example.com/redirect?url=https://evil.com/
# Flow:
1. Auth Server redirects after login to:
https://auth.example.com/redirect?url=https://evil.com/&code;=SECRET_CODE
2. Open Redirect forwards to:
https://evil.com/?code=SECRET_CODE
3. Attacker steals the authorization code!
4. Exchanges code for access token
→ Critical combination! Code Severity: High/Critical
---
SSRF via Open Redirect:
# SSRF filter only checks external URLs:
# If the app has SSRF protection but does not account for open redirects:
fetch?url=https://legitimate-site.com/redirect?url=http://169.254.169.254/
# Protective measure (apparently):
→ URL is checked: legitimate-site.com → allowed ✓
# Exploit:
→ Server follows redirect to http://169.254.169.254/
→ SSRF protection bypassed!
---
Credential Theft via Referrer Header:
# If external site: Authorization code in URL + Referrer:
# User comes from:
https://auth.site.com/callback?code=SECRET&state;=xyz
→ Clicks on link on callback page to external provider
→ Referrer header contains: https://auth.site.com/callback?code=SECRET!
→ Code leaked to external site!
Detection and Prevention
Programmatic protection:
Allowlist-based redirect (Best Practice):
# Python (Flask):
from urllib.parse import urlparse
ALLOWED_DOMAINS = {'myapp.com', 'www.myapp.com', 'api.myapp.com'}
def safe_redirect(url: str, default: str = '/') -> str:
"""Redirect only to allowed domains"""
if not url:
return default
parsed = urlparse(url)
# Allow relative URLs (no scheme → internal):
if not parsed.netloc and not parsed.scheme:
# Only /path/... - no domain specified
return url
# External URLs: Domain check
if parsed.netloc.rstrip('/') not in ALLOWED_DOMAINS:
return default # Unknown domain → Default
# Scheme check (no javascript:, data:, etc.)
if parsed.scheme not in ('http', 'https'):
return default
return url
# Usage:
redirect_url = request.args.get('redirect', '/')
return redirect(safe_redirect(redirect_url))
JavaScript (Next.js):
// Middleware or API route:
function safeRedirect(url: string, fallback = '/'): string {
try {
// Allow relative URLs directly
if (url.startsWith('/') && !url.startsWith('//')) {
return url;
}
const parsed = new URL(url);
const allowed = ['myapp.com'];
if (allowed.includes(parsed.hostname)) {
return url;
}
return fallback;
} catch {
return fallback; // Invalid URL
}
}
PHP:
function safe_redirect(string $url, string $default = '/'): string {
// Only relative paths and own domain
$parsed = parse_url($url);
if (!isset($parsed['host'])) {
return $url; // Relative URL
}
$allowed = ['example.com', 'www.example.com'];
return in_array($parsed['host'], $allowed) ? $url : $default;
}
Common errors:
# Insufficient validation (can be bypassed!):
# Only check prefix - bypassable with ?:
if starts_with(url, 'https://mysite.com'):
redirect(url)
# Bypass: https://mysite.com.evil.com/ or https://mysite.com@evil.com/
# Host match only - bypassable with @:
parsed = urlparse(url)
if parsed.netloc == 'mysite.com':
redirect(url)
# Bypass: https://mysite.com@evil.com/ → netloc is 'mysite.com@evil.com'
# → parsed.netloc != 'mysite.com' BUT browser navigates to evil.com!
# Forgot JavaScript URIs:
if not url.startswith('http://evil'):
redirect(url)
# Bypass: javascript:alert(1) or data:text/html,...
---
OAuth-specific measures:
# Authorization Server: Match redirect_uri EXACTLY!
# No wildcards, no prefix match – only exact match!
# Bad:
allowed_uris = ['https://myapp.com/callback*'] # Wildcard – dangerous!
if url.startswith(allowed_uris): # Prefix – dangerous!
# Good:
ALLOWED_REDIRECT_URIS = {
'https://myapp.com/callback',
'https://myapp.com/oauth/callback',
}
if redirect_uri not in ALLOWED_REDIRECT_URIS:
return error('invalid_redirect_uri')
# State parameter for CSRF protection (against session fixation):
state = secrets.token_urlsafe(32)
session['oauth_state'] = state
# → On callback: Check state from session!
Testing in the Penetration Test
Open Redirect Testing:
Parameter Discovery:
# Burp Intruder: test all parameters with redirect semantics
# Wordlist: url, redirect, next, return, goto, dest, ...
# Manual: Search for link elements in the response
grep -E "(href|action|src|redirect|url)=[\"']/" response.html
Test Payloads:
# External Domain:
?redirect=https://evil.com/
?redirect=//evil.com/ ← Protocol-relative URL
?redirect=https:evil.com ← Missing slashes (some browsers!)
# Encoding Variants:
?redirect=%68%74%74%70%73%3a%2f%2fevil.com ← URL-encoded
?redirect=https://evil%2Ecom/ ← Dot-encoded
# Bypass attempts:
?redirect=https://legitimate-site.evil.com/
?redirect=https://legitimate-site.com.evil.com/
?redirect=https://legitimate-site.com@evil.com/
# JavaScript URI:
?redirect=javascript:alert(1)
?redirect=data:text/html,<script>alert(1)</script># SSRF via Redirect:
?redirect=http://169.254.169.254/latest/meta-data/
# For OAuth combination:
# Test redirect_uri with Open Redirect chain