Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 5 min read

Our 38 page site sends exactly one security header, and we did not write it

Notifio is a desktop rental alert app with a 38 page marketing site in front of it. I went looking for the file where the site's security headers are configured, could not find it, and then realised that was the finding.

Notifio is a desktop rental alert app with a 38 page marketing site in front of it. I went looking for the file where the site's security headers are configured, could not find it, and then realised that was the finding.

The sitemap is the page list, so the audit is one loop:

curl -s https://notifio.app/sitemap.xml \
  | grep -o '<loc>[^<]*' | cut -c6- > routes.txt

while read u; do curl -s -D - -o /dev/null "$u"; done < routes.txt

Thirty-eight pages, 19 distinct response header names between them, and this is the entire security relevant yield:

strict-transport-security: max-age=63072000     38/38
content-security-policy                          0/38
x-frame-options                                  0/38
x-content-type-options                           0/38
referrer-policy                                  0/38
permissions-policy                               0/38
cross-origin-opener-policy                       0/38
cross-origin-resource-policy                     0/38

One out of eight, and the one is Vercel's. Nothing in our repository asks for it.

The config file is eight lines and two of them are a comment about something else

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Stripe webhooks need the raw body, disable body parsing for that route
  // handled per-route via export const config
};

export default nextConfig;

That is the whole of next.config.ts. No headers(), no poweredByHeader, no redirects. There is no vercel.json and no middleware.ts either. A grep for the eight header names above across the entire server source returns nothing.

What the source does contain is header reads. Seven route files touch headers and every one of them is reading:

api/contact          x-forwarded-for     rate limit key
api/checkout         x-forwarded-for     rate limit key
api/checkout/upgrade x-forwarded-for     rate limit key
api/notify           x-forwarded-for     rate limit key
api/validate         x-forwarded-for     rate limit key
api/webhooks/resend  svix-id, svix-timestamp, svix-signature
api/webhooks/stripe  stripe-signature

Seven files read a request header. Zero files set a response header. Every one of the 19 names a visitor sees was put there by Next.js or by Vercel's edge, which is worth knowing because it means none of it is under our version control and none of it will show up in a diff if it changes.

The headers do split the site in two, just not on security

The census does separate the pages cleanly, into 35 and 3:

35 pages   x-nextjs-prerender: 1, etag, content-disposition: inline,
           access-control-allow-origin: *, cache-control: public
 3 pages   x-powered-by: Next.js, link: <...>; rel=preload,
           cache-control: private, no-cache, no-store, must-revalidate

The three are the homepage, /pricing and /download, which is exactly right: those are the three that render per request, because the price is chosen from the visitor's country and the download buttons from their platform. They are the only pages carrying x-powered-by, because that header comes from the Node runtime and a page served off the static cache never reaches it. So x-powered-by: Next.js on this site is not a framework leak so much as a live marker of which routes are dynamic.

The other 34, like this one, came out of the build and are served by the CDN with an etag and access-control-allow-origin: *. Vercel adds that wildcard to static assets, which means any origin can fetch() our marketing HTML. There is nothing in it that is not public, so that is fine, but it is not a decision anybody made.

Why this is a low grade finding, stated honestly

It would be easy to write this up as a scary audit. It is not one, and the reason is in two more lines of the same census:

set-cookie                                       0/38
third-party origins referenced in 38 pages of HTML:  0

No page on this site sets a cookie. There is no login, no session, no CSRF-able form. The contact form on /help posts JSON and the only thing an attacker could achieve by framing it is sending me an email, which they can also do by using the form. A missing X-Frame-Options matters when a framed page can be clicked into performing an authenticated action, and there is no authenticated action here. Modern browsers also default to strict-origin-when-cross-origin for referrers now, so the missing Referrer-Policy is a smaller gap than it was in 2019.

The one I would actually fix first is the cheapest and dullest: X-Content-Type-Options: nosniff. It costs nothing and it closes the one class of bug a static marketing site can genuinely have, which is a user supplied file served with a guessable type. We serve no user files today. The point of the header is that the day somebody does, the header is already there.

The CSP is unusually cheap here, and the bill is inline scripts

This part surprised me. I expected a CSP to be the expensive one, because on most marketing sites it is: an analytics script, a tag manager, a chat widget, two font CDNs, and script-src becomes a list of other people's domains that changes without telling you.

Here it is a single line, because there is nothing to allow:

absolute URLs in src/href across 38 pages:  76
distinct origins among them:                 1  (https://notifio.app)

Fonts are self hosted, icons are inline SVG, there is no analytics script, there is no video embed. default-src 'self' would not break a single request on the site.

The actual cost is the other number in that scan:

script tags across 38 pages:  1,059
with no src attribute:          750

Those 750 are the React flight payload, the inline chunks that let a server rendered page hydrate without a round trip. A real script-src 'self' would kill all of them. The honest options are a nonce from middleware, which means adding the middleware file this site does not have and making every page dynamic, or 'unsafe-inline', which makes script-src mostly decorative. That tradeoff is the real reason there is no CSP here, and "we never got round to it" was only the surface reason.

The HSTS we did not write is also not finished

strict-transport-security: max-age=63072000

Two years, no includeSubdomains, no preload. So the apex is pinned to HTTPS after a visitor's first request, subdomains are not covered at all, and the domain is not in the browser preload list, which means that very first request is still available to be downgraded. Vercel picked a sensible default and stopped exactly where a platform should stop, because includeSubdomains is a promise about hosts the platform does not know about and preload is close to irreversible.

That is the thing worth taking away from a header census. The absence of a headers() block does not mean no headers; it means the platform's defaults are your policy, and they are tuned for not breaking anybody's subdomain rather than for your threat model. Finding out which of your headers you actually chose takes one curl loop, and on this site the answer was none of them.

Related reading on this site's plumbing: I counted every internal link on our 38 page site and 61% of our HTML is inside script tags, which is where those 750 inline tags got measured the first time.

πŸ“° 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.