Skip to main content

CSRF in Next.js route handlers, without tokens

9 min read

In the post about this site’s auth, the admin cookie goes out as sameSite: "lax", and until late September that was the whole defense against CSRF (cross-site request forgery: a page somewhere else getting my browser to send a write to the admin, cookie attached).

A full review of the codebase I ran then flagged exactly that: Lax stops most of it, but not a request from a sibling subdomain, and this site is a subdomain itself, roland.leth.ro.

Next.js has a check of its own, but only for Server Actions, where it compares the Origin header with the host. There are no Server Actions here; every admin write is a route handler under /api/admin, and for those, as the Next.js team’s security post puts it, CSRF protection “has to be done manually”.

The check went into the handlers instead, with no tokens: the browser already sends 2 headers that say where a request came from, and a test holds every write handler to them. It’s a single-user site with one admin, so the exact rules fit it — they might be a bit too strict, or not strict enough, for yours.

SameSite=Lax and sibling subdomains

If we set a cookie as Lax, the browser leaves it off cross-site requests, form POSTs included, and still sends it on top-level GET navigations, like following a link.

The catch is really in “cross-site”.

A site isn’t an origin: the origin is the scheme, host and port, while the site is, more or less, the scheme plus the registrable domain, the part we actually register (web.dev has a pretty good explainer).

That makes https://roland.leth.ro and https://other.leth.ro 2 origins, but the same site, and the browser attaches a Lax cookie to a POST from one to the other without a second thought.

Does that matter? Well, only if someone gets a page on a *.leth.ro subdomain first, through a forgotten DNS record pointing at a service anyone can claim, or an XSS hole in some other app on the domain, which is probably a tall order.

It’s not just theory, though. The first one even has a name, a subdomain takeover: a subdomain’s DNS record still points at a hosting service after whatever was on it is gone, so whoever claims that name on the service gets a page on the domain. Owning the main domain doesn’t rule it out, either, since the forgotten record would be one I added myself.

For this site, it’s theory for now, since nothing else lives on *.leth.ro. But it only takes one subdomain I set up later and forget about, and the check is a few lines.

The review’s version of the risk was concrete: back then request.json() didn’t look at Content-Type, so a plain text/plain form on such a page could have created a post here, and new posts are published by default.

Checking Sec-Fetch-Site and Origin

The browser already tells us where a request came from, it just doesn’t do it through the cookie. Here’s the check, minus the logging:

// src/lib/api/sameOrigin.ts (excerpt)
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"])
 
export function refuseCrossSiteWrite(
  request: Request,
  tag: string
): NextResponse | null {
  if (SAFE_METHODS.has(request.method)) {                     // ❶
    return null
  }
 
  const fetchSite = request.headers.get("sec-fetch-site")
  const origin = request.headers.get("origin")
  const isFetchSiteForeign =
    fetchSite != null && fetchSite !== "same-origin"          // ❷
  const isOriginForeign =
    origin != null && origin !== new URL(request.url).origin  // ❸
 
  if (!isFetchSiteForeign && !isOriginForeign) {              // ❹
    return null
  }
 
  // A warn-level log line with both headers, then:
  return NextResponse.json(
    { error: "Cross-site request refused" },
    { status: 403 }
  )
}

Reads pass right away ❶, whatever their headers say.

Writes go through 2 checks, and either one is enough to refuse.

Sec-Fetch-Site ❷ is one of the Fetch Metadata headers. The browser sets it, and since its name starts with Sec-, a script can’t touch it.

It has 4 values: same-origin, same-site, cross-site and none (the last one for requests the user started directly, like typing a URL). We only let same-origin through: same-site is exactly the sibling-subdomain case, and the admin’s own writes all come from its own pages, so none goes too.

Origin ❸ covers browsers that don’t send Sec-Fetch-Site. It has to match the request’s own origin exactly, so http://roland.leth.ro, https://roland.leth.ro:8443 and the literal string null (what a sandboxed iframe sends, for example) are all refused.

When the 2 headers disagree, say a same-origin fetch site with a foreign Origin, we trust neither.

What about a request with neither header? We let it through ❹.

