Skip to content

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 parameters
  • header("Location: ...") accepts any URL without validation, including absolute URLs to attacker domains
  • Missing isset() check on $returnUrl causes 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:// or Https://
  • 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 substrings
  • isset($parsed['host']) check ensures no domain is present, rejecting absolute URLs and protocol-relative URLs like //evil.com
  • isset($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 bypass
  • str_replace('\\', '/') closes the backslash form of that same bypass: browsers convert \ to / while resolving a URL, so /\evil.com and \\evil.com reach https://evil.com, while parse_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 $_GET already 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, and parse_url() reports /<tab>/evil.com as a path with no host, so without this check the value passes validation and reaches evil.com
  • $parsed === false handles the seriously malformed input that parse_url() refuses outright, so it cannot fall through the isset() checks as if no host were present
  • Null coalescing operator ?? provides safe default when parameter is missing
  • empty() and is_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 URLs
  • define() 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 Request object provides safe parameter access
  • Same parse_url() validation logic ensures consistency
  • $this->redirect() works safely with validated relative URLs
  • redirectToRoute('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 URL
  • ENT_QUOTES flag 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 slash
  • substr($normalized, 0, 2) === '//' rejects protocol-relative URLs, including the /\evil.com form 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, so https://evil.com passes just as easily as https://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 plain redirect($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.

Additional Resources