mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:11:30 +00:00
4f2d865b51
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.
154 lines
5.7 KiB
Go
154 lines
5.7 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestSecurityHeaders_DefaultsAllPresent asserts every default header
|
|
// arrives on a 200 response. H-1 closure (cat-s11-missing_security_headers).
|
|
func TestSecurityHeaders_DefaultsAllPresent(t *testing.T) {
|
|
mw := SecurityHeaders(SecurityHeadersDefaults())
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
for _, h := range []string{
|
|
"Strict-Transport-Security",
|
|
"X-Frame-Options",
|
|
"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)
|
|
}
|
|
}
|
|
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Errorf("X-Content-Type-Options: got %q, want %q", got, "nosniff")
|
|
}
|
|
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
|
|
t.Errorf("X-Frame-Options: got %q, want %q", got, "DENY")
|
|
}
|
|
}
|
|
|
|
// TestSecurityHeaders_EmptyValueDisablesHeader asserts an operator can
|
|
// disable a single header by setting its config field to empty without
|
|
// affecting the others.
|
|
func TestSecurityHeaders_EmptyValueDisablesHeader(t *testing.T) {
|
|
cfg := SecurityHeadersDefaults()
|
|
cfg.HSTS = "" // simulate operator override
|
|
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("Strict-Transport-Security"); got != "" {
|
|
t.Errorf("HSTS should be omitted when config value is empty; got %q", got)
|
|
}
|
|
// Other headers still present
|
|
if got := rec.Header().Get("X-Frame-Options"); got == "" {
|
|
t.Errorf("X-Frame-Options should still be present (empty HSTS only disables HSTS)")
|
|
}
|
|
}
|
|
|
|
// TestSecurityHeaders_OverrideValueApplied asserts a non-default value
|
|
// makes it through.
|
|
func TestSecurityHeaders_OverrideValueApplied(t *testing.T) {
|
|
cfg := SecurityHeadersDefaults()
|
|
cfg.FrameOptions = "SAMEORIGIN"
|
|
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("X-Frame-Options"); got != "SAMEORIGIN" {
|
|
t.Errorf("X-Frame-Options: got %q, want %q", got, "SAMEORIGIN")
|
|
}
|
|
}
|
|
|
|
// TestSecurityHeaders_AppliedOnErrorResponses asserts headers are
|
|
// present on 4xx/5xx as well as 2xx — this is critical for the
|
|
// security posture (an attacker probing for misconfiguration sees
|
|
// the same headers on a 401 as on a 200).
|
|
func TestSecurityHeaders_AppliedOnErrorResponses(t *testing.T) {
|
|
mw := SecurityHeaders(SecurityHeadersDefaults())
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusUnauthorized)
|
|
}
|
|
if got := rec.Header().Get("Strict-Transport-Security"); got == "" {
|
|
t.Errorf("HSTS missing on 401 response (must be on every response)")
|
|
}
|
|
if got := rec.Header().Get("Content-Security-Policy"); got == "" {
|
|
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")
|
|
}
|
|
}
|