More security headers and checks

This commit is contained in:
Jonathon Leight committed 2026-07-02 11:39:02 -04:00
1 parent b0683af5a0
commit 01c7adffdd
4 files changed
+75 -6

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ when:
depends_on:
- test
- lint
- vuln
steps:
publish:
+20
View File
@@ -0,0 +1,20 @@
# Scans dependencies and the Go stdlib for known, reachable vulnerabilities
# (govulncheck uses call-graph analysis, so it flags CVEs the code actually
# reaches, not just present-in-go.sum). Runs on every push and PR; the build
# depends on it, so a reachable advisory blocks the image build and deploy.
# Tracks the tool's latest, matching lint.yaml's :latest philosophy; the vuln DB
# is fetched at run.
when:
- event: [push, pull_request]
steps:
govulncheck:
image: golang:1.26.4
environment:
# go.mod may target a newer Go than the image bundles; fetch on demand.
GOTOOLCHAIN: auto
commands:
- go install golang.org/x/vuln/cmd/govulncheck@latest
- govulncheck ./...
depends_on: []
+22 -6
View File
@@ -29,16 +29,32 @@ var cspDirectives = strings.Join([]string{
"object-src 'none'",
}, "; ")
// permissionsPolicy denies powerful browser features we don't use and explicitly
// allows the two we do: WebSerial (the KISS modem on the confirm/console pages)
// and WebAuthn (passkeys). Unknown tokens are ignored by browsers that don't
// implement them.
const permissionsPolicy = "serial=(self), " +
"publickey-credentials-get=(self), publickey-credentials-create=(self), " +
"geolocation=(), camera=(), microphone=(), payment=(), usb=()"
// securityHeaders sets a strict Content-Security-Policy (with a fresh per-request
// script nonce, also exposed via the context for templates) plus a couple of
// companion hardening headers, on every response.
// script nonce, also exposed via the context for templates) plus companion
// hardening headers, on every response.
func (e *Env) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nonce := newNonce()
w.Header().Set("Content-Security-Policy",
cspDirectives+"; script-src 'self' 'nonce-"+nonce+"'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
h := w.Header()
h.Set("Content-Security-Policy", cspDirectives+"; script-src 'self' 'nonce-"+nonce+"'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("X-Frame-Options", "DENY") // old-browser fallback for frame-ancestors 'none'
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Permissions-Policy", permissionsPolicy)
// HSTS only over TLS — never on plain-http localhost dev, where it would
// pin the browser to https for the dev host.
if e.Cfg != nil && e.Cfg.Secure {
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), nonceCtxKey{}, nonce)))
})
}
+32
View File
@@ -8,6 +8,8 @@ import (
"regexp"
"strings"
"testing"
"github.com/jleight/meshtender/internal/config"
)
func TestSecurityHeadersCSPNonce(t *testing.T) {
@@ -33,11 +35,41 @@ func TestSecurityHeadersCSPNonce(t *testing.T) {
if rec1.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Errorf("missing X-Content-Type-Options: nosniff")
}
if rec1.Header().Get("X-Frame-Options") != "DENY" {
t.Errorf("missing X-Frame-Options: DENY")
}
if rec1.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" {
t.Errorf("missing Cross-Origin-Opener-Policy: same-origin")
}
// Permissions-Policy must keep the features we actually use.
pp := rec1.Header().Get("Permissions-Policy")
if !strings.Contains(pp, "serial=(self)") || !strings.Contains(pp, "publickey-credentials-get=(self)") {
t.Errorf("Permissions-Policy doesn't allow serial/webauthn: %q", pp)
}
if seen[0] == "" || seen[1] == "" || seen[0] == seen[1] {
t.Errorf("nonce not fresh per request: %q, %q", seen[0], seen[1])
}
}
func TestSecurityHeadersHSTSGatedOnTLS(t *testing.T) {
t.Parallel()
// No TLS (nil/insecure config): no HSTS.
rec := httptest.NewRecorder()
(&Env{}).securityHeaders(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).
ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Header().Get("Strict-Transport-Security") != "" {
t.Errorf("HSTS set without TLS: %q", rec.Header().Get("Strict-Transport-Security"))
}
// TLS on: HSTS present.
rec = httptest.NewRecorder()
(&Env{Cfg: &config.Config{Secure: true}}).
securityHeaders(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).
ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if !strings.HasPrefix(rec.Header().Get("Strict-Transport-Security"), "max-age=") {
t.Errorf("HSTS missing under TLS: %q", rec.Header().Get("Strict-Transport-Security"))
}
}
// TestTemplatesHaveNoInlineJS enforces the CSP-compatible pattern across every
// template: no inline event handlers (on*=), and no un-nonce'd inline <script>.
// Nonces don't cover on*= handlers, so any new one would silently break under CSP.