feat(middleware): SEC-008 — Permissions-Policy deny-all-features header

Acquisition-audit SEC-008 closure (Sprint 2 ACQ, 2026-05-16).

Add Permissions-Policy as a sixth security header alongside HSTS,
X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and CSP.
Default value is a deny-all-features baseline:

  accelerometer=(), camera=(), geolocation=(), microphone=(),
  payment=(), usb=(), interest-cohort=()

certctl is a control-plane API + dashboard; no part of the surface
needs camera / microphone / geolocation / accelerometer / payment /
USB access, and `interest-cohort=()` opts out of the deprecated
FLoC browser feature. The deny-all default removes those
attack/fingerprint surfaces if certctl is ever embedded in a
malicious page or if a dashboard route is XSS-compromised
post-CSP-bypass.

Per-field empty-string suppression is preserved: operators who want
to allow a feature (e.g. hardware-attestation flows wanting
WebAuthn's USB transport) can either set Cfg.PermissionsPolicy to
their own narrowed allowlist or set it to "" to suppress the
header entirely.

Tests:
  - TestSecurityHeaders_PermissionsPolicyDefault — pins the literal
    default value byte-for-byte so any widening (e.g. someone adding
    camera=*) breaks the test.
  - TestSecurityHeaders_PermissionsPolicyOverrideToEmptySuppresses —
    pins the operator escape hatch and that the per-field
    suppression contract still holds field-by-field.
  - TestSecurityHeaders_DefaultsAllPresent gains Permissions-Policy
    in its loop, so the existing on-error and on-2xx paths now
    cover the new header too.

The middleware pre-trim slice capacity bumps from 5 → 6 entries.
This commit is contained in:
shankar0123
2026-05-16 17:13:17 +00:00
parent 578ac4ec68
commit 4f2d865b51
2 changed files with 66 additions and 1 deletions
@@ -25,6 +25,7 @@ func TestSecurityHeaders_DefaultsAllPresent(t *testing.T) {
"X-Content-Type-Options",
"Referrer-Policy",
"Content-Security-Policy",
"Permissions-Policy",
} {
if got := rec.Header().Get(h); got == "" {
t.Errorf("expected header %q to be set, got empty", h)
@@ -102,3 +103,51 @@ func TestSecurityHeaders_AppliedOnErrorResponses(t *testing.T) {
t.Errorf("CSP missing on 401 response")
}
}
// TestSecurityHeaders_PermissionsPolicyDefault pins the literal value
// of the default Permissions-Policy header. Acquisition-audit SEC-008
// closure (Sprint 2 ACQ, 2026-05-16). The deny-all baseline removes
// camera/microphone/geolocation/accelerometer/payment/USB/interest-cohort
// attack + fingerprint surfaces — none of which the certctl control
// plane needs. A regression here (e.g. someone widening to allow
// camera=*) would surface as a failing test.
func TestSecurityHeaders_PermissionsPolicyDefault(t *testing.T) {
mw := SecurityHeaders(SecurityHeadersDefaults())
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
got := rec.Header().Get("Permissions-Policy")
if got == "" {
t.Fatal("Permissions-Policy missing from default response")
}
want := "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=(), usb=(), interest-cohort=()"
if got != want {
t.Errorf("Permissions-Policy default = %q; want %q", got, want)
}
}
// TestSecurityHeaders_PermissionsPolicyOverrideToEmptySuppresses pins
// the operator escape hatch: setting Cfg.PermissionsPolicy = "" makes
// the middleware omit the header entirely (per the per-field empty-
// string suppression contract), without affecting the other defaults.
// Acquisition-audit SEC-008 closure (Sprint 2 ACQ, 2026-05-16).
func TestSecurityHeaders_PermissionsPolicyOverrideToEmptySuppresses(t *testing.T) {
cfg := SecurityHeadersDefaults()
cfg.PermissionsPolicy = ""
mw := SecurityHeaders(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if got := rec.Header().Get("Permissions-Policy"); got != "" {
t.Errorf("Permissions-Policy = %q; want empty (operator override-to-empty suppression)", got)
}
if got := rec.Header().Get("Strict-Transport-Security"); got == "" {
t.Errorf("HSTS suppressed too; the empty-string override is per-field")
}
}