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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user