From 4f2d865b51da4079e7d61818706dd9886f41a498 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sat, 16 May 2026 17:13:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(middleware):=20SEC-008=20=E2=80=94=20Permi?= =?UTF-8?q?ssions-Policy=20deny-all-features=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/api/middleware/securityheaders.go | 18 ++++++- .../api/middleware/securityheaders_test.go | 49 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/internal/api/middleware/securityheaders.go b/internal/api/middleware/securityheaders.go index d2b4a86..790b98f 100644 --- a/internal/api/middleware/securityheaders.go +++ b/internal/api/middleware/securityheaders.go @@ -25,6 +25,7 @@ type SecurityHeadersConfig struct { ContentTypeOptions string // X-Content-Type-Options ReferrerPolicy string // Referrer-Policy ContentSecurityPolicy string // Content-Security-Policy + PermissionsPolicy string // Permissions-Policy (SEC-008 closure, Sprint 2 ACQ 2026-05-16) } // SecurityHeadersDefaults returns a recommended baseline. @@ -78,6 +79,19 @@ type SecurityHeadersConfig struct { // Referrer-Policy: no-referrer-when-downgrade — preserves Referer // for same-origin navigation (useful for support/diagnostics) but // strips it on HTTPS→HTTP transitions. +// +// Permissions-Policy: deny-all-browser-features default. Acquisition- +// audit SEC-008 closure (Sprint 2 ACQ, 2026-05-16). certctl is a +// control-plane API + dashboard; no part of the surface needs +// access to the camera, microphone, geolocation, accelerometer, +// payment, USB, or the deprecated `interest-cohort` (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. Operators +// running certctl with intentional dependence on any of these (e.g. +// hardware-attestation flows wanting WebAuthn's USB transport) can +// set `Cfg.PermissionsPolicy: ""` to suppress the header entirely, +// or override with their own narrowed allowlist. func SecurityHeadersDefaults() SecurityHeadersConfig { return SecurityHeadersConfig{ HSTS: "max-age=31536000; includeSubDomains", @@ -85,6 +99,7 @@ func SecurityHeadersDefaults() SecurityHeadersConfig { ContentTypeOptions: "nosniff", ReferrerPolicy: "no-referrer-when-downgrade", ContentSecurityPolicy: "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'", + PermissionsPolicy: "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=(), usb=(), interest-cohort=()", } } @@ -100,7 +115,7 @@ func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler // Pre-trim each value once; the per-request hot path stays a // straight set of map writes. type headerEntry struct{ name, value string } - entries := make([]headerEntry, 0, 5) + entries := make([]headerEntry, 0, 6) add := func(name, value string) { v := strings.TrimSpace(value) if v != "" { @@ -112,6 +127,7 @@ func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler add("X-Content-Type-Options", cfg.ContentTypeOptions) add("Referrer-Policy", cfg.ReferrerPolicy) add("Content-Security-Policy", cfg.ContentSecurityPolicy) + add("Permissions-Policy", cfg.PermissionsPolicy) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/middleware/securityheaders_test.go b/internal/api/middleware/securityheaders_test.go index a649ce3..68a8757 100644 --- a/internal/api/middleware/securityheaders_test.go +++ b/internal/api/middleware/securityheaders_test.go @@ -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") + } +}