skip to content

A Laravel bookshop redirects after login with redirect()->to($request->input('return')); why is that an open redirect, and how do you fix it?

level: seniorimportance: should knowfreq 30%

answer

  1. who controls the target URL
  2. to() passes full URLs through unchanged
  3. //evil.test counts as a valid URL
  4. enforceSameOrigin($fallback) or a route allowlist
  5. prefer intended() and named routes

basics

~20 s

Redirector::to() returns any string its URL generator considers a valid URL unchanged, including https:// and protocol-relative //host values, so a crafted return parameter sends users to another site. Redirect only to named routes, intended(), or a same-origin check.

solid answer

~40 s

`redirect()->to($path)` asks the URL generator for `to($path)`, whose first step is `isValidUrl()`: anything starting with `https://`, `http://`, `//`, `#`, `mailto:` or `tel:`, or passing PHP's URL filter, is returned **as-is**. So `?return=https://evil.test/login` or `?return=//evil.test` produces a redirect off your site, and a check like `str_starts_with($return, '/')` still lets `//evil.test` through. Fixes, strongest first: redirect to named routes and never to raw input; use `redirect()->intended()`, whose value your app wrote into the session; map a short key such as `checkout` to a route; or, when a URL must be accepted, chain `->enforceSameOrigin(route('home'))`, which in the pinned 13.x source swaps any target whose host, scheme or port differs from the request for the fallback. `back()` deserves the same care, because it trusts the `Referer` header.

code

php · 15 lines
php
<?php

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class PostLoginRedirect
{
    public function __invoke(Request $request): RedirectResponse
    {
        // Accepts ?return=https://... only when it points back at this host.
        return redirect()
            ->to($request->input('return', route('account')))
            ->enforceSameOrigin(route('account'));
    }
}

go deeper

for a junior

Remember never to pass a request value straight into redirect()->to() or away(); redirect to named routes instead.

for a middle

Explain that to() returns absolute and protocol-relative URLs unchanged, and that back() trusts the Referer header.

for a senior

Design return flows around intended() or a route allowlist, apply enforceSameOrigin() where a URL must be accepted, and review logout and locale links for the same pattern.

for a principal

Make redirect targets a reviewed pattern, with a shared helper and a test, so no team reintroduces raw-input redirects under deadline pressure.

## The bug A bookshop's login form carries a hidden `return` field so that a link like `/login?return=/checkout` brings the customer back to checkout: ```php return redirect()->to($request->input('return', '/')); ``` An attacker sends victims a real link to the real shop, `https://books.test/login?return=https://books-test.evil/login`. The victim logs in on the genuine site and is then silently redirected to a look-alike page that asks them to "log in again" or "confirm card details". This is an **open redirect**: your domain's reputation is lent to someone else's URL. The general vulnerability class is application security; what makes it a Laravel question is exactly which Laravel calls pass the value through. ## Why `to()` does not protect you `Redirector::to($path)` calls `UrlGenerator::to($path)`. Its first step is: ```php if ($this->isValidUrl($path)) { return $path; } ``` `isValidUrl()` returns `true` for strings starting with `#`, `//`, `http://`, `https://`, `mailto:`, `tel:` or `sms:`, and otherwise for anything `filter_var(..., FILTER_VALIDATE_URL)` accepts. Those strings are **returned unchanged**, so: - `https://evil.test/x` redirects off-site; - `//evil.test/x` is a protocol-relative URL, and browsers treat it as `https://evil.test/x` on an HTTPS page; - only relative paths such as `/checkout` are rebuilt on your app's own root. `redirect()->away()` is even more direct: it skips the generator altogether. And `back()` redirects to the `Referer` header when present, which is a value the client sends. ## Fixes, strongest first 1. **Do not redirect to input at all.** Most "return to" needs are covered by `redirect()->intended($default)`: the value is written into the session by your own `guest()` or `setIntendedUrl()` call, never from a query parameter. It is not entirely client-free: for non-GET requests `guest()` records the previous URL, which prefers the `Referer` header. 2. **Map keys to routes.** Accept `?return=checkout` and translate it through an allowlist: `['checkout' => 'checkout.show', 'basket' => 'basket.show']`. Unknown keys fall back to the home page. 3. **Enforce same origin on the response.** The pinned Laravel 13.x source has `RedirectResponse::enforceSameOrigin(string $fallback, bool $validateScheme = true, bool $validatePort = true)`. It compares the target URL's host, and by default its scheme and port, with the current request's, and replaces the target with `$fallback` on any mismatch. It is not described in the 13.x docs, so check your installed version before relying on it. 4. **If you must validate by hand,** parse the value and compare hosts rather than checking the first character. `str_starts_with($value, '/')` accepts `//evil.test`, and backslash variants have tricked browsers before. ## A safe version ```php $targets = ['checkout' => 'checkout.show', 'basket' => 'basket.show']; $route = $targets[$request->input('return')] ?? 'account'; return redirect()->intended(route($route)); ``` ## Where else the same mistake appears - **Logout and language-switch links** with a `redirect_to` parameter. - **`back()` after sensitive actions:** a missing or forged `Referer` falls back to the session or `/`, but a present one is trusted. Prefer a named route after anything security-relevant. - **Middleware** that redirects to a URL taken from a query parameter. | Call | Accepts an absolute or `//` URL from input? | |---|---| | `redirect()->to($input)` | yes, unchanged | | `redirect()->away($input)` | yes, without any processing | | `back()` | uses the client's `Referer` header | | `to_route($name)` | no, the URL is generated from your routes | | `redirect()->intended($default)` | uses a URL your app stored in the session | | `redirect()->to($input)->enforceSameOrigin($fallback)` | only if host, scheme and port match | ## Testing for it Open redirects are cheap to test once you know the inputs to try. For every endpoint that accepts a return value, assert that each of these ends on your own host or the fallback: - `https://evil.test/` - `//evil.test/` - `http://books.test.evil.test/` (a host that merely starts like yours) - an empty string and a missing parameter A single data-driven feature test covers all of them, and it stays valuable because the vulnerable pattern tends to come back with new features such as a marketing campaign's `?next=` link or a partner integration that wants to return users to its own page. When a partner genuinely needs an external return URL, keep an explicit allowlist of partner hosts in config and compare the parsed host against it. ## What a senior answer shows Naming the exact line that makes `to()` unsafe (`isValidUrl()` returning the input unchanged), knowing the `//host` trick that defeats naive prefix checks, and preferring designs where the redirect target never comes from the request.

  • Why is `if (str_starts_with($return, '/'))` not enough before `redirect()->to($return)`?
    `//evil.test/login` starts with a slash but is a protocol-relative URL. Laravel's `isValidUrl()` recognises the `//` prefix and returns it unchanged, and the browser resolves it against the current scheme, landing on `https://evil.test/login`. Compare parsed hosts, or use a route allowlist.
  • Is `redirect()->intended()` itself safe from this?
    Much safer, not absolute. `url.intended` is written by your own code: `guest()` stores the current URL for routed GETs and, for other requests, the previous URL, which prefers the `Referer` header. No query parameter can set it, but a Referer is client-sent, so never call `setIntendedUrl()` with raw input and consider `enforceSameOrigin()` on high-value flows.

saying these in an interview costs you the question

  • redirect()->to() only ever redirects within the application's own domain.
  • Checking that the value starts with a slash makes it safe.
  • away() rejects hosts that are not configured in config/app.php.
  • back() is always safe because it uses the server-side session only.
  • An open redirect is harmless because no data leaves the application.