diff --git a/deploy/Caddyfile b/deploy/Caddyfile index c94c8a24..fbc84494 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -1,4 +1,21 @@ +# Security response headers on every site (issue #119, checklist item 33). +# Nothing embeds the site, so framing is refused; HSTS has no includeSubDomains +# because the subdomains are listed one by one. +(security_headers) { + header { + Strict-Transport-Security "max-age=31536000" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + X-Frame-Options "DENY" + Content-Security-Policy "frame-ancestors 'none'" + Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" + -Server + -X-Powered-By + } +} + {$SITE_ADDRESS:https://jyotisha.chat} { + import security_headers encode zstd gzip @htmlDocuments path / /login @@ -11,5 +28,6 @@ } www.jyotisha.chat { + import security_headers redir https://jyotisha.chat{uri} permanent } diff --git a/deploy/Caddyfile.production.selfhosted b/deploy/Caddyfile.production.selfhosted index a4662ac1..fe63154d 100644 --- a/deploy/Caddyfile.production.selfhosted +++ b/deploy/Caddyfile.production.selfhosted @@ -1,4 +1,21 @@ +# Security response headers on every site (issue #119, checklist item 33). +# Nothing embeds the site, so framing is refused; HSTS has no includeSubDomains +# because the subdomains are listed one by one. +(security_headers) { + header { + Strict-Transport-Security "max-age=31536000" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + X-Frame-Options "DENY" + Content-Security-Policy "frame-ancestors 'none'" + Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" + -Server + -X-Powered-By + } +} + {$SITE_ADDRESS:https://jyotisha.chat} { + import security_headers encode zstd gzip @htmlDocuments path / /login @@ -11,6 +28,7 @@ } https://admin.jyotisha.chat { + import security_headers encode zstd gzip @root path / redir @root /admin 308 @@ -18,5 +36,6 @@ https://admin.jyotisha.chat { } https://www.jyotisha.chat { + import security_headers redir https://jyotisha.chat{uri} 308 } diff --git a/deploy/Caddyfile.staging b/deploy/Caddyfile.staging index cb31c654..b182341f 100644 --- a/deploy/Caddyfile.staging +++ b/deploy/Caddyfile.staging @@ -1,4 +1,21 @@ +# Security response headers on every site (issue #119, checklist item 33). +# Nothing embeds the site, so framing is refused; HSTS has no includeSubDomains +# because the subdomains are listed one by one. +(security_headers) { + header { + Strict-Transport-Security "max-age=31536000" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + X-Frame-Options "DENY" + Content-Security-Policy "frame-ancestors 'none'" + Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" + -Server + -X-Powered-By + } +} + {$SITE_ADDRESS:https://staging.jyotisha.chat} { + import security_headers encode zstd gzip @htmlDocuments path / /login @@ -11,6 +28,7 @@ } https://admin.staging.jyotisha.chat { + import security_headers encode zstd gzip @root path / redir @root /admin 308 diff --git a/frontend/tests/stale-client-recovery.test.ts b/frontend/tests/stale-client-recovery.test.ts index 1b7cb07f..dae3a396 100644 --- a/frontend/tests/stale-client-recovery.test.ts +++ b/frontend/tests/stale-client-recovery.test.ts @@ -123,3 +123,28 @@ test("user-facing Caddy files do not let browsers keep the chat HTML across rele assert.match(caddy, /header @htmlDocuments Cache-Control "private, no-store, must-revalidate"/); } }); + +test("every Caddy site sends the security response headers (issue #119 item 33)", () => { + for (const caddy of [stagingCaddy, productionCaddy, publicCaddy]) { + const snippet = caddy.match(/^\(security_headers\) \{\n([\s\S]*?)\n\}\n/m)?.[1] ?? ""; + assert.ok(snippet, "security_headers snippet missing"); + for (const line of [ + 'Strict-Transport-Security "max-age=31536000"', + 'X-Content-Type-Options "nosniff"', + 'Referrer-Policy "strict-origin-when-cross-origin"', + 'X-Frame-Options "DENY"', + `Content-Security-Policy "frame-ancestors 'none'"`, + 'Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"', + "-Server", + "-X-Powered-By", + ]) { + assert.ok(snippet.includes(line), line); + } + // HSTS stays per host: the subdomains are listed one by one, not all covered by TLS here. + assert.doesNotMatch(snippet, /includeSubDomains|preload/); + // Every site block imports it as its first directive. + const sites = [...caddy.matchAll(/^(?!\(security_headers\))\S[^\n]*\{\n([^\n]*)/gm)]; + assert.ok(sites.length >= 2); + for (const site of sites) assert.equal(site[1], " import security_headers", site[0]); + } +});