From 09680557ef68337fd9d1a8daee8c479ce5e40e35 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 11 May 2026 14:06:13 +0200 Subject: [PATCH] security(caddy): add Permissions-Policy header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Permissions-Policy: camera=(), microphone=(), geolocation=()` to the shared (security_headers) snippet, so both archiv vhosts and the git vhost deny browser APIs the app does not use. Reduces blast radius of an XSS landing in a privileged origin. The deploy smoke steps in nightly.yml and release.yml gain a matching assertion against the canonical header value, so a future Caddyfile edit that drops or loosens the header (e.g. `camera=(self)`) fails the deploy instead of regressing silently. `caddy validate` against caddy:2 passes; both workflow YAMLs parse. Addresses @nora's round-2 suggestion on PR #499 — "lower-impact than CSP but nearly free". Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/nightly.yml | 5 +++++ .gitea/workflows/release.yml | 5 +++++ infra/caddy/Caddyfile | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 3353e6f7..870a5a99 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -112,6 +112,11 @@ jobs: # fail this check rather than pass it silently. curl -fsS $RESOLVE --max-time 10 -I "$URL/" \ | grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload' + # Permissions-Policy denies APIs the app does not use (camera, + # microphone, geolocation). A regression that loosens or drops the + # header now fails the smoke step. + curl -fsS $RESOLVE --max-time 10 -I "$URL/" \ + | grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)' status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health") [ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; } echo "All smoke checks passed" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 69e59dd6..3d5c99d1 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -106,6 +106,11 @@ jobs: # fail this check rather than pass it silently. curl -fsS $RESOLVE --max-time 10 -I "$URL/" \ | grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload' + # Permissions-Policy denies APIs the app does not use (camera, + # microphone, geolocation). A regression that loosens or drops the + # header now fails the smoke step. + curl -fsS $RESOLVE --max-time 10 -I "$URL/" \ + | grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)' status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health") [ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; } echo "All smoke checks passed" diff --git a/infra/caddy/Caddyfile b/infra/caddy/Caddyfile index b5dfd345..fc6b02bb 100644 --- a/infra/caddy/Caddyfile +++ b/infra/caddy/Caddyfile @@ -18,6 +18,10 @@ Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" + # Deny browser APIs the app does not use. Reduces blast radius of an + # XSS landing in a privileged origin: a payload cannot silently turn + # on the microphone or read geolocation. + Permissions-Policy "camera=(), microphone=(), geolocation=()" -Server } }