diff --git a/caddy/Caddyfile b/caddy/Caddyfile index 9b06ee6..877d9d7 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -14,7 +14,14 @@ # # When v2 of the API ships, add `handle /api/v2/admin/* { ... }` etc. # 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 admin off @@ -25,6 +32,12 @@ } :80 { + # Baseline headers applied to every response. + header { + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + } + handle /healthz { reverse_proxy picloud:8080 } @@ -33,6 +46,12 @@ } 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 } handle /api/v1/execute/* { @@ -50,10 +69,20 @@ # (root); we don't strip the prefix because SvelteKit was built with # paths.base = '/admin' so the bundle's URLs already include it. 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 } handle /admin { # 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 } diff --git a/caddy/Caddyfile.prod b/caddy/Caddyfile.prod index 8b037b4..28536e6 100644 --- a/caddy/Caddyfile.prod +++ b/caddy/Caddyfile.prod @@ -3,7 +3,15 @@ # Set PICLOUD_DOMAIN and PICLOUD_ADMIN_EMAIL in the environment Caddy is # started from (docker-compose.prod.yml passes them through). Caddy then # 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} } @@ -11,6 +19,13 @@ {$PICLOUD_DOMAIN} { 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 { reverse_proxy picloud:8080 } @@ -19,6 +34,12 @@ } 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 } handle /api/v1/execute/* { @@ -33,9 +54,19 @@ } 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 } 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 }