From 906722594581ee089ecac031463b4806ff777b08 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 11 Jun 2026 20:27:47 +0200 Subject: [PATCH] fix(audit-2026-06-11/H-A1+A2+A3+M07-06+07+08+09): Caddy security-header layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- caddy/Caddyfile | 31 ++++++++++++++++++++++++++++++- caddy/Caddyfile.prod | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) 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 }