Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix open redirects in PHP and Laravel

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Treat next as untrusted: resolve it against the current URL with PHP 8.5's Uri\WhatWg\Url::parse(), the parser browsers use, and redirect()->away() to the serialized result only when its scheme is https and its host is on your allowlist. Otherwise use redirect()->intended('/account'). Never pass request data to redirect(), to(), away() or intended().

Stack
  • Language PHP
  • Runtime PHP 8.5.11 (CLI)
  • Framework Laravel 13.34.0 (sessions on SQLite)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 55 KB
On this page

Steps

  1. Find every redirect built from request data: redirect($request->...), redirect()->to(), away(), intended($request->...), session()->put('url.intended', ...) or redirect()->setIntendedUrl() with input, and Location headers set by hand.
  2. Prefer keeping the return address on the server: the auth middleware stores the page the visitor asked for in the session (url.intended), and redirect()->intended('/account') returns there after sign-in.
  3. When a return URL must travel in a link (?next=...), resolve it with Url::parse($next, new Url($request->url())) and accept it only if getScheme() is https and getAsciiHost() is one of your hosts.
  4. Redirect to $url->toAsciiString(), the URL you checked as the parser serialized it, with redirect()->away(), not to the raw input: what you checked and what the browser follows are then the same string.
  5. Send everything else to a fixed page, and add regression tests with this card's payloads: //host, ///host, backslashes, user info, look-alike hosts and javascript: URLs.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableapp/Auth/VulnerableLoginRedirect.phpPHP
public function redirectAfterLogin(Request $request, ?string $next): RedirectResponse
{
    // intended() returns to the page the auth middleware remembered in the session, and
    // otherwise to its default: here, whatever the link put in next.
    return redirect()->intended($next ?: '/account');
}

With IMPL=vulnerable, 6 of 16 tests fail (6 exploit tests).

The fix

Fixedapp/Auth/FixedLoginRedirect.phpPHP
private const ALLOWED_HOSTS = ['app.example.test', 'docs.example.test'];

public function redirectAfterLogin(Request $request, ?string $next): RedirectResponse
{
    $target = $next !== null && $next !== '' ? $this->allowedTarget($request, $next) : null;

    // Nothing usable: the page the auth middleware remembered, or the account page.
    return $target !== null ? redirect()->away($target) : redirect()->intended('/account');
}

private function allowedTarget(Request $request, string $next): ?string
{
    // Relative values resolve against this page, as they will in the browser.
    $url = Url::parse($next, new Url($request->url()));
    if ($url === null || $url->getScheme() !== 'https' || ! in_array($url->getAsciiHost(), self::ALLOWED_HOSTS, true)) {
        return null;
    }

    return $url->toAsciiString();
}

With IMPL=fixed, 16 of 16 tests pass.

A fix that looks safe but is not

Wrong fixapp/Auth/WrongLoginRedirect.phpPHP
public function redirectAfterLogin(Request $request, ?string $next): RedirectResponse
{
    return redirect()->to($this->checkedTarget($next));
}

private function checkedTarget(?string $next): string
{
    if ($next === null || $next === '') {
        return '/account';
    }
    // A path on this site...
    if (str_starts_with($next, '/')) {
        return $next;
    }
    // ...or a full link back to one of our sites, app.example.test or docs.example.test.
    if (str_contains((string) parse_url($next, PHP_URL_HOST), 'example.test')) {
        return $next;
    }

    return '/account';
}

