Dev.to Security 🔐 Cybersecurity 👁 0 📖 6 min read

29 API routes, not one CORS header in any of them, and the only browser that needs them is our own app

Munchable has 29 route handlers under /api. None of them contains the word CORS. Here is the whole policy, as the live site answers it: $ curl -s -o /dev/null -D - -X OPTIONS \ -H "Origin: https://example.com" \

Munchable has 29 route handlers under /api. None of them contains the word CORS. Here is the whole policy, as the live site answers it:

$ curl -s -o /dev/null -D - -X OPTIONS \
    -H "Origin: https://example.com" \
    -H "Access-Control-Request-Method: GET" \
    https://munchable.app/api/taxonomy | grep -i 'http/\|access-control\|vary'
HTTP/2 204
vary: Origin, Access-Control-Request-Headers

$ curl -s -o /dev/null -D - -X OPTIONS \
    -H "Origin: https://app.munchable.app" \
    -H "Access-Control-Request-Method: GET" \
    -H "Access-Control-Request-Headers: authorization" \
    https://munchable.app/api/taxonomy | grep -i 'http/\|access-control\|vary'
HTTP/2 204
access-control-allow-headers: authorization
access-control-allow-methods: GET, POST, OPTIONS
access-control-allow-origin: https://app.munchable.app
access-control-max-age: 86400
vary: Origin, Access-Control-Request-Headers

Same status both times. The difference is the three headers that decide whether the browser lets the real request happen, and neither response ran a route handler.

The question worth asking first

Who calls this API from a browser?

The marketing site and the signed-in web pages are the same Next.js app on the same origin, and CORS does not apply to a same-origin request at all. The Android app is React Native: it has no browser, no Origin header, and no notion of a preflight. That accounts for essentially all the traffic.

What is left is one caller, and it is also us. The app is an Expo project, and an Expo project also builds for the web. In development that is Metro serving http://localhost:8081 while the API runs on http://localhost:3000, two origins. In production it is app.munchable.app talking to munchable.app, two origins again.

So the answer is: one cross-origin browser caller, which happens to be our own app wearing a different hat. Before this existed, every preflight from it failed, because route handlers emit no CORS headers on their own and nothing was adding any.

It belongs in one place, and that place is the proxy

Next 16 renamed the middleware convention to proxy.ts. It runs before route handlers, which makes it the only place a policy like this can be both complete and invisible:

export async function proxy(request: NextRequest) {
  if (MAINTENANCE) return maintenanceResponse(request);
  const { pathname } = request.nextUrl;
  if (pathname.startsWith('/api/webhook')) return NextResponse.next();
  const isApi = pathname.startsWith('/api/');
  if (isApi && request.method === 'OPTIONS') return corsPreflightResponse(request);
  const response = await updateSession(request);
  return isApi ? applyCorsHeaders(request, response) : response;
}

Three things follow from those six lines.

A preflight never reaches a handler. It is answered and returned before updateSession, so an OPTIONS costs no session refresh, no auth provider call, and no route code. It is also why both curl commands above work on an endpoint that otherwise requires a token.

The two webhook routes are outside the policy on purpose. Stripe and the mail provider post to them server to server, verified by signature. There is no browser involved, so there is no Origin to allow and nothing a CORS header could protect. They are matched by the proxy at all only so the maintenance switch can refuse them too.

27 route files inherit the policy by existing. The next one does too. A rule that each handler has to remember is a rule that is one new file away from being wrong.

Reflect, do not wildcard

export function isAllowedOrigin(origin: string | null): origin is string {
  const url = parseUrl(origin ?? undefined);
  if (!url) return false; // absent, malformed, or the opaque "null" origin
  if (url.origin === SITE.origin || EXTRA_ORIGINS.has(url.origin)) return true;
  if (
    url.protocol === 'https:' &&
    (url.hostname === SITE.hostname || url.hostname.endsWith(`.${SITE.hostname}`))
  ) {
    return true;
  }
  return !IS_PRODUCTION && LOOPBACK_ORIGIN.test(url.origin);
}

