fix(audit-2026-06-11/H-A1+A2+A3+M07-06+07+08+09): Caddy security-header layer
Adds defense-in-depth HTTP headers to the dev and prod Caddyfiles. CSP,
X-Frame-Options, Permissions-Policy, and Cache-Control: no-store apply
to the dashboard SPA (/admin/*) and admin API (/api/v1/admin/*). nosniff
and Referrer-Policy apply to every response (a sane default). HSTS is
unconditional in prod.
User-route responses (the catch-all `handle`) deliberately get NO CSP —
user scripts own their own response headers by design.
The CSP / X-Frame-Options / Cache-Control / Permissions-Policy headers
use the `?` operator ("set only if not already present") so application
responses with their own restrictive policy — notably the audit C-2
file-download handler, which sends a sandboxed CSP — win over Caddy's
defaults.
This downgrades H-A4 (dashboard token in localStorage) from acute to
defense-in-depth: with this CSP and no inline JS in the dashboard,
XSS-based token exfiltration is no longer a one-step exploit.
Validated with `caddy validate` (docker caddy:2-alpine) for both files;
also `caddy fmt --overwrite`'d.
Audit ref: security_audit/07_http_cors_csrf_xss.md (H07-03/04/05 +
M07-06/07/08/09).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,14 @@
|
|||||||
#
|
#
|
||||||
# When v2 of the API ships, add `handle /api/v2/admin/* { ... }` etc.
|
# When v2 of the API ships, add `handle /api/v2/admin/* { ... }` etc.
|
||||||
# alongside the v1 handles, before the catch-all `/api/*` 404.
|
# alongside the v1 handles, before the catch-all `/api/*` 404.
|
||||||
{
|
#
|
||||||
|
# Audit 2026-06-11 (H-A1/2/3, M07-06/07/08/09): defense-in-depth headers.
|
||||||
|
# CSP / X-Frame-Options / Permissions-Policy / no-store land on the
|
||||||
|
# dashboard SPA and admin API; nosniff + Referrer-Policy land everywhere.
|
||||||
|
# User-route responses (catch-all `handle`) deliberately get NO CSP —
|
||||||
|
# user scripts own their own response headers by design.
|
||||||
|
# `?` operator = "default if missing" so a downstream response (e.g. the
|
||||||
|
# file-download endpoint with its own restrictive CSP) wins.
|
||||||
{
|
{
|
||||||
auto_https off
|
auto_https off
|
||||||
admin off
|
admin off
|
||||||
@@ -25,6 +32,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
|
# Baseline headers applied to every response.
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
}
|
||||||
|
|
||||||
handle /healthz {
|
handle /healthz {
|
||||||
reverse_proxy picloud:8080
|
reverse_proxy picloud:8080
|
||||||
}
|
}
|
||||||
@@ -33,6 +46,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle /api/v1/admin/* {
|
handle /api/v1/admin/* {
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
?Cache-Control "no-store"
|
||||||
|
?Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()"
|
||||||
|
}
|
||||||
reverse_proxy picloud:8080
|
reverse_proxy picloud:8080
|
||||||
}
|
}
|
||||||
handle /api/v1/execute/* {
|
handle /api/v1/execute/* {
|
||||||
@@ -50,10 +69,20 @@
|
|||||||
# (root); we don't strip the prefix because SvelteKit was built with
|
# (root); we don't strip the prefix because SvelteKit was built with
|
||||||
# paths.base = '/admin' so the bundle's URLs already include it.
|
# paths.base = '/admin' so the bundle's URLs already include it.
|
||||||
handle /admin/* {
|
handle /admin/* {
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
?Cache-Control "no-store"
|
||||||
|
?Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()"
|
||||||
|
}
|
||||||
reverse_proxy dashboard:80
|
reverse_proxy dashboard:80
|
||||||
}
|
}
|
||||||
handle /admin {
|
handle /admin {
|
||||||
# Bare /admin (no trailing slash) — let SvelteKit's SPA handle it.
|
# Bare /admin (no trailing slash) — let SvelteKit's SPA handle it.
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
}
|
||||||
reverse_proxy dashboard:80
|
reverse_proxy dashboard:80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
# Set PICLOUD_DOMAIN and PICLOUD_ADMIN_EMAIL in the environment Caddy is
|
# Set PICLOUD_DOMAIN and PICLOUD_ADMIN_EMAIL in the environment Caddy is
|
||||||
# started from (docker-compose.prod.yml passes them through). Caddy then
|
# started from (docker-compose.prod.yml passes them through). Caddy then
|
||||||
# obtains and renews a Let's Encrypt cert automatically for that domain.
|
# obtains and renews a Let's Encrypt cert automatically for that domain.
|
||||||
{
|
#
|
||||||
|
# Audit 2026-06-11 (H-A1/2/3/5, M07-06/07/08/09): defense-in-depth
|
||||||
|
# headers. HSTS lands on every prod response. CSP, X-Frame-Options,
|
||||||
|
# Permissions-Policy, and Cache-Control: no-store land on the dashboard
|
||||||
|
# SPA and admin API. nosniff + Referrer-Policy land everywhere. User-
|
||||||
|
# route responses (catch-all `handle`) deliberately get NO CSP — user
|
||||||
|
# scripts own their own response headers by design.
|
||||||
|
# `?` operator = "default if missing" so downstream responses (e.g. the
|
||||||
|
# file-download endpoint with its own restrictive CSP) win.
|
||||||
{
|
{
|
||||||
email {$PICLOUD_ADMIN_EMAIL}
|
email {$PICLOUD_ADMIN_EMAIL}
|
||||||
}
|
}
|
||||||
@@ -11,6 +19,13 @@
|
|||||||
{$PICLOUD_DOMAIN} {
|
{$PICLOUD_DOMAIN} {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Baseline headers on every response. HSTS is unconditional in prod.
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
}
|
||||||
|
|
||||||
handle /healthz {
|
handle /healthz {
|
||||||
reverse_proxy picloud:8080
|
reverse_proxy picloud:8080
|
||||||
}
|
}
|
||||||
@@ -19,6 +34,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle /api/v1/admin/* {
|
handle /api/v1/admin/* {
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
?Cache-Control "no-store"
|
||||||
|
?Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()"
|
||||||
|
}
|
||||||
reverse_proxy picloud:8080
|
reverse_proxy picloud:8080
|
||||||
}
|
}
|
||||||
handle /api/v1/execute/* {
|
handle /api/v1/execute/* {
|
||||||
@@ -33,9 +54,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle /admin/* {
|
handle /admin/* {
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
?Cache-Control "no-store"
|
||||||
|
?Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()"
|
||||||
|
}
|
||||||
reverse_proxy dashboard:80
|
reverse_proxy dashboard:80
|
||||||
}
|
}
|
||||||
handle /admin {
|
handle /admin {
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
}
|
||||||
reverse_proxy dashboard:80
|
reverse_proxy dashboard:80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user