nebula-mesh: Web UI and API responses lack security headers (CSP, X-Frame-Options, HSTS, etc.)
Description
None of the response paths in internal/web/ or internal/api/ set the standard browser-security headers. grep for Content-Security-Policy, X-Frame-Options, Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy returns zero matches across the codebase.
Impact
The admin UI signs CA certificates, mints API keys (returned inline once per page), displays TOTP QR codes, and exposes operator-management forms. Missing X-Frame-Options: DENY / frame-ancestors 'none' is a real clickjacking lever against an admin browsing /ui/operators/* or /ui/cas/*. Missing X-Content-Type-Options: nosniff allows MIME confusion on any user-supplied content surface. Missing HSTS on TLS deployments leaves a downgrade window.
Affected
All released versions up to v0.3.0.
Suggested fix
A single response-header middleware mounted at the chi router root in both /ui/* and /api/* paths:
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
h := rw.Header()
h.Set("Content-Security-Policy",
"default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY") // belt-and-braces; CSP frame-ancestors is the modern path
if r.TLS != nil {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(rw, r)
})
}
The inline ` in layout.html for CSRF wiring (added in the CSRF advisory) will need either a nonce, a hash in CSP, or external-file extraction. Easiest path: a nonce per request (crypto/rand, base64) injected into both the CSP header and the script's nonce=""` attribute.
Affected products
1- Range: <= 0.3.0
Patches
1b45fda5476c4fix(http): add security response headers middleware (#125)
3 files changed · +144 −1
internal/cli/security_headers.go+32 −0 added@@ -0,0 +1,32 @@ +package cli + +import "net/http" + +// securityHeaders wraps next with HTTP response security headers per advisory +// GHSA-w7w5-5gcp-38rw: clickjacking, MIME sniffing, referrer leakage, and +// (on TLS) protocol downgrade defenses. +// +// CSP keeps 'unsafe-inline' on script-src and style-src for compatibility +// with existing inline <script>, <style>, onclick=, and style="..." usage +// in templates. Tightening that requires extracting inline blocks and +// rewiring inline event handlers, tracked separately. +func securityHeaders(next http.Handler) http.Handler { + const csp = "default-src 'self'; " + + "script-src 'self' 'unsafe-inline'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "frame-ancestors 'none'; " + + "base-uri 'none'; " + + "form-action 'self'" + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", csp) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "same-origin") + h.Set("X-Frame-Options", "DENY") + if r.TLS != nil { + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + next.ServeHTTP(w, r) + }) +}
internal/cli/security_headers_test.go+111 −0 added@@ -0,0 +1,111 @@ +package cli + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSecurityHeaders_PlainHTTP(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := securityHeaders(inner) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/anything", nil) + h.ServeHTTP(rec, req) + + got := rec.Result().Header + + wantPrefix := map[string]string{ + "Content-Security-Policy": "default-src 'self';", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "same-origin", + "X-Frame-Options": "DENY", + } + for k, prefix := range wantPrefix { + v := got.Get(k) + if v == "" { + t.Errorf("%s missing", k) + continue + } + if !strings.HasPrefix(v, prefix) { + t.Errorf("%s = %q, want prefix %q", k, v, prefix) + } + } + + // HSTS must NOT be set on plain HTTP — sending it from a plain + // listener would invite the client to upgrade to a non-listening + // HTTPS endpoint. + if v := got.Get("Strict-Transport-Security"); v != "" { + t.Errorf("HSTS set on plain HTTP: %q", v) + } +} + +func TestSecurityHeaders_TLS_SetsHSTS(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := securityHeaders(inner) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/anything", nil) + req.TLS = &tls.ConnectionState{} + h.ServeHTTP(rec, req) + + hsts := rec.Result().Header.Get("Strict-Transport-Security") + if hsts == "" { + t.Fatal("HSTS missing on TLS request") + } + if !strings.Contains(hsts, "max-age=") { + t.Errorf("HSTS without max-age: %q", hsts) + } + if !strings.Contains(hsts, "includeSubDomains") { + t.Errorf("HSTS without includeSubDomains: %q", hsts) + } +} + +func TestSecurityHeaders_CSPDirectives(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := securityHeaders(inner) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + h.ServeHTTP(rec, req) + + csp := rec.Result().Header.Get("Content-Security-Policy") + want := []string{ + "default-src 'self'", + "frame-ancestors 'none'", + "base-uri 'none'", + "form-action 'self'", + } + for _, d := range want { + if !strings.Contains(csp, d) { + t.Errorf("CSP missing %q; got %q", d, csp) + } + } +} + +func TestSecurityHeaders_AppliedOnErrorResponse(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + }) + h := securityHeaders(inner) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + if rec.Result().Header.Get("X-Frame-Options") != "DENY" { + t.Error("security headers stripped on error response") + } +}
internal/cli/serve.go+1 −1 modified@@ -227,7 +227,7 @@ func Serve(configPath string) error { httpServer := &http.Server{ Addr: cfg.Listen, - Handler: mux, + Handler: securityHeaders(mux), ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, }
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
4News mentions
0No linked articles in our index yet.