From 6ff353b94a0eb8e68fbbd939faa253075684a193 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Sat, 1 Aug 2026 23:02:02 +0300 Subject: [PATCH] panel: security headers and an origin check on unsafe methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 14.A, both halves of it in one middleware wrapped around the whole router — outside requireAuth, so POST /login and POST /setup/{token} are covered too. Headers: CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and HSTS only where the deployment is HTTPS-only (the same CookieSecure condition that gates the cookie's Secure attribute; on the plain-HTTP dev instance HSTS would pin the browser to a scheme that instance does not speak). HSTS goes without includeSubDomains on purpose: the panel may sit at an apex, and forcing HTTPS on every unrelated subdomain of the operator's domain for a year is not this project's call. They are emitted here rather than delegated to the reverse proxy, so the part that is easy to get wrong lives in the service. Origin check: this is what SameSite=Lax cannot do. SameSite is judged per site (registrable domain), so a neighbouring host — a CMS, a forgotten staging subdomain — is same-site and its forged POST arrives with the session cookie attached. Sec-Fetch-Site and Origin are judged per origin and tell it apart. For the typical deployment, where the panel is a subdomain of a domain used for other things, that neighbour is the realistic attacker, not a theoretical one; POST /domains/import is the case that turns a blind write into working credentials on someone else's relay. A request carrying neither header is still let through — the risk accepted in the plan, since a client that old cannot be checked at all and the strict mode would not protect it, only break the panel in it. The check compares Origin's host against r.Host, not the full origin: the panel speaks plain HTTP behind the proxy and does not know its own external scheme. That makes it depend on the proxy preserving Host. All four shipped fragments do, but one that rewrites it would turn every form submission into a 403, so the rejection logs both sides of the comparison — otherwise the symptom reads as "the panel stopped saving anything". Co-Authored-By: Claude Opus 5 --- internal/web/security.go | 126 +++++++++++++++++++++++++++++++++ internal/web/security_test.go | 129 ++++++++++++++++++++++++++++++++++ internal/web/web.go | 4 +- 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 internal/web/security.go create mode 100644 internal/web/security_test.go diff --git a/internal/web/security.go b/internal/web/security.go new file mode 100644 index 0000000..b2eb1d4 --- /dev/null +++ b/internal/web/security.go @@ -0,0 +1,126 @@ +package web + +import ( + "net/http" + "net/url" +) + +// contentSecurityPolicy is the panel's CSP. Everything the pages load — +// stylesheet, HTMX, the panel's own script, the favicon — is served from +// /static by this same origin, and no template carries an inline