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 proper validation, enabling phishing attacks and credential theft. Both raw PHP and frameworks like Laravel, Symfony require careful handling of redirect destinations.

Primary Defence: 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;

// 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
function is_local_url($url) {
    if (empty($url) || !is_string($url)) {
        return false;
    }

    // Parse URL
    $parsed = parse_url($url);

    // Must not have host or scheme (relative URL only)
    if (isset($parsed['host']) || isset($parsed['scheme'])) {
        return false;
    }

    // Must start with / but not //
    if (!str_starts_with($url, '/') || str_starts_with($url, '//')) {
        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() properly parses URLs into components (scheme, host, path, etc.), handling edge cases
  • 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
  • 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
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class LoginController extends Controller
{
    private function isLocalUrl($url)
    {
        if (empty($url) || !is_string($url)) {
            return false;
        }

        $parsed = parse_url($url);

        // No host or scheme (relative only)
        if (isset($parsed['host']) || isset($parsed['scheme'])) {
            return false;
        }

        // Must start with /
        return str_starts_with($url, '/') && !str_starts_with($url, '//');
    }

    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
  • Object-oriented structure makes validation reusable across controllers

Allowlist External Domains

<?php
define('ALLOWED_DOMAINS', [
    'example.com',
    'www.example.com',
    'partner.example.org'
]);

function is_allowed_url($url) {
    if (empty($url) || !is_string($url)) {
        return false;
    }

    $parsed = parse_url($url);

    // Allow relative URLs (no host)
    if (!isset($parsed['host'])) {
        return str_starts_with($url, '/') && !str_starts_with($url, '//');
    }

    // 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)
    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:

  • Combines relative URL validation with external domain allowlist for flexibility
  • strtolower($parsed['host']) performs case-insensitive exact host matching
  • in_array(..., true) uses strict comparison to prevent type juggling
  • $parsed['scheme'] check with allowlist blocks JavaScript URLs, data URLs, file URLs
  • Separate validation for relative vs absolute URLs ensures proper handling
  • define() creates immutable constant for allowed domains
  • Fail-closed default redirects to / for invalid/unlisted domains

Indirect Redirects (Best Practice)

<?php
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 - users provide string keys, not URLs
  • Array lookup is safe with no injection risk
  • 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
  • Immune to encoding bypasses, protocol tricks, and domain manipulation
  • Easiest pattern to audit - just review the REDIRECT_MAP array

Symfony Framework Pattern

<?php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class LoginController extends AbstractController
{
    private function isLocalUrl(string $url): bool
    {
        if (empty($url)) {
            return false;
        }

        $parsed = parse_url($url);

        if (isset($parsed['host']) || isset($parsed['scheme'])) {
            return false;
        }

        return str_starts_with($url, '/') && !str_starts_with($url, '//');
    }

    #[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
const ALLOWED_DOMAINS = ['example.com', 'partner.example.org'];

function is_allowed_url($url) {
    if (empty($url) || !is_string($url)) {
        return false;
    }

    $parsed = parse_url($url);

    // Allow local
    if (!isset($parsed['host'])) {
        return str_starts_with($url, '/') && !str_starts_with($url, '//');
    }

    // Check external allowlist
    return isset($parsed['scheme']) &&
           in_array($parsed['scheme'], ['http', 'https'], true) &&
           in_array(strtolower($parsed['host']), ALLOWED_DOMAINS, true);
}

$targetUrl = $_GET['url'] ?? null;

if (!$targetUrl) {
    header("Location: /");
    exit();
}

// Check if local
$parsed = parse_url($targetUrl);
if (!isset($parsed['host']) && 
    str_starts_with($targetUrl, '/') && 
    !str_starts_with($targetUrl, '//')) {
    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
// For PHP < 8.0
function is_local_url($url) {
    if (empty($url) || !is_string($url)) {
        return false;
    }

    $parsed = parse_url($url);

    if (isset($parsed['host']) || isset($parsed['scheme'])) {
        return false;
    }

    // PHP 7.x compatible string check
    if (substr($url, 0, 1) !== '/' || substr($url, 0, 2) === '//') {
        return false;
    }

    return true;
}

Why this works:

  • substr($url, 0, 1) !== '/' checks first character is forward slash
  • substr($url, 0, 2) === '//' rejects protocol-relative URLs
  • Same security properties as PHP 8 version using str_starts_with()
  • Compatible with PHP 7.x environments

Additional Resources