DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our Permissions-Policy denies geolocation, and the pricing page still knows what country you are in

curl -sI https://pub-trivia.app/
Enter fullscreen mode Exit fullscreen mode
permissions-policy: camera=(), microphone=(), geolocation=()
referrer-policy: origin-when-cross-origin
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-content-type-options: nosniff
x-dns-prefetch-control: on
x-frame-options: SAMEORIGIN
Enter fullscreen mode Exit fullscreen mode

No x-powered-by. Six headers, declared once in next.config.mjs for /:path*, and each one is a decision rather than a default. The two most interesting are the ones that look like they contradict the product.

Denying geolocation on a site that localises by country

The pricing page shows you a price in your own currency. It knows roughly where you are. And the document it serves you denies itself permission to ask where you are.

Both of those are true because the country never comes from the browser. It arrives as a request header on the server, set by the edge from the connection's IP, and the currency is resolved before any HTML is sent. navigator.geolocation is not called anywhere in the codebase, so there is nothing for the permission to grant.

That makes geolocation=() free, and free is exactly why it is worth declaring. A capability you do not use is one you should actively give up, because the alternative is leaving the door open for a dependency, a third party script, or a future version of your own code to walk through quietly. There is no prompt, ever, on any page, and that is now a property of the document rather than a property of today's code.

It is also the better deal for the visitor. An IP derived country is coarse and it is already in the request; a browser location prompt is precise, frightening, and asks somebody to trust a site they have known for nine seconds. The coarse signal is enough to pick a currency, so the precise one is never worth asking for. The small print under each price says the figure is approximate and Stripe shows the exact total, which is the honest version of that trade.

Denying camera on a product you join by scanning a QR code

This one surprises people, because scanning is the entire joining flow: players point a phone at the code on their table and they are in.

But the scanning does not happen in our page. We generate the QR codes server side and they get printed on table cards. The scan happens in the phone's own camera app, which is where every phone sold in the last several years already puts a QR reader, and which asks for no permission from us at all. There is no getUserMedia call anywhere in the repository.

So camera=() costs nothing and removes a whole class of failure. An in-page scanner means a permission prompt, a denied prompt with no recovery path, iOS and Android behaving differently, and a stranger in a pub being asked for camera access by a website they have never heard of, while their round gets cold. The product is better without it, and the header is how you say so in a way a browser enforces. You can see the generated codes and read how the joining flow works on the QR joining page.

HSTS with preload is a one way door

max-age=63072000; includeSubDomains; preload
Enter fullscreen mode Exit fullscreen mode

Two years, all subdomains, and a request to be baked into the browsers' hardcoded preload list.

Worth being clear eyed about what that commits you to. Once the domain is on the preload list, browsers refuse plain HTTP to it and to every subdomain, including ones that do not exist yet, regardless of what your server says. Removal is a submission to a list plus a wait for browser releases to roll out, which is months. includeSubDomains plus preload means any future staging., internal. or legacy. host has to be HTTPS on day one.

For a site that is HTTPS only on a single Vercel domain that is an easy yes. For a company with a sprawl of internal subdomains on mixed infrastructure it is a decision to make deliberately rather than by copying a header from a blog post, including this one.

The two small ones, and what they actually buy

X-Content-Type-Options: nosniff stops a browser second-guessing a declared Content-Type. The attack it prevents is an uploaded file served as text/plain being sniffed as HTML and executed in our origin.

Referrer-Policy: origin-when-cross-origin sends the full URL within our own site and only the bare origin outward. That matters more than usual here because we publish comparison pages that link out to competitors. With the default policy, every one of those outbound clicks would hand the destination the exact path of the page comparing us to them. They learn that traffic came from our domain, which is fair, and not which page they were being compared on, which is ours.

X-Frame-Options: SAMEORIGIN is formally superseded by CSP frame-ancestors, which brings me to the gap.

The header we have not shipped

There is no Content-Security-Policy on that list, and I would rather say so than let you notice.

A real CSP for an App Router site means generating a nonce per request in middleware, threading it through the inline scripts the framework emits, and keeping it correct as the framework changes. A CSP with unsafe-inline in it, which is where most attempts land, is a header that looks like security while permitting the exact thing CSP exists to stop. The honest position is that the six headers above are the cheap ones, they are all shipped, and the expensive one is still open work rather than a box that has been ticked.

And one deliberate hole in our own middleware

Browser error reports go to Sentry. Ad blockers block requests to Sentry's ingest domain, which means the errors you most want, the ones from real users on real phones in real pubs, are the ones least likely to arrive. The fix is a tunnel: reports post to a route on our own origin, and the server forwards them.

tunnelRoute: "/monitoring",
Enter fullscreen mode Exit fullscreen mode

That single line creates a route that must reach its handler with no session, which puts it straight into conflict with the middleware that auth gates everything else. Sentry's own documentation warns about precisely this, and the failure is silent: client side error reporting just stops. So /monitoring is excluded from the matcher, alongside the Stripe webhook and the API routes:

'/((?!_next/static|_next/image|favicon\\.ico|manifest\\.webmanifest|robots\\.txt|sitemap\\.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$|webhook|api|monitoring).*)',
Enter fullscreen mode Exit fullscreen mode

A regex of exceptions is not a thing to be proud of, and each entry has a reason. /webhook because Stripe sends no auth header and must not be intercepted. /api because those routes authenticate themselves. robots.txt and sitemap.xml because they are Next metadata routes rather than files, so the gate saw them and redirected every crawler to /login. And /monitoring because an error report cannot require a logged in user to be delivered.

You can watch the exclusion work:

curl -sI https://pub-trivia.app/monitoring | head -1
curl -sI https://pub-trivia.app/dashboard  | head -1
Enter fullscreen mode Exit fullscreen mode

The first is a 404, because the tunnel only accepts POSTed envelopes, and a 404 is the correct answer to a GET. The second is a 307 to /login. The difference between those two status codes is the whole point: one request reached its handler, the other never got past the gate. If /monitoring answered 307 too, error reporting would be broken and nothing would tell us.

What we rely on instead of the gate is that the route is write-only toward Sentry and carries nothing a session would protect. The general rule I would keep: when you punch a hole in your own middleware, write down in the same place what makes the hole safe. The regex can tell you what is excluded. Only a comment can tell you why it was allowed to be.

Sentry only initialises in production, and what it collects is listed on our privacy page alongside the other processors, because a monitoring tool that quietly becomes an analytics tool is how a privacy policy stops being true.

Check all of it in one go

curl -sI https://pub-trivia.app/ | sort
Enter fullscreen mode Exit fullscreen mode

Every claim in this post is in that output or absent from it on purpose. The absent ones are x-powered-by, which is off, and content-security-policy, which is the next job.

Top comments (0)