These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP SecurityOpen Redirect

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=php

Test inputs

Direct external redirect:

  • https://evil.com
  • https://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.com
  • https://target.com.evil.com
  • https://evil.com#@target.com
  • https://evil.com?@target.com

Parser quirks:

  • https:evil.com (Firefox parses as https://evil.com)
  • https:/\evil.com
  • javascript:alert(1) — when sink is an HTML href, not Location:
  • 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/cb
  • https://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:

  1. Source of the destination — usually next / redirect / url / returnTo / r / dest. Build a list from grep and trace each.
  2. 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.
  3. Parser usedparse_url("javascript:alert(1)//evil.com") returns unexpected fields. Don’t trust parse_url alone; combine with filter_var($url, FILTER_VALIDATE_URL) and explicit scheme check.
  4. HTML sink overlap — does the same parameter also end up in HTML (<a href="$url"> / window.location =)? Then javascript: is in scope.
  5. Auth-flow context — OAuth redirect_uri, SSO RelayState, MFA next — 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.