With IMPL=wrong, 4 of 16 tests fail (4 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Feature/RedirectTest.phpPHP
// Each value arrives in a link to the real sign-in page, such as /login?next=%2F%2Fevil.example%2F.
// After a genuine sign-in the browser must still be on this site or the allowlisted docs site.
public static function attacks(): array
{
    return [
        'an absolute URL to another site' => ['https://evil.example/'],
        'a scheme-relative URL' => ['//evil.example/'],
        'three slashes' => ['///evil.example/'],
        'a backslash after the slash' => ['/\\evil.example/'],
        'two backslashes' => ['\\\\evil.example/'],
        'our host name as a user name' => ['https://app.example.test@evil.example/'],
        'a look-alike host that contains an allowed name' => ['https://docs.example.test.evil.example/'],
        'a backslash before the @' => ['https://evil.example\\@docs.example.test/'],
        'encoded slashes' => ['/%2F%2Fevil.example/'],
        'a javascript: URL' => ['javascript:alert(document.domain)'],
    ];
}

#[DataProvider('attacks')]
public function test_exploit_lands_on_an_allowed_site(string $next): void
{
    $response = $this->signInWithNext($next);
    $origin = self::origin($this->landing($response));
    $this->assertContains($origin, self::ALLOWED_ORIGINS,
        "next=$next sent the browser to {$response->headers->get('Location')} (origin ".($origin ?? 'none').')');
}

Pitfalls

  • redirect()->to() and redirect() pass through anything that starts with //, http://, https://, mailto:, tel:, sms: or # or passes FILTER_VALIDATE_URL, and prefix the app's root to everything else. So //evil.example/ and ///evil.example/ leave the site, while /\evil.example/ and javascript:alert(1) become harmless paths on it. This card checked each payload on Laravel 13.34.0.
  • redirect()->intended($default) prefers the URL the auth middleware remembered, but a visitor who arrives straight from a link has none, so $default decides. That is this card's vulnerable implementation: the default comes from the request.
  • str_starts_with($next, '/') accepts //evil.example/, and a host check with str_contains() accepts docs.example.test.evil.example; that is this card's wrong implementation.
  • parse_url() is not the WHATWG parser browsers use: it reads https://evil.example\@docs.example.test/ as user evil.example\ at host docs.example.test, while a browser goes to evil.example.
  • redirect()->away() does no validation at all, by design: the Laravel docs describe it as redirecting without any additional URL encoding, validation or verification.
  • url()->previous() and redirect()->back() use the Referer header as given, and redirect()->guest() stores it as the intended URL when the unauthenticated request was not a GET.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs open-redirect/php-laravel does this for you in the pinned composer image, so it needs Docker).
  2. Against your own sign-in, follow links to /login?next=%2F%2Fevil.example%2F, ?next=https://evil.example/ and ?next=https://your.host.evil.example/, sign in, and check that the Location header resolves to your own origin each time.
  3. grep for redirect(, ->to(, ->away(, ->intended( and setIntendedUrl( fed from $request; Semgrep's PHP rules have no Laravel redirect sink, while Psalm with the Laravel plugin flagged both broken implementations.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix open redirects in PHP and Laravel
ToolRuleWhat it found here
Semgrepphp.lang.security.redirect-to-request-uri.redirect-to-request-uriNo findings, and none expected: it matches header('Location: ' . $_SERVER['REQUEST_URI']) only. A probe that sends header('Location: ' . $_GET['next']) was not flagged by any PHP rule either.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3)
Semgrepr/php (every PHP rule in the Semgrep registry, 47 rules)No findings on any implementation: no PHP rule in the registry models redirect(), redirect()->to(), away() or intended().Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/php app routes resources config bootstrap
PsalmTaintedHeaderPsalm taint analysis with the Laravel plugin1 TaintedHeader issue in VulnerableLoginRedirect.php (intended($next)) and 1 in WrongLoginRedirect.php (redirect()->to()), none in FixedLoginRedirect.php. The fixed code passes only Url::toAsciiString()'s result to away(); read that as the taint path ending at a class Psalm does not model, not as a recognised allowlist.Checked: Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6; psalm --taint-analysis over app/ and routes/

CodeQL has no PHP support: the CodeQL supported languages page lists C/C++, C#, GitHub Actions, Go, Java, Kotlin, JavaScript, Python, Ruby, Rust, Swift and TypeScript.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePHP
RuntimePHP 8.5.11 (CLI)
FrameworkLaravel 13.34.0 (sessions on SQLite)
laravel/framework13.34.0
phpunit/phpunit13.4.0
uri extension, Uri\WhatWg\Url (bundled with PHP 8.5)PHP 8.5.11
pdo_sqlite (bundled with PHP)PHP 8.5.11; SQLite 3.53.4

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the open-redirect/php-laravelfolder.

Shell
composer install --no-interaction --no-progress
Shell
# Expected: every test passes
IMPL=fixed vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: an exploit test fails
IMPL=vulnerable vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: a test fails
IMPL=wrong vendor/bin/phpunit --log-junit reports/junit.xml

Without PHP installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'composer install --no-interaction --no-progress'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=fixed \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=vulnerable \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=wrong \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs open-redirect/php-laravel runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 55 KB

Verified

Machine
Alpine Linux 3.24 in the composer:2.10.3 image (Docker 29.4.3 on macOS 27.0, arm64)
Toolchain
  • php 8.5.11 (CLI)
  • laravel/framework 13.34.0
  • phpunit/phpunit 13.4.0
  • composer 2.10.3
  • semgrep 1.180.0
  • psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6
Matrix
fixed: 16 of 16 tests pass; vulnerable: 6 of 16 tests fail (6 exploit tests); wrong: 4 of 16 tests fail (4 exploit tests)
Test results
fixed 16/16 pass; vulnerable 6/16 fail (6 exploit tests); wrong 4/16 fail (4 exploit tests), each test run in a container with --network=none after composer install
Dependency freshness
Laravel 13.35.0 (2026-10-06) is newer but was five days old and is not a security release, so the cards pin 13.34.0 (2026-09-29); laravel/prompts is held at 0.3.24 for the same reason (0.3.25 is from 2026-10-06), and PHPUnit 13.4.1 (2026-10-05) was six days old, so the cards use 13.4.0 (2026-10-02). Every other locked package is at least seven days old. composer audit reported no advisories for the lock file. PHP 8.5.11 (2026-09-24) is the current 8.5 security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Unvalidated Redirects and Forwards Cheat Sheet
  2. Laravel 13.x: Redirecting to external domains (away)
  3. Laravel 13.x: Manually authenticating users (redirect()->intended())
  4. Laravel 13.x: Redirecting unauthenticated users
  5. PHP manual: Uri\WhatWg\Url (PHP 8.5)
  6. PHP manual: parse_url()
  7. WHATWG URL Standard: basic URL parser
  8. CWE-601: URL Redirection to Untrusted Site ('Open Redirect')

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.