Open Redirect Remediation
Open redirects allow attackers to redirect users to malicious sites using your trusted domain. While often considered low severity, they enable phishing attacks and can be chained with other vulnerabilities.
Phishing Enabler
trusted-bank.com/redirect?url=evil.com
appears legitimate but redirects to a credential harvesting site.
Open Redirect Defense
Understanding Open Redirects
Vulnerable Patterns
/login?redirect=https://evil.com
/go?url=//evil.com
//evil.com
https://trusted.com@evil.com
/\/evil.com
Remediation Strategies
1. Allowlist Domains
Only redirect to known, trusted domains.
2. Use Relative Paths
Only allow redirects to paths within your own site.
3. Indirect References
Map IDs to URLs server-side instead of accepting URLs directly.
Python (Flask)
from urllib.parse import urlparse, urljoin
from flask import request, redirect, url_for
ALLOWED_HOSTS = {'mysite.com', 'www.mysite.com', 'app.mysite.com'}
def is_safe_url(target):
"""Check if URL is safe for redirect."""
if not target:
return False
# Parse the target URL
parsed = urlparse(target)
# Allow relative URLs (no scheme or netloc)
if not parsed.scheme and not parsed.netloc:
# Ensure it doesn't start with // (protocol-relative)
if target.startswith('//') or target.startswith('\/'):
return False
return True
# For absolute URLs, check against allowlist
if parsed.scheme in ('http', 'https'):
return parsed.netloc in ALLOWED_HOSTS
return False
@app.route('/redirect')
def safe_redirect():
next_url = request.args.get('next', '/')
if not is_safe_url(next_url):
return redirect('/') # Default to home
return redirect(next_url)Node.js (Express)
const url = require('url');
const ALLOWED_HOSTS = new Set([
'mysite.com',
'www.mysite.com',
'app.mysite.com'
]);
function isSafeUrl(target, req) {
if (!target) return false;
// Check for protocol-relative URLs
if (target.startsWith('//') || target.startsWith('\\/')) {
return false;
}
try {
const reqHost = req.get('host');
const parsed = new URL(target, 'https://' + reqHost);
// If it's a relative URL, the host will be the request host
if (parsed.host === reqHost) {
return true;
}
// Check against allowlist for absolute URLs
return ALLOWED_HOSTS.has(parsed.host);
} catch (e) {
return false;
}
}
app.get('/redirect', (req, res) => {
const nextUrl = req.query.next || '/';
if (!isSafeUrl(nextUrl, req)) {
return res.redirect('/');
}
res.redirect(nextUrl);
});PHP
<?php
$ALLOWED_HOSTS = ['mysite.com', 'www.mysite.com', 'app.mysite.com'];
function isSafeUrl($target) {
global $ALLOWED_HOSTS;
if (empty($target)) {
return false;
}
// Block protocol-relative URLs
if (preg_match('/^[\\\/]{2}/', $target)) {
return false;
}
$parsed = parse_url($target);
// Allow relative URLs (no scheme and no host)
if (!isset($parsed['scheme']) && !isset($parsed['host'])) {
return true;
}
// For absolute URLs, validate host
if (isset($parsed['host'])) {
return in_array($parsed['host'], $ALLOWED_HOSTS, true);
}
return false;
}
$next = $_GET['next'] ?? '/';
if (!isSafeUrl($next)) {
header('Location: /');
exit;
}
header('Location: ' . $next);
exit;
?>Alternative: Indirect References
# Instead of accepting URLs directly, use IDs
REDIRECT_MAPPING = {
'dashboard': '/dashboard',
'profile': '/user/profile',
'settings': '/user/settings',
'docs': 'https://docs.mysite.com/',
}
@app.route('/go/<redirect_id>')
def indirect_redirect(redirect_id):
target = REDIRECT_MAPPING.get(redirect_id)
if not target:
return redirect('/')
return redirect(target)Testing Verification
Open Redirect Test Payloads
# Basic external redirects - should be blocked
https://evil.com
http://evil.com
//evil.com
# Protocol-relative with variations
\/\/evil.com
/\/evil.com
# URL with credentials (bypass attempt)
https://trusted.com@evil.com
https://evil.com#@trusted.com
# Encoded payloads
https:%2f%2fevil.com
%2f%2fevil.com
# Data/javascript URLs
data:text/html,<script>alert(1)</script>
javascript:alert(1)
# Path confusion
/https://evil.com
/.evil.com
# Expected: All should redirect to safe default or be blockedCommon Mistakes
Simple String Check
# DON'T: Easily bypassed
if url.startswith('https://mysite'):
redirect(url)
# Bypassed with: https://mysite.evil.comCorrect Approach
# DO: Parse and validate host
parsed = urlparse(url)
if parsed.netloc in ALLOWED_HOSTS:
redirect(url)Forgetting Protocol-Relative
# DON'T: Only check http/https
if not url.startswith(('http://', 'https://')):
# Assume it's relative
# Bypassed with: //evil.comCorrect Approach
# DO: Block protocol-relative URLs
if url.startswith('//') or url.startswith('\\/'):
reject(url)Open Redirect Prevention Checklist
- ☐ URL parsed properly before validation
- ☐ Host/domain validated against allowlist
- ☐ Protocol-relative URLs blocked (//evil.com)
- ☐ Relative paths validated (no /\/, etc.)
- ☐ URL encoding handled correctly
- ☐ Fallback to safe default for invalid URLs
- ☐ Consider using indirect references instead
Open Redirect Labs
Practice identifying and fixing open redirect vulnerabilities.
Evidence to Capture
The 302 itself: the request with the crafted next or returnUrl value and the response with its full Location header pointing off-site.
The click path: Both requests in order — the trusted link and the resulting navigation — so the trust hop a victim would follow is visible without the reader reconstructing it.
Accepted variants: Which encodings passed: protocol-relative, backslash form, userinfo form, percent-encoded scheme, and a target appended after an existing query parameter. List the rejected ones too.
What rides on the redirect: Whether the redirect carries a token, a reset code or an OAuth authorization code, and whether it fires before or after authentication, since that sets the severity.
Likely False Positives
- Relative targets only: If a protocol-relative and an absolute off-site target are both rejected or rewritten to a local path, there is no open redirect. The allowlist is working.
- Partner domain on an allowlist: Redirecting to a documented partner is by design. It becomes a finding only if the allowlist is matched loosely, or if the partner itself has an open redirect.
- Same-host path change: A 302 to a different path on the same origin has no phishing value and no token-exfiltration use.
- Externally validated OAuth redirect_uri: An exact match against a registered absolute URI is the control. A prefix or subdomain-wildcard match is the finding.
- Client-side navigation to a fixed URL: A hardcoded logout or home redirect that ignores the parameter is not user controlled; check the code before reporting the parameter as the cause.
Testing Checklist
Discovery
- ☐ Enumerate every parameter that influences navigation: next, return, redirect, url, continue, callback, and any client-side assignment from a query value.
- ☐ Note the intended destinations — login, SSO, checkout, unsubscribe — because those determine which endpoints plausibly trust the value.
- ☐ Check whether the same parameter is used in an email link, which makes the phishing path one click from an inbox.
Confirmation
- ☐ Send protocol-relative, backslash, userinfo and percent-encoded targets, and follow the Location header manually rather than with redirects enabled.
- ☐ Append the target after an existing parameter and after a fragment marker, since many parsers take the last value.
- ☐ Retest with the scheme split across encoding layers, and confirm the response now rewrites or rejects.
Impact
- ☐ Show the trusted URL landing on a page you control, and state whether the victim sees a plausible origin in the address bar before the hop.
- ☐ Where a token or code is carried, demonstrate that it reaches your server, which upgrades the finding from phishing to account takeover.
- ☐ Note whether the redirect is reachable unauthenticated, since that is what makes mass phishing practical.
Remediation Summary
Structural fix
- • Do not redirect to a user-supplied absolute URL. Accept a relative path only, resolve it against the application root, and reject anything that escapes it.
- • Where an external destination is required, match the value against a server-side map of permitted destinations and build the Location header from the mapped value.
- • For OAuth, require an exact string match against a registered redirect URI, with no wildcard or prefix comparison.
Compensating control
- • Insert an interstitial warning page for off-site navigation, so a forged link still requires the victim to confirm.
- • Sign the redirect target and verify the signature, so a tampered value is rejected before the header is written.
- • Keep the parameter out of email templates and set a restrictive Referrer-Policy on redirect responses.
CWE References: CWE-601