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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user