Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 4 min read

Two signals want the same redirect, and the fresher one is not the one we stored

A visitor reads a page about one assessment provider, clicks "Sign up" in the header, confirms an email, and arrives back on the site. Where should they land? We know two things that could answer that, and the interesti

A visitor reads a page about one assessment provider, clicks "Sign up" in the header, confirms an email, and arrives back on the site. Where should they land?

We know two things that could answer that, and the interesting part is that the more recent one is not the one we deliberately stored.

The two signals

The first is an explicit next parameter: a deep link the user clicked seconds ago, from an email confirmation link or a "sign in to continue" bounce off a specific page. The second is a cookie saying what they originally arrived for, either a provider's games or the assessment centre.

The whole decision is one expression:

export function postAuthDestination({ next, landingIntent }) {
  return internalPath(next) ?? destinationForLandingIntent(landingIntent, DEFAULT_DESTINATION);
}

next wins outright, and the reason is the cookie's provenance. Its value comes from a first-touch record in localStorage that survives for 30 days. Without that precedence, somebody who browsed a provider page a fortnight ago would be dropped into a practice game no matter which link they actually clicked, and the next they were sent would be silently discarded. A stored intent is not evidence of present intent.

One function, because two paths had drifted

The function is pure and cookie-free, which is deliberate. Two very different callers need the same answer: the OAuth callback, a route handler that clears the cookie on its own NextResponse, and the email signup server action, which clears it through cookies(). Before this existed they each worked out a destination on their own, and they disagreed about what to fall back to. Sharing the lookup would not have fixed that. What had to be shared was the precedence rule.

The module also has no server-only imports on purpose, so the client component that writes the cookie imports the cookie name and the encoder from the same file the readers use. The writer and the readers cannot disagree about either.

Nothing here can become an open redirect

Two user-controlled values feed a redirect, so both are constrained at the point of use rather than at the caller.

function internalPath(next) {
  if (!next || !next.startsWith('/')) return null;
  if (next.startsWith('//') || next.startsWith('/\\')) return null;
  return next;
}

The first check rejects absolute URLs. The second rejects the protocol-relative //evil.com form, and then rejects the backslash variant too, because the URL parser folds \ into / for http and https URLs, which makes /\evil.com equivalent to //evil.com. That is the version people forget.

The cookie gets a stronger treatment: it is never used as a path at all. It only selects between a fixed set of internal destinations, and a provider value is resolved through a lookup against the game library rather than concatenated into a URL. An attacker who writes whatever they like into that cookie can therefore only choose between our dashboard and our exercises page, or fall through to the default.

The value also carries its kind explicitly, provider:<slug> or the assessment-centre sentinel, rather than a bare slug. That is not verbosity. A bare slug would mean the sentinel string must never collide with a provider slug we add in future, and a constraint that lives in nobody's head is a constraint that gets broken.

The bug that made the cookie necessary

Originally the intent only existed if a link carried ?provider=<slug> into signup. Only deliberately constructed links do that. So the most common path on the site, read a provider page, click the ordinary "Sign up" in the header, arrived with no intent at all and landed on a generic dashboard, despite us knowing exactly what the visitor had come for. The client component now falls back to the stored first-touch intent, re-validating the slug against the known providers first, because localStorage is user-writable. The server validates it again before redirecting.

The cookie itself is short-lived: max-age=1800 and SameSite=Lax. Lax rather than Strict because it has to survive the top-level redirect back from an OAuth provider, which Strict would drop.

See it

Open cogniprep.app/games/shl in a window where you are not signed in, then in the console read the first-touch record:

localStorage.getItem('cogniprep:entry-intent')
// {"kind":"provider","id":"shl","at":1790669068435}

Now visit a different provider's page under /games/ and read it again. The value does not change, which is what "first touch" means: it records what brought you here, not where you last clicked.

Then confirm the cookie writer is shipped rather than described. The signup page loads 19 JavaScript chunks, and exactly one of them contains the cookie name:

curl -s https://cogniprep.app/signup | grep -o '/_next/static/chunks/[^"]*\.js' | sort -u \
  | while read c; do curl -s "https://cogniprep.app$c" | grep -l landing_intent >/dev/null \
  && echo "$c"; done

One file, one cookie name, and a redirect that is only ever chosen from a list we wrote.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.