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 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;
// 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
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 casesisset($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 bypass- 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
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 matchingin_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
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
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 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
// 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 slashsubstr($url, 0, 2) === '//'rejects protocol-relative URLs- Same security properties as PHP 8 version using
str_starts_with() - Compatible with PHP 7.x environments