mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
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:
@@ -25,6 +25,7 @@ type SecurityHeadersConfig struct {
|
|||||||
ContentTypeOptions string // X-Content-Type-Options
|
ContentTypeOptions string // X-Content-Type-Options
|
||||||
ReferrerPolicy string // Referrer-Policy
|
ReferrerPolicy string // Referrer-Policy
|
||||||
ContentSecurityPolicy string // Content-Security-Policy
|
ContentSecurityPolicy string // Content-Security-Policy
|
||||||
|
PermissionsPolicy string // Permissions-Policy (SEC-008 closure, Sprint 2 ACQ 2026-05-16)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecurityHeadersDefaults returns a recommended baseline.
|
// SecurityHeadersDefaults returns a recommended baseline.
|
||||||
@@ -78,6 +79,19 @@ type SecurityHeadersConfig struct {
|
|||||||
// Referrer-Policy: no-referrer-when-downgrade — preserves Referer
|
// Referrer-Policy: no-referrer-when-downgrade — preserves Referer
|
||||||
// for same-origin navigation (useful for support/diagnostics) but
|
// for same-origin navigation (useful for support/diagnostics) but
|
||||||
// strips it on HTTPS→HTTP transitions.
|
// 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 {
|
func SecurityHeadersDefaults() SecurityHeadersConfig {
|
||||||
return SecurityHeadersConfig{
|
return SecurityHeadersConfig{
|
||||||
HSTS: "max-age=31536000; includeSubDomains",
|
HSTS: "max-age=31536000; includeSubDomains",
|
||||||
@@ -85,6 +99,7 @@ func SecurityHeadersDefaults() SecurityHeadersConfig {
|
|||||||
ContentTypeOptions: "nosniff",
|
ContentTypeOptions: "nosniff",
|
||||||
ReferrerPolicy: "no-referrer-when-downgrade",
|
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'",
|
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
|
// Pre-trim each value once; the per-request hot path stays a
|
||||||
// straight set of map writes.
|
// straight set of map writes.
|
||||||
type headerEntry struct{ name, value string }
|
type headerEntry struct{ name, value string }
|
||||||
entries := make([]headerEntry, 0, 5)
|
entries := make([]headerEntry, 0, 6)
|
||||||
add := func(name, value string) {
|
add := func(name, value string) {
|
||||||
v := strings.TrimSpace(value)
|
v := strings.TrimSpace(value)
|
||||||
if v != "" {
|
if v != "" {
|
||||||
@@ -112,6 +127,7 @@ func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler
|
|||||||
add("X-Content-Type-Options", cfg.ContentTypeOptions)
|
add("X-Content-Type-Options", cfg.ContentTypeOptions)
|
||||||
add("Referrer-Policy", cfg.ReferrerPolicy)
|
add("Referrer-Policy", cfg.ReferrerPolicy)
|
||||||
add("Content-Security-Policy", cfg.ContentSecurityPolicy)
|
add("Content-Security-Policy", cfg.ContentSecurityPolicy)
|
||||||
|
add("Permissions-Policy", cfg.PermissionsPolicy)
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func TestSecurityHeaders_DefaultsAllPresent(t *testing.T) {
|
|||||||
"X-Content-Type-Options",
|
"X-Content-Type-Options",
|
||||||
"Referrer-Policy",
|
"Referrer-Policy",
|
||||||
"Content-Security-Policy",
|
"Content-Security-Policy",
|
||||||
|
"Permissions-Policy",
|
||||||
} {
|
} {
|
||||||
if got := rec.Header().Get(h); got == "" {
|
if got := rec.Header().Get(h); got == "" {
|
||||||
t.Errorf("expected header %q to be set, got empty", h)
|
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")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user