Current browsers send Origin on every request that isn’t a GET or a HEAD, so a write without it probably didn’t come from another site’s page. More likely it came from curl or a script, which needs the cookie’s value before it can do anything, and at that point CSRF is the least of my problems. It’s the one rule here that leans on browsers behaving, so I left myself a note to revisit it if one turns up that sends neither header, or if the admin API ever gets a client that isn’t a browser.

There’s also a smaller layer for the handlers that take JSON. An HTML form can only send text/plain, multipart/form-data or application/x-www-form-urlencoded, and a cross-origin fetch with anything else needs a CORS preflight, which this site never says yes to. That’s why the parseJsonBody helper now answers 415 to any other Content-Type.

That doesn’t cover the upload route, which takes multipart/form-data, or the IndexNow and keepalive routes, which take no body at all. For those 3, the origin check is really the only thing in the way.

Where the check runs

Every admin handler already opens with requireAdmin, which I added in July as a second lock behind the proxy, after a bug in the proxy’s path matching let some admin paths skip it entirely. The cross-site check went in there:

// src/lib/api/requireAdmin.ts (excerpt)
export async function requireAdmin(
  request: Request,
  tag: string
): Promise<NextResponse | null> {
  if (!(await verifySession())) {                 // ❶
    logMiddlewareBypass(tag, "the handler")
 
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
  }
 
  return refuseCrossSiteWrite(request, tag)       // ❷
}

The session check comes first ❶, so a request that slipped past the proxy without a session still answers 401 and gets logged as a bypass, instead of a quieter 403.

The cross-site check runs after it ❷.

Why not in the proxy? Because then it would depend on the proxy’s path matching being right, which is the one thing requireAdmin was added to stop relying on. A new handler also gets the check just by making the call it has to make anyway.

adminAuthContract.test.ts keeps “every handler” true. It lists the 14 admin route files by hand (a glob that matched nothing would make a test that always passes), fails if a route file isn’t on the list, and makes every exported write handler, 18 of them today, answer 403 to a cross-site request:

// src/app/api/admin/adminAuthContract.test.ts (excerpt)
const CROSS_SITE_HEADER_SETS = [
  { label: "Sec-Fetch-Site only", headers: { "sec-fetch-site": "same-site" } }, // ❶
  { label: "Origin only", headers: { origin: "http://other.localhost" } },
]
 
// For every route module and every header set:
vi.mocked(verifySession).mockResolvedValue(true)                               // ❷
 
// `writes` is every exported handler that isn't GET.
for (const method of writes) {
  const handler = routeModule[method] as Handler
  const request = new Request("http://localhost/api/admin/probe", {
    method,
    headers,
  })
  const response = await handler(request, {
    params: Promise.resolve({ id: "1" }),
  })
 
  expect(
    response.status,
    `${method} answered ${response.status} to a cross-site write`
  ).toBe(403)
}

Each header goes in on its own ❶, so a handler that only looked at one of them fails.

And the session is valid ❷, because without one every handler would stop at the 401, and the test would pass without the cross-site check ever running.

Login and logout live outside /api/admin, so they call refuseCrossSiteWrite directly. Login is the one my September change missed; the next day’s review caught it.

On login, the check runs before the rate limiter, on purpose. The limiter from the auth post buckets attempts by IP, so a page I visit could fire cross-site form POSTs from my own browser, and my own IP, until I’m locked out of my own admin. A test sends one and checks both the 403 and that the limiter was never called.

Logout can only end a session, which from another *.leth.ro page is just a nuisance; it had been on a review’s list since May, and it got the same line in September.

None of this covers everything. An XSS hole on roland.leth.ro itself is same-origin by definition, and a handler that writes on a GET would be wide open again, since the check lets reads through and Lax sends the cookie on top-level GET navigations.

I also didn’t take the __Host- cookie prefix, which stops a sibling from planting a cookie in place of mine. With one user and a signed JWT, a planted cookie gets nobody anything, and the rename would complicate development over http://localhost.

If you’ve done this differently, or you can see a hole in the neither-header rule, I’m at @roland.leth.ro and @rolandleth.