Open Redirect
A redirect to an attacker-chosen URL is a phishing primitive: the URL bar
shows your trusted domain until the redirect fires. It chains with OAuth
(steal code / id_token), SSO (steal SAMLResponse),
password-reset-link interception, and CSRF-protected flows that whitelist
your domain.
Why
The shape:
header("Location: " . $_GET['next']);?next=https://evil.com — done. Same outcome with framework helpers
(redirect($url), RedirectResponse).
Worse: when the same parameter also ends up in HTML (<a href="$url">),
the bug graduates from open redirect to reflected XSS via the
javascript: scheme.
Search patterns
rg -n 'header\s*\(\s*[''"]Location:' --type=php
# Framework redirect helpers
rg -n '\bredirect\s*\(' --type=php
rg -n 'RedirectResponse|->redirect\(|response\(\)->redirect\(' --type=php
# Common parameter names — start the audit with these
rg -n '\$_(GET|POST|REQUEST)\[[''"](next|redirect|url|returnTo|return_url|continue|r|dest|destination|ref|referrer|goto|forward|callback|redirect_uri)[''"]\]' --type=phpTest inputs
Direct external redirect:
https://evil.comhttps://evil.com/login(looks like an SSO callback)
Protocol-relative tricks:
//evil.com///evil.com\\evil.com(some routers normalise\to/)\/\/evil.com\/\\evil.com
Userinfo / subdomain confusion:
https://target.com@evil.comhttps://target.com.evil.comhttps://evil.com#@target.comhttps://evil.com?@target.com
Parser quirks:
https:evil.com(Firefox parses ashttps://evil.com)https:/\evil.comjavascript:alert(1)— when sink is an HTML href, notLocation:data:text/html,<script>alert(1)</script>— same
Encoding bypasses (against denylist filters):
%2F%2Fevil.com→//evil.com%252F%252Fevil.com(double-encoded)/evil.com(Unicode — only matters when JS parser reads it)
OAuth redirect_uri specifics:
https://target.com/path?redirect_uri=https://evil.com/cbhttps://target.com/path?redirect_uri=https%3A%2F%2Fevil.com%2Fcb- Subdomain wildcards too lax:
https://anything.target.com/cb
Audit focus
For each redirect call:
- Source of the destination — usually
next/redirect/url/returnTo/r/dest. Build a list from grep and trace each. - Validation shape:
- Allowlist of absolute paths (only
/...allowed) — safe. - Allowlist of hostnames — safe if parsed correctly.
- Denylist of substrings — almost always bypassable.
- No validation — the canonical bug.
- Allowlist of absolute paths (only
- Parser used —
parse_url("javascript:alert(1)//evil.com")returns unexpected fields. Don’t trustparse_urlalone; combine withfilter_var($url, FILTER_VALIDATE_URL)and explicit scheme check. - HTML sink overlap — does the same parameter also end up in HTML
(
<a href="$url">/window.location =)? Thenjavascript:is in scope. - Auth-flow context — OAuth
redirect_uri, SSORelayState, MFAnext— these are higher severity because the redirect carries a secret.
An “open redirect to a path-only target” feels low impact, but it’s the oil that makes phishing, OAuth-theft, and CSRF-allowlist-bypass chains work. Always report it.
Fix
For internal navigation — allowlist paths:
$allowed = ['/dashboard', '/account', '/'];
$next = $_GET['next'] ?? '/';
if (!in_array($next, $allowed, true)) {
$next = '/';
}
header("Location: $next");
exit;When external destinations are genuinely needed (OAuth, SSO, marketplace links) — allowlist hosts and re-parse the URL:
$next = $_GET['next'] ?? '';
$parts = parse_url($next);
if (
!isset($parts['scheme'], $parts['host']) ||
!in_array($parts['scheme'], ['https'], true) ||
!in_array($parts['host'], ['target.com', 'docs.target.com'], true)
) {
$next = '/';
}
header("Location: $next");
exit;Reject anything containing \ or starting with // before parsing.