From b4e5300febeebab74c5a9197772a20c3c09a64cc Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 20:43:13 +0200 Subject: [PATCH] feat: add HSTS, CSP, and Permissions-Policy security headers (closes #91) SecurityHeaders() now also sets Strict-Transport-Security (one year, includeSubDomains), a Content-Security-Policy (default-src self, frame-ancestors none) and a Permissions-Policy denying the browser features pixa does not use. X-Frame-Options stays as the legacy fallback. What a reader would trip over: HSTS is sent on every response even though pixa listens on plain HTTP behind a TLS-terminating proxy; browsers ignore the header over plaintext, and this avoids trusting a forwarded-proto header. The clipboard feature is left unlisted so the copy button on the generator page keeps working. Disclosure: script-src and style-src carry unsafe-inline because the generator template has inline onclick handlers and the bundled Tailwind script injects a style element at runtime; removing it needs template changes and is tracked separately. Model: opus-4-8 (implementation, review); fable-5-1 (landing message) --- internal/middleware/middleware.go | 37 +++++++++++++ .../middleware/middleware_internal_test.go | 55 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 754ebd1..00d59f7 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -21,6 +21,33 @@ import ( // CORSMaxAgeSeconds is the max age for CORS preflight cache (24 hours). const CORSMaxAgeSeconds = 86400 +// HSTSValue is the Strict-Transport-Security header value: one year with +// includeSubDomains. Emitted unconditionally even though pixa listens plain +// HTTP behind a TLS-terminating proxy; browsers ignore an HSTS header received +// over plaintext (RFC 6797 section 8.1), so it never lies about the connection, +// and emitting it here avoids trusting a forwarded-proto header. +const HSTSValue = "max-age=31536000; includeSubDomains" + +// ContentSecurityPolicyValue is the Content-Security-Policy header value. +// default-src 'self' is the baseline and frame-ancestors 'none' is the primary +// clickjacking control. 'unsafe-inline' is required in script-src and style-src +// because the served templates carry inline onclick handlers (generator page) +// and the bundled Tailwind asset injects a runtime