diff --git a/.env.example b/.env.example index 641cd37..75cd168 100644 --- a/.env.example +++ b/.env.example @@ -173,6 +173,16 @@ AMA_FORM_PLACEHOLDER=What would you like to know? AMA_SUBMIT_LABEL=Submit Question AMA_THANKYOU_COPY=Question submitted! It will appear once answered. +# ============================================================================= +# SECURITY HEADERS +# ============================================================================= + +# Set CSP_DISABLE=true when an edge proxy (Caddy, nginx, Cloudflare) emits its +# own Content-Security-Policy and you want to avoid double-headers. The other +# security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, +# Strict-Transport-Security, Permissions-Policy) always ship. +CSP_DISABLE=false + # ============================================================================= # LOGGING CONFIGURATION # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index 89cec55..b7ff55f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.16.0] - 2026-05-18 + +Theme: **security headers + demo retire.** First user-driven scoping pass — +no anchoring issue. Closes two broken windows: the Pages demo had been +serving 96-day-stale wrong-account content, and the Security middleware +was shipping deprecated `X-XSS-Protection` while missing CSP / HSTS / +Permissions-Policy. A consolidated frontend audit doc catalogs deferred +items for v3.17.0+ cycles. + +### Added + +- **Content-Security-Policy** (enforcing) with a hardcoded SHA-256 hash + for the single inline FOUC-prevention script in `base.html`. + Directives: `default-src 'self'; base-uri 'self'; connect-src 'self'; + font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src + 'self' data: https:; object-src 'none'; script-src 'self' 'sha256-...'; + style-src 'self'`. JSON-LD blocks are unaffected (non-JS MIME). +- **Strict-Transport-Security**: `max-age=31536000; includeSubDomains`. + The `preload` directive is intentionally omitted; opt-in deferred to a + later cycle after observed stability. +- **Permissions-Policy**: denies camera, microphone, geolocation, payment, + USB, magnetometer, gyroscope, accelerometer, and FLoC (`interest-cohort`). +- **`CSP_DISABLE` env var** (default `false`): set to `true` when an edge + proxy emits its own CSP and you want to avoid double-headers. Other + security headers always ship. +- **Regression tests** lock the FOUC script hash to `base.html` contents + and assert exactly one inline JavaScript across templates — a future + inline ``) + match := re.FindSubmatch(data) + require.NotNil(t, match, "expected exactly one inline ) + jsonLD := regexp.MustCompile(`]*type="application/ld\+json"[^>]*>`) + count := 0 + err := fs.WalkDir(web.Assets, "templates", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".html") { + return nil + } + data, err := web.Assets.ReadFile(path) + if err != nil { + return err + } + inlineJS := len(scriptOpen.FindAll(data, -1)) - len(jsonLD.FindAll(data, -1)) + if inlineJS > 0 { + t.Logf("inline JS in %s: %d", path, inlineJS) + } + count += inlineJS + return nil + }) + require.NoError(t, err) + assert.Equal(t, 1, count, + "expected exactly one inline JavaScript across templates (the FOUC script in base.html); "+ + "any new inline executable script needs a CSP hash entry — see foucScriptHash in middleware.go") +} + +// TestSecurity_CSPDisableSkipsCSPHeader verifies CSP_DISABLE=true skips the CSP +// header without affecting the other security headers. +func TestSecurity_CSPDisableSkipsCSPHeader(t *testing.T) { + router := setupTestRouter() + router.Use(Security(&config.Config{Security: config.SecurityConfig{CSPDisable: true}})) + router.GET("/test", func(c *gin.Context) { c.String(200, "ok") }) + + req := httptest.NewRequest("GET", "/test", http.NoBody) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Empty(t, w.Header().Get("Content-Security-Policy")) + assert.Equal(t, "max-age=31536000; includeSubDomains", w.Header().Get("Strict-Transport-Security")) + assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options")) + assert.Contains(t, w.Header().Get("Permissions-Policy"), "interest-cohort=()") } // TestLogger tests the logger middleware