From 90f2398e5687a4d591e2ee067def7403f5e07693 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 13 Jul 2026 21:52:54 +0200 Subject: [PATCH] fix: send HSTS on HTTPS document responses Add Strict-Transport-Security (max-age=63072000; includeSubDomains) in applySecurityHeaders, gated on x-forwarded-proto === 'https' so a plain-HTTP dev/misconfig deploy never pins the browser to HTTPS (audit LOW). Tested. Co-Authored-By: Claude Opus 4.8 --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/package.json | 2 +- frontend/src/hooks.server.test.ts | 10 ++++++++++ frontend/src/hooks.server.ts | 17 +++++++++++++++-- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5bf0041..1f493cb 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.128.22" +version = "0.128.23" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 15e7a59..42192e5 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.128.22" +version = "0.128.23" edition = "2021" default-run = "mangalord" diff --git a/frontend/package.json b/frontend/package.json index 1b2458d..b174fc4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.128.22", + "version": "0.128.23", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index efa54cf..4cc2445 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -370,6 +370,16 @@ describe('applySecurityHeaders', () => { expect(pp).toContain('geolocation=()'); }); + it('emits HSTS only when the request was secure', () => { + const insecure = applySecurityHeaders(new Headers()); + expect(insecure.get('strict-transport-security')).toBeNull(); + + const secure = applySecurityHeaders(new Headers(), { hsts: true }); + expect(secure.get('strict-transport-security')).toBe( + 'max-age=63072000; includeSubDomains' + ); + }); + it('does not clobber a header the response already set', () => { // If a downstream route deliberately set a stricter Referrer-Policy, // the helper must not override it. diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 4ad2fbb..3e951db 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -124,13 +124,23 @@ export function shouldBypassProxyTimeout(headers: Headers): boolean { * stricter value keeps it. Mutates and returns the passed `Headers`. Exported * for unit-test coverage. */ -export function applySecurityHeaders(headers: Headers): Headers { +export function applySecurityHeaders( + headers: Headers, + opts: { hsts?: boolean } = {} +): Headers { const defaults: Record = { 'x-frame-options': 'DENY', 'referrer-policy': 'strict-origin-when-cross-origin', 'x-content-type-options': 'nosniff', 'permissions-policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()' }; + // HSTS ONLY when the browser reached us over HTTPS (the caller derives this + // from x-forwarded-proto). Sending it over plain HTTP — a dev run or a + // misconfigured deploy — would pin the browser to HTTPS for this host for + // two years and could lock users out, so it's opt-in per request. + if (opts.hsts) { + defaults['strict-transport-security'] = 'max-age=63072000; includeSubDomains'; + } for (const [name, value] of Object.entries(defaults)) { if (!headers.has(name)) headers.set(name, value); } @@ -232,6 +242,9 @@ export const handle: Handle = async ({ event, resolve }) => { }); } const response = await resolve(event); - applySecurityHeaders(response.headers); + // The TLS terminator (reverse proxy) sets x-forwarded-proto; only emit HSTS + // when the browser's hop to it was HTTPS. + const secure = event.request.headers.get('x-forwarded-proto') === 'https'; + applySecurityHeaders(response.headers, { hsts: secure }); return response; };