CWE-601: Open Redirect - PHP
Overview
Open redirect vulnerabilities in PHP applications occur when user-controlled input is used in header("Location: ..."), <meta> refresh tags, or JavaScript redirects without validation, enabling phishing attacks and credential theft. Raw PHP and frameworks such as Laravel and Symfony all require careful handling of redirect destinations.
Primary Defence: Where the destination does not have to come from the request at all, use the indirect pattern below - a server-side map from an opaque key to a URL removes the weakness rather than constraining it, and no parser disagreement can apply to a value that is never parsed.
Where it does: for local redirects, validate that user-supplied URLs are relative paths by checking they start with / but not //, and use parse_url() to verify the host component is empty. For external redirects, use an explicit allowlist of permitted domains with exact host matching after parsing with parse_url(). Reject protocol-relative URLs (//evil.com), JavaScript URLs (javascript:), and ensure validation happens before calling header() or framework redirect methods. Always fail-closed with a safe default redirect when validation fails.
Common Vulnerable Patterns
Unvalidated Header Redirect
<?php
// VULNERABLE - No validation
session_start();
if (isset($_POST['username']) && isset($_POST['password'])) {
// Authenticate user...
$returnUrl = $_GET['returnUrl'];
header("Location: $returnUrl"); // Dangerous!
exit();
}
// Attack: login.php?returnUrl=https://evil.com/phishing
Why this is vulnerable:
$_GET['returnUrl']retrieves user-controlled input directly from URL parametersheader("Location: ...")accepts any URL without validation, including absolute URLs to attacker domains- Missing isset() check on
$returnUrlcauses undefined variable notice - No validation of protocol-relative URLs (
//evil.com), JavaScript URLs, or data URLs
Unvalidated Laravel Redirect
<?php
use Illuminate\Http\Request;
class LoginController extends Controller
{
// VULNERABLE - Direct redirect from request input
public function login(Request $request)
{
// Authenticate user...
$returnUrl = $request->input('returnUrl');
return redirect($returnUrl); // Vulnerable!
}
}
// Attack: /login?returnUrl=https://evil.com/fake-login
Why this is vulnerable:
$request->input('returnUrl')retrieves user input without validation- Laravel's
redirect()accepts absolute URLs without restriction when given a string - Missing default value means null redirect when parameter is missing
- No check for external domains or dangerous protocols
String-Based Validation
<?php
// VULNERABLE - Insufficient string checking
function unsafe_redirect($url) {
if (strpos($url, 'http://') === false &&
strpos($url, 'https://') === false) {
header("Location: $url");
exit();
}
header("Location: /");
exit();
}
// Attack: $url = "//evil.com/phishing"
// Protocol-relative URL bypasses the check
Why this is vulnerable:
strpos()check misses protocol-relative URLs like//evil.com- Case-sensitive check can be bypassed with
HTTP://orHttps:// - Doesn't prevent JavaScript URLs (
javascript:alert(1)) - No proper URL parsing to validate structure
Secure Patterns
Validate Local URLs
<?php
// SECURE - same-site path check with control characters and backslashes rejected
function is_local_url($url) {
if (empty($url) || !is_string($url)) {
return false;
}
// Browsers delete tab, CR and LF from a URL before resolving it, so
// "/<tab>/evil.com" arrives at the browser as "//evil.com". parse_url()
// keeps them and reports an ordinary path, and header() passes a tab
// through unchanged, so reject control characters outright.
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
return false;
}
// Browsers treat a backslash as a path separator when resolving a
// Location header, so /\evil.com is protocol-relative to them.
// parse_url() does not, so normalize before parsing.
$normalized = str_replace('\\', '/', $url);
$parsed = parse_url($normalized);
// Must not have host or scheme (relative URL only)
if ($parsed === false || isset($parsed['host']) || isset($parsed['scheme'])) {
return false;
}
// Must start with / but not //
if (!str_starts_with($normalized, '/') || str_starts_with($normalized, '//')) {
return false;
}
return true;
}
session_start();
if (isset($_POST['username']) && isset($_POST['password'])) {
// Authenticate user...
$returnUrl = $_GET['returnUrl'] ?? '/';
if (is_local_url($returnUrl)) {
header("Location: $returnUrl");
} else {
header("Location: /"); // Safe default
}
exit();
}
Why this works:
parse_url()splits the URL into components (scheme, host, path and the rest) instead of matching on substringsisset($parsed['host'])check ensures no domain is present, rejecting absolute URLs and protocol-relative URLs like//evil.comisset($parsed['scheme'])blocks JavaScript URLs (javascript:), data URLs (data:), file URLs (file:)str_starts_with('/')ensures URL is a valid relative path (PHP 8+)!str_starts_with('//')prevents protocol-relative URL bypassstr_replace('\\', '/')closes the backslash form of that same bypass: browsers convert\to/while resolving a URL, so/\evil.comand\\evil.comreachhttps://evil.com, whileparse_url()reports both as an ordinary path with no host- The control-character check closes the third spelling. A browser deletes tab, CR and LF from a URL before parsing it, so
/%09/evil.com- which arrives in$_GETalready decoded to a real tab - is resolved as//evil.com. Measured on PHP 8.5.8:header()rejects CR and LF but writes a tab into the header unchanged, andparse_url()reports/<tab>/evil.comas a path with no host, so without this check the value passes validation and reachesevil.com $parsed === falsehandles the seriously malformed input thatparse_url()refuses outright, so it cannot fall through theisset()checks as if no host were present- Null coalescing operator
??provides safe default when parameter is missing empty()andis_string()checks prevent type juggling vulnerabilities- Fail-closed behavior redirects to
/when validation fails
Laravel: Validate and Redirect
<?php
// SECURE - Laravel: same check inside the controller
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class LoginController extends Controller
{
private function isLocalUrl($url)
{
if (empty($url) || !is_string($url)) {
return false;
}
// Browsers delete tab, CR and LF before resolving a URL, so
// "/<tab>/evil.com" is read as "//evil.com"
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
return false;
}
// Normalize backslashes first - browsers resolve /\evil.com as
// protocol-relative, parse_url() reports it as a path
$normalized = str_replace('\\', '/', $url);
$parsed = parse_url($normalized);
// No host or scheme (relative only)
if ($parsed === false || isset($parsed['host']) || isset($parsed['scheme'])) {
return false;
}
// Must start with /
return str_starts_with($normalized, '/') && !str_starts_with($normalized, '//');
}
public function login(Request $request)
{
// Authenticate user...
$returnUrl = $request->input('returnUrl', '/');
if ($this->isLocalUrl($returnUrl)) {
return redirect($returnUrl);
}
return redirect('/');
}
}
Why this works:
- Same URL validation logic as raw PHP example using
parse_url() - Laravel's
$request->input('returnUrl', '/')provides safe default redirect()helper works safely with validated relative URLs- Fail-closed behavior returns
redirect('/')for invalid URLs - The check lives in one private method, so every redirect path in the controller goes through it
Allowlist External Domains
<?php
// SECURE - allowlist of external hosts, exact match after parsing
define('ALLOWED_DOMAINS', [
'example.com',
'www.example.com',
'partner.example.org'
]);
function is_allowed_url($url) {
if (empty($url) || !is_string($url)) {
return false;
}
// Browsers delete tab, CR and LF before resolving a URL, so
// "/<tab>/evil.com" is read as "//evil.com"
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
return false;
}
// Normalize backslashes first - browsers resolve /\evil.com as
// protocol-relative, parse_url() reports it as a path
$normalized = str_replace('\\', '/', $url);
$parsed = parse_url($normalized);
if ($parsed === false) {
return false;
}
// Allow relative URLs (no host)
if (!isset($parsed['host'])) {
return str_starts_with($normalized, '/') && !str_starts_with($normalized, '//');
}
// For absolute URLs, check scheme and host
if (!isset($parsed['scheme']) ||
!in_array($parsed['scheme'], ['http', 'https'], true)) {
return false;
}
// Exact host match (case-insensitive); reject userinfo and custom ports
if (isset($parsed['user']) || isset($parsed['pass']) || isset($parsed['port'])) {
return false;
}
return in_array(strtolower($parsed['host']), ALLOWED_DOMAINS, true);
}
$targetUrl = $_GET['url'] ?? '/';
if (is_allowed_url($targetUrl)) {
header("Location: $targetUrl");
} else {
header("Location: /");
}
exit();
Why this works:
- Relative URLs and allowlisted external domains take separate paths through the check
strtolower($parsed['host'])performs case-insensitive exact host matching after parsing- Rejecting userinfo and custom ports avoids ambiguous external redirect destinations
in_array(..., true)uses strict comparison to prevent type juggling$parsed['scheme']check with allowlist blocks JavaScript URLs, data URLs, file URLsdefine()creates immutable constant for allowed domains- Fail-closed default redirects to
/for invalid/unlisted domains
Indirect Redirects (Best Practice)
<?php
// SECURE - indirect redirect: the request carries a key, never a URL
const REDIRECT_MAP = [
'dashboard' => '/dashboard',
'profile' => '/user/profile',
'settings' => '/user/settings'
];
$dest = $_GET['dest'] ?? null;
if ($dest && isset(REDIRECT_MAP[$dest])) {
header("Location: " . REDIRECT_MAP[$dest]);
} else {
header("Location: /");
}
exit();
Why this works:
- Eliminates URL injection entirely - the request carries a key that is looked up in the map, never a URL
- Invalid keys (like
'<script>alert(1)</script>'or'../../etc/passwd') won't exist in array isset()check safely handles missing keys without warnings- Fail-closed behavior redirects to
/for invalid/missing destination IDs - Encoding bypasses, protocol tricks and domain manipulation have nothing in the request to act on
- Easy to audit - review the REDIRECT_MAP array and keep mapped destinations local or explicitly trusted
Symfony Framework Pattern
<?php
// SECURE - Symfony: same check inside the controller
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class LoginController extends AbstractController
{
private function isLocalUrl(string $url): bool
{
if (empty($url)) {
return false;
}
// Browsers delete tab, CR and LF before resolving a URL, so
// "/<tab>/evil.com" is read as "//evil.com"
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
return false;
}
// Normalize backslashes first - browsers resolve /\evil.com as
// protocol-relative, parse_url() reports it as a path
$normalized = str_replace('\\', '/', $url);
$parsed = parse_url($normalized);
if ($parsed === false || isset($parsed['host']) || isset($parsed['scheme'])) {
return false;
}
return str_starts_with($normalized, '/') && !str_starts_with($normalized, '//');
}
#[Route('/login', name: 'login')]
public function login(Request $request): Response
{
// Authenticate...
$returnUrl = $request->query->get('returnUrl', '/');
if ($this->isLocalUrl($returnUrl)) {
return $this->redirect($returnUrl);
}
return $this->redirectToRoute('home');
}
}
Why this works:
- Symfony's
Requestobject provides safe parameter access - Same
parse_url()validation logic ensures consistency $this->redirect()works safely with validated relative URLsredirectToRoute('home')uses named routes for fail-closed behavior- Type hints (
string $url,: bool) provide additional safety
Warning Page for External URLs
<?php
// SECURE - interstitial for allowlisted external destinations
const ALLOWED_DOMAINS = ['example.com', 'partner.example.org'];
function is_allowed_url($url) {
if (empty($url) || !is_string($url)) {
return false;
}
// Browsers delete tab, CR and LF before resolving a URL, so
// "/<tab>/evil.com" is read as "//evil.com"
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
return false;
}
// Normalize backslashes first - browsers resolve /\evil.com as
// protocol-relative, parse_url() reports it as a path
$normalized = str_replace('\\', '/', $url);
$parsed = parse_url($normalized);
if ($parsed === false) {
return false;
}
// Allow local
if (!isset($parsed['host'])) {
return str_starts_with($normalized, '/') && !str_starts_with($normalized, '//');
}
// Check external allowlist. Reject userinfo and custom ports for the same
// reason as the allowlist above, and more sharply here: this page exists so
// the user can read the destination, and "https://example.com@partner.example.org"
// reads as example.com while pointing somewhere else.
if (isset($parsed['user']) || isset($parsed['pass']) || isset($parsed['port'])) {
return false;
}
return isset($parsed['scheme']) &&
in_array($parsed['scheme'], ['http', 'https'], true) &&
in_array(strtolower($parsed['host']), ALLOWED_DOMAINS, true);
}
$targetUrl = $_GET['url'] ?? null;
// Browsers delete tab, CR and LF before resolving a URL, so "/<tab>/evil.com"
// is read as "//evil.com". Reject control characters before anything parses.
if (!$targetUrl || preg_match('/[\x00-\x1F\x7F]/', $targetUrl) === 1) {
header("Location: /");
exit();
}
// Check if local, on the backslash-normalized form
$normalized = str_replace('\\', '/', $targetUrl);
$parsed = parse_url($normalized);
if ($parsed !== false &&
!isset($parsed['host']) &&
str_starts_with($normalized, '/') &&
!str_starts_with($normalized, '//')) {
header("Location: $targetUrl");
exit();
}
// Check if allowed external
if (is_allowed_url($targetUrl)) {
// Show warning page
$escapedUrl = htmlspecialchars($targetUrl, ENT_QUOTES, 'UTF-8');
echo <<<HTML
<!DOCTYPE html>
<html>
<head><title>Leaving Our Site</title></head>
<body>
<h2>You are leaving our site</h2>
<p>You are about to visit: {$escapedUrl}</p>
<a href="{$escapedUrl}">Continue to external site</a>
<a href="/">Stay here</a>
</body>
</html>
HTML;
exit();
}
// Invalid - go home
header("Location: /");
exit();
Why this works:
- Interstitial warning breaks automatic phishing redirect chain
htmlspecialchars()prevents XSS when displaying destination URLENT_QUOTESflag escapes both single and double quotes- Requires explicit user click to proceed to external site
- Provides clear escape option ("Stay here") for suspicious redirects
- Only shown for external allowlisted URLs - local redirects are seamless
- Combines validation with user awareness for defense-in-depth
PHP 7.x Compatible (Without str_starts_with)
<?php
// SECURE - PHP 7.x spelling of the same check
// For PHP < 8.0
function is_local_url($url) {
if (empty($url) || !is_string($url)) {
return false;
}
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
return false;
}
$normalized = str_replace('\\', '/', $url);
$parsed = parse_url($normalized);
if ($parsed === false || isset($parsed['host']) || isset($parsed['scheme'])) {
return false;
}
// PHP 7.x compatible string check
if (substr($normalized, 0, 1) !== '/' || substr($normalized, 0, 2) === '//') {
return false;
}
return true;
}
Why this works:
substr($normalized, 0, 1) !== '/'checks first character is forward slashsubstr($normalized, 0, 2) === '//'rejects protocol-relative URLs, including the/\evil.comform once backslashes are normalized- Same security properties as PHP 8 version using
str_starts_with() - Compatible with PHP 7.x environments, though PHP 7 itself reached end of life in November 2022 and should be upgraded rather than accommodated
Common Pitfalls
- Using
filter_var($url, FILTER_VALIDATE_URL)as the fix. This filter only confirms the string is a syntactically well-formed URL - it says nothing about which host it points to, sohttps://evil.compasses just as easily ashttps://trusted-site.com. It's a common misconception that PHP's URL "validation" filter does domain checking; it doesn't. - Using Laravel's
redirect()->away($url)for what looks like the framework's official external-redirect helper.away()is documented to skip URL encoding, validation, and verification entirely - it exists for redirecting to a URL your own code already trusts, not as a safer alternative to a plainredirect($url)call. - Switching the source of the redirect value from
$_GET['returnUrl']to$_SERVER['HTTP_REFERER']on the reasoning that "it's not directly attacker-supplied." The Referer header is still fully client-controlled and trivially spoofed by any HTTP client - changing where the value comes from doesn't change that it's untrusted.