Four ways in. The site's own origin. Any https subdomain of it, which is what covers a hosted Expo web build without naming it. An explicit comma-separated environment list, for pointing a local build at production. And any loopback origin on any port, but only outside production, because Metro picks whichever port is free and hardcoding 8081 means a broken afternoon the day it picks 8082.

parseUrl returning null covers three cases at once: no Origin header, a malformed one, and the literal string null, which is what a browser sends from a file:// document or a sandboxed iframe. None of them is an origin, so none of them is allowed.

Reflecting the request origin rather than sending * is the conservative choice, and it is worth being clear about what it does and does not buy. Since this API never sends credentials, * would work for the token-bearing caller. What * would also do is let any page on the internet read every response this API gives, including the ones that are only uninteresting today. Reflection keeps that list to the origins we actually ship.

Which is why Vary is appended unconditionally, before any allow decision:

response.headers.append('Vary', 'Origin');

The response genuinely differs by Origin, so any shared cache in front of it has to key on that. Without the Vary, one CDN hit for an allowed origin could be served to everybody, which hands out exactly the permission the reflection was there to withhold.

The header we never send is the interesting one

There is no Access-Control-Allow-Credentials anywhere in this codebase, and that absence is load bearing.

The mobile client authenticates with a bearer token in Authorization, which travels perfectly well in a non-credentialed cross-origin request. The web app authenticates with a cookie session. A browser will not attach cookies to a cross-origin request unless the server opts in with Allow-Credentials, so by never opting in, the cookie session simply cannot be exercised from another origin. There is no endpoint to audit and no token to compare: the capability is not there.

That is the kind of security property worth preferring. Not a check somewhere, but a header that is never written.

Six names, or the client reads undefined

const EXPOSED_HEADERS =
  'Retry-After, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, ETag, x-taxonomy-version';

By default, cross-origin JavaScript can read exactly seven response headers, and Cache-Control and Content-Type are the only interesting ones in that list. Everything else is invisible unless you name it. This is a quiet bug, because the server is doing the right thing and the client sees nothing:

// The 429 is correct, the header is really on the wire, and this is undefined.
const wait = response.headers.get('RateLimit-Reset');

So the list is deliberate, and every entry earns its place. The four rate-limit headers turn a 429 into a number of seconds rather than a mystery. Retry-After is on it because the maintenance switch answers 503 with one, and a cross-origin client that cannot read that header has to guess when to come back. ETag and x-taxonomy-version are how the app learns that its own copy of the ingredient data is behind the server's.

Access-Control-Max-Age is 86400, which is a request rather than an instruction. Chromium caps a preflight cache at 2 hours and Firefox at 24, so the real answer is "as long as your browser will tolerate", and asking for a day is how you get it.

Even the refusals are legible

$ curl -s -o /dev/null -D - -H "Origin: https://app.munchable.app" \
    https://munchable.app/api/taxonomy | grep -i 'http/\|access-control\|vary'
HTTP/2 401
access-control-allow-origin: https://app.munchable.app
access-control-expose-headers: Retry-After, RateLimit-Limit, ...
vary: Origin

$ curl -s -o /dev/null -D - -H "Origin: https://example.com" \
    https://munchable.app/api/taxonomy | grep -i 'http/\|access-control\|vary'
HTTP/2 401
vary: Origin

Both are 401, because that endpoint wants a token and curl has not got one. The first 401 is readable by the caller that got it. The second is a response the browser will refuse to hand to the page that asked for it, which is the correct outcome and worth seeing once: CORS does not stop the request happening, it stops the answer being read.

The maintenance switch follows the same rule. While it is on, the API answers 503 with Retry-After and the preflight is still answered, because an app that cannot read the 503 has learned nothing except that something is wrong.

What this buys the public site: nothing at all

Worth stating plainly. Every public page on Munchable is prerendered and calls none of this. The condition index, the recipes, an ingredient answer page and the about page are static documents, prerendered at build time, and not one of them asks the API for anything.

The API exists for the app. The CORS policy exists for one build of that app that happens to run in a browser. Both curl commands at the top of this post work against the live site right now, and the gap between their two answers is the entire policy.

📰 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.