DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A mistyped URL on our site gets the login page instead of our 404, and one capital letter changes which

Our auth boundary is one allowlist. Public paths are listed in src/lib/public-paths.ts, everything else needs a session, and the check runs in src/proxy.ts, which is what Next 16 calls the file that used to be middleware.ts.

I have written about the allowlist before. What I had never done is throw malformed URLs at the deployed site and write down what comes back. It took one loop:

for u in "/guides" "/guides/" "/guides/does-not-exist" "/GUIDES" "/nope"; do
  printf '%-28s ' "$u"
  curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "https://nakodo.app$u"
done
Enter fullscreen mode Exit fullscreen mode

Thirty shapes later I had three findings, one of which is a real bug in how our site looks to a crawler. Every line below is reproducible against nakodo.app right now.

The table

request answer
/guides 200
/guides/ 308 to /guides
//guides 308 to /guides
/guides?foo=bar 200
/guides/does-not-exist 404
/instagrammers/nope 404
/tools/nope 404
/for/nope 404
/og/nope 404
/GUIDES 404
/ABOUT 404
/guides/HOW-TO-FIND-YOUTUBE-INFLUENCERS 404
/nope 307 to /login?next=%2Fnope
/_not-found 307 to /login?next=%2F_not-found
/maintenance 307 to /login?next=%2Fmaintenance
/campaigns 307 to /login?next=%2Fcampaigns
/campaigns/ 308 to /campaigns, then the 307
/campaigns.rsc 307 to /login?next=%2Fcampaigns
/about.rsc 200
/guides%2f 307 to /login?next=%2Fguides%252f
HEAD /guides 200
OPTIONS /guides 204
POST /guides 405
PUT /guides 405
/o/abcdefghjkmn 200
/alcy/ 404

Finding one: our 404 page is only reachable inside an allowlist entry

The 404 page is a field guide entry for a bird that was never recorded. Its heading is "Page not recorded in this area" and its title is "Page not found ยท Nakodo", and it returns a real 404.

Now mistype a path one level up. /nope does not reach that page. It is not in the exact list and does not begin with any public prefix, so the proxy treats it the way it treats /campaigns: as something private that a signed-out visitor should log in for. The visitor gets a 307 to the login page, with ?next=%2Fnope attached, and if they do sign in they are then sent to /nope and get the 404 they should have had two requests earlier.

For a person this is a strange little detour. For a crawler it is worse: a mistyped or stale inbound link to our site resolves to a redirect to a noindex login page rather than to a clean 404, which is exactly the shape Google reports as a soft 404 and which teaches it nothing about the URL being gone.

The reason it is written this way is a good one, and it is in a comment at the top of the allowlist:

// A public page missing from here still works for signed-in visitors, which is
// how it slips through: crawlers and new visitors get sent to /login instead,
// and the page drops out of search.
Enter fullscreen mode Exit fullscreen mode

Default deny is the right default for an auth boundary: if I add a private route and forget the allowlist, the route is protected rather than exposed. The cost of that choice is that "unknown" and "private" are the same answer, and the cost falls on the 404 page.

The fix I am going with is to send an unknown path to the 404 instead of the login page, which means naming the private prefixes rather than inferring them. The list exists already, one export below:

export const PRIVATE_PREFIXES = ["/campaigns", "/settings", "/onboarding", "/billing", "/api/"];
Enter fullscreen mode Exit fullscreen mode

The reason that is not a one-line change is the thing I like about writing this down: moving from "deny unless public" to "deny if private" means every future private route has to remember to join that list, and the test that would catch a forgotten one has to be written before the switch, not after.

Finding two: capitalising a path changes who answers

/about is 200. /ABOUT is 404. /nope is a redirect. All three are URLs that do not need a session, so why do they get three different answers?

Because two different matchers, with two different notions of case, run in order.

The proxy's config.matcher is one long negative lookahead listing everything the proxy should not run on, public pages included. Next compiles matchers with path-to-regexp, and the default it passes is in its own source, next/dist/shared/lib/router/utils/path-match.js:

sensitive: typeof options?.sensitive === 'boolean' ? options.sensitive : false,
Enter fullscreen mode Exit fullscreen mode

Case-insensitive unless you say otherwise. So /ABOUT matches the exclusion written as about, and the proxy never runs on it. You can see that it never runs from the answer itself: if the proxy had run, isPublicPath("/ABOUT") would have been false and the response would have been a redirect to the login page, which is what /nope gets.

The app router, on the other hand, is case-sensitive, and there is no /ABOUT route. So the request skips the auth check and lands on the 404.

/nope matches no exclusion, the proxy runs, and the answer is the login redirect. One of these paths is handled by the router and the other by the auth boundary, and the only difference between them is the shift key.

Nothing is leaked by this. Everything case-mangled is a public page or a 404. But the next person to reason about "which requests run the session check" will get it wrong unless they know that the exclusion list is case-insensitive while the routes it names are not, and there is no test in the repo that pins that behaviour down.

Finding three: the status codes are all deliberate, and one of them costs two round trips

Three redirect codes show up, and they mean different things:

  • 308 for a trailing slash, because next.config.ts declares that redirect permanent: true. A 308 keeps the method and body, which matters if anyone ever POSTs to a slashed URL.
  • 307 for the auth boundary, because being signed out is temporary. 307 also keeps the method, so a form POST bounced to the login page can be retried intact.
  • 405 for POST /guides, from the framework, because a static page has no POST handler. OPTIONS answers 204.

//guides normalising to /guides with a 308 is the platform being sensible. /campaigns/ is the one that costs: the trailing-slash redirect fires first, so a signed-out visitor pays a 308 and then a 307 before seeing the login page. Nothing is broken, it is just two hops where one would do, and the ordering is fixed by where each rule lives.

The nicest small detail is /campaigns.rsc. That is the URL a client-side navigation fetches, and the proxy's redirect comes back as ?next=%2Fcampaigns, without the suffix, because nextUrl.pathname is normalised before our code sees it. The bounced prefetch therefore produces a next that points at a page rather than at a payload.

And /guides%2f is a nice reminder that the allowlist sees what it is given: the encoded slash is not a path separator, so the path does not start with /guides/, so it is not public, so it redirects, and the next parameter comes back double-encoded as %252f.

Where next gets checked

Since every one of those redirects writes a next parameter, the obvious question is whether next can be made to point off-site. Try it: the login page renders, and the parameter is carried into the form as a hidden field.

It is checked on the way out, in src/lib/auth.ts, by a function with three refusals in it:

// Only same-site paths, so links and forms can't be used as an open redirect.
export function safeNext(value: unknown, fallback = "/campaigns"): string {
  const next = typeof value === "string" ? value : "";
  return next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\") ? next : fallback;
}
Enter fullscreen mode Exit fullscreen mode

A path must start with a slash, must not start with two (a protocol-relative URL, which browsers read as a host), and must not start with slash-backslash, which some browsers also read as a host. Everything else falls back to /campaigns. Four call sites use it: sign-in, the email confirmation route, the billing return route, and one settings action.

One more that is worth copying

Static assets answer with what you would hope:

cache-control: public,max-age=31536000,immutable
strict-transport-security: max-age=63072000
Enter fullscreen mode Exit fullscreen mode

A year of immutability on content-hashed chunks, and two years of HSTS. Neither is interesting until you check and find out it is not there.

If you want to do this on your own site, the loop at the top is the whole method. Thirty requests, one table, and in our case two findings worth fixing and one piece of framework behaviour I would have sworn went the other way. Start with /guides/does-not-exist to see what a 404 ought to look like, then try /nope and watch where you end up.

Top comments (0)