feat(deploy): security response headers on every Caddy site (#119 item 33)

A shared (security_headers) snippet, imported first in every site block of the
public, staging and production Caddyfiles: HSTS (one year, no
includeSubDomains), nosniff, strict-origin-when-cross-origin, framing refused
(X-Frame-Options DENY + frame-ancestors 'none'), camera/microphone/
geolocation/payment off, and the Server / X-Powered-By headers dropped.
Approved by the product owner 2026-11-01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
This commit is contained in:
2026-11-01 23:23:58 +08:00
co-authored by Claude Opus 5.5
parent 0f1c179eb6
commit 618d074c94
4 changed files with 80 additions and 0 deletions
+18
View File
@@ -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
}
+19
View File
@@ -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
}
+18
View File
@@ -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
@@ -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]);
}
});