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 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 21:52:54 +02:00
parent e960aae163
commit 90f2398e56
5 changed files with 28 additions and 5 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.128.22" version = "0.128.23"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.128.22" version = "0.128.23"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.128.22", "version": "0.128.23",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -370,6 +370,16 @@ describe('applySecurityHeaders', () => {
expect(pp).toContain('geolocation=()'); 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', () => { it('does not clobber a header the response already set', () => {
// If a downstream route deliberately set a stricter Referrer-Policy, // If a downstream route deliberately set a stricter Referrer-Policy,
// the helper must not override it. // the helper must not override it.

View File

@@ -124,13 +124,23 @@ export function shouldBypassProxyTimeout(headers: Headers): boolean {
* stricter value keeps it. Mutates and returns the passed `Headers`. Exported * stricter value keeps it. Mutates and returns the passed `Headers`. Exported
* for unit-test coverage. * for unit-test coverage.
*/ */
export function applySecurityHeaders(headers: Headers): Headers { export function applySecurityHeaders(
headers: Headers,
opts: { hsts?: boolean } = {}
): Headers {
const defaults: Record<string, string> = { const defaults: Record<string, string> = {
'x-frame-options': 'DENY', 'x-frame-options': 'DENY',
'referrer-policy': 'strict-origin-when-cross-origin', 'referrer-policy': 'strict-origin-when-cross-origin',
'x-content-type-options': 'nosniff', 'x-content-type-options': 'nosniff',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()' '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)) { for (const [name, value] of Object.entries(defaults)) {
if (!headers.has(name)) headers.set(name, value); if (!headers.has(name)) headers.set(name, value);
} }
@@ -232,6 +242,9 @@ export const handle: Handle = async ({ event, resolve }) => {
}); });
} }
const response = await resolve(event); 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; return response;
}; };