DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our admin API answers 404 to strangers, and three response headers give the game away

Fetch munchable.app/api/admin/users right now and you will get this:

HTTP/2 404
content-type: application/json
cache-control: no-store

{"error":"not_found"}
Enter fullscreen mode Exit fullscreen mode

There is a route there. It answers 404 because you are not the operator, and it returns the same 404 that a URL I never wrote would return, so that nobody can map the admin surface by watching which paths answer differently. That is the design. This post is about where the design holds and where, having actually measured it, it does not.

One wrapper, so the gate is written once

Every /api/admin/* handler is wrapped:

export function withAdmin<TParams>(handler: AdminHandler<TParams>, options: AdminRouteOptions = {}) {
  return async (request: NextRequest, context?: { params?: Promise<TParams> }) => {
    const admin = await requireApiAdmin(request);
    if (!admin) return notFound();
    // ...
  };
}
Enter fullscreen mode Exit fullscreen mode

A gate that each handler re-derives is a gate that one handler eventually derives wrongly, and the handler that gets it wrong will be the one added in a hurry. There are seven of these route files and they serve customer email addresses, ticket contents and unpublished catalog state, so the check is a wrapper and not a convention.

The wrapper does three other things on every exit path. It stamps Cache-Control: no-store, including on the 404 and the 429 and the 500, because none of those responses may sit in a CDN. It awaits the route params, which Next hands over as a promise. And it catches a throwing handler and returns server_error rather than letting a stack trace become the response body.

The pages deliberately answer differently from the API

Try munchable.app/admin and you do not get a 404. You get a 307 to /get-started?next=%2Fadmin.

That asymmetry was a decision, and the first version did not have it: every non-admin got a 404 everywhere. The problem is that a silent 404 is indistinguishable from the panel being broken, and every single "the admin panel does not load" investigation I have had ended at the same cause, which is that the browser had no session on that origin. A page that says nothing cannot tell its one legitimate user that.

So the page gate separates two cases that the API gate does not bother to:

  • No usable session (signed out, or an anonymous session, which is never admin whatever email it claims): redirect to sign in, carrying the page they wanted so they land back on it.
  • A real account that is not the operator: 404.

The redirect gives away only that /admin wants a sign in, which a stranger would assume about any site's /admin anyway. The allowlist is still the security boundary. A signed-in non-operator learns nothing either way.

The disguise is imperfect, and here is the measurement

The claim in my own comment is that a non-admin gets "the same answer a nonexistent route gives". I checked it against production instead of trusting it. Three requests, no session:

Request Status Content type Cache-Control x-matched-path
/api/admin/tickets (real) 404 application/json no-store /api/admin/tickets
/api/admin/nope (not a route) 404 text/html public, max-age=0, must-revalidate /404
/api/admin/notices/send (real, POST only) 405 public, max-age=0, must-revalidate /api/admin/notices/send

The status code matches. Nothing else does.

A real admin route answers with a JSON body and no-store, because my wrapper built that response. A path that does not exist never reaches my code at all, so it gets the framework's HTML 404 and a cacheable header. And the host echoes x-matched-path, which names the route that matched, so the response that is supposed to deny the route's existence is carrying the route's own path in a header.

The third row is worse, and it is mine: a GET against a route that only exports POST is answered 405 by the framework before the wrapper ever runs. A 405 is an unambiguous "this exists".

I am leaving all of this as it is, which is a choice worth stating rather than quietly not mentioning. The 404 is a speed bump against casual probing and it costs one line. It was never the thing standing between a stranger and the data, and now I have numbers that say how thin it is rather than a comment that implies it is airtight. If I needed the disguise to actually hold I would have to strip the host's header at the edge, return the framework's HTML 404 byte for byte, and add an explicit handler for every unsupported method. That is a lot of work to protect the names of seven routes that are each individually useless without being on a list in a compiled file.

Admin reads have no rate limit at all

This is the opposite of the instinct, so it is worth saying why.

There is exactly one legitimate caller, and the allowlist is the boundary. A limiter in front of a read could not stop an attacker, because an attacker is already answered 404 before any limiter would run. What it would reliably do is add a Redis round trip to every click in the panel.

One kind of route opts in:

if (options.budget === 'send') {
  // tight per-operator budget
}
Enter fullscreen mode Exit fullscreen mode

Those are the routes that put real email in real inboxes: a service notice to every account holder, a reply on a support ticket. The budget exists so a double-clicked Send cannot become a double campaign. And that limiter fails open, inside a try whose catch does nothing, because locking the operator out of their own support queue because Redis blinked is worse than the duplicate it guards against.

The pages gate twice, and it is not redundant

Each admin page calls the gate as its first statement, and the layout above it gates the whole subtree. Both, on purpose.

A layout that calls redirect() or notFound() decides the response status, but Next has already started rendering the page beneath it. An async page sitting on an await keeps the response stream open, so instead of a clean 404 the request hangs. Checking inside the page too means a non-admin is turned away before the first query starts. It is free after the first call, because the resolver is wrapped in React's cache() and one request resolves it once.

Related: the allowlist is a constant in a compiled file, which is also the story of what happened when it was read from an environment variable instead. And at the other end of the same product, the support form does not ask anyone to sign in: the operator's doors are 404 by default, the customer's door is open by default, and the difference between those two defaults is most of what the gate is for.

Top comments (0)