From 01c7adffdd1fe67aa86a9c116222988ef7d1d95e Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Thu, 2 Jul 2026 11:39:02 -0400 Subject: [PATCH] More security headers and checks --- .woodpecker/build.yaml | 1 + .woodpecker/vuln.yaml | 20 ++++++++++++++++++++ internal/web/security.go | 28 ++++++++++++++++++++++------ internal/web/security_test.go | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 .woodpecker/vuln.yaml diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml index 4b93f49..3cf2b35 100644 --- a/.woodpecker/build.yaml +++ b/.woodpecker/build.yaml @@ -8,6 +8,7 @@ when: depends_on: - test - lint + - vuln steps: publish: diff --git a/.woodpecker/vuln.yaml b/.woodpecker/vuln.yaml new file mode 100644 index 0000000..7e683d5 --- /dev/null +++ b/.woodpecker/vuln.yaml @@ -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: [] diff --git a/internal/web/security.go b/internal/web/security.go index c81d7f9..5d240b1 100644 --- a/internal/web/security.go +++ b/internal/web/security.go @@ -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))) }) } diff --git a/internal/web/security_test.go b/internal/web/security_test.go index d9e2252..4db91b5 100644 --- a/internal/web/security_test.go +++ b/internal/web/security_test.go @@ -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