middleware: add security response headers (closes #98)
check / check (push) Successful in 54s
check / check (push) Successful in 54s
Adds a SecurityHeaders middleware and registers it globally, right after the request ID middleware, so every route gets the headers, including static files, /metrics and error responses. It sets Strict-Transport-Security (one year, includeSubDomains), a Content-Security-Policy with default-src 'self', no scripts and frame-ancestors 'none', X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy no-referrer and a Permissions-Policy that turns every listed feature off. HSTS is sent on every response, not only over TLS: the service runs behind a TLS-terminating proxy and REPO_POLICIES.md requires the application to send it. Referrer-Policy is stricter than the policy baseline because dashboard URLs can name internal hosts. model: claude-opus-4-8 (implementation); claude-fable-5 (commit message)
This commit was merged in pull request #112.
This commit is contained in:
@@ -21,6 +21,60 @@ import (
|
||||
// corsMaxAge is the maximum age for CORS preflight responses.
|
||||
const corsMaxAge = 300
|
||||
|
||||
// Security response header values applied to every response.
|
||||
//
|
||||
// The CSP is as strict as the dashboard allows: the template ships no
|
||||
// JavaScript, no inline styles, no inline event handlers and no images,
|
||||
// and its only subresource is the embedded stylesheet at
|
||||
// /s/css/tailwind.min.css, which style-src 'self' permits. Neither
|
||||
// unsafe-inline nor unsafe-eval is used. frame-ancestors 'none' is the
|
||||
// primary anti-framing control; X-Frame-Options is the legacy fallback.
|
||||
const (
|
||||
// hstsValue is emitted unconditionally, including over plain HTTP,
|
||||
// because the service runs behind a TLS-terminating proxy and the
|
||||
// browser must still enforce HTTPS end to end.
|
||||
hstsValue = "max-age=31536000; includeSubDomains"
|
||||
|
||||
cspValue = "default-src 'self'; " +
|
||||
"script-src 'none'; " +
|
||||
"style-src 'self'; " +
|
||||
"img-src 'self'; " +
|
||||
"font-src 'none'; " +
|
||||
"connect-src 'none'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'none'; " +
|
||||
"form-action 'none'; " +
|
||||
"frame-ancestors 'none'"
|
||||
|
||||
frameOptionsValue = "DENY"
|
||||
|
||||
contentTypeOptionsValue = "nosniff"
|
||||
|
||||
// referrerPolicyValue is stricter than the policy minimum of
|
||||
// strict-origin-when-cross-origin: the dashboard has no
|
||||
// cross-origin navigation needs and its URL may name internal
|
||||
// hosts.
|
||||
referrerPolicyValue = "no-referrer"
|
||||
|
||||
permissionsPolicyValue = "accelerometer=(), " +
|
||||
"autoplay=(), " +
|
||||
"camera=(), " +
|
||||
"display-capture=(), " +
|
||||
"encrypted-media=(), " +
|
||||
"fullscreen=(), " +
|
||||
"geolocation=(), " +
|
||||
"gyroscope=(), " +
|
||||
"magnetometer=(), " +
|
||||
"microphone=(), " +
|
||||
"midi=(), " +
|
||||
"payment=(), " +
|
||||
"picture-in-picture=(), " +
|
||||
"publickey-credentials-get=(), " +
|
||||
"screen-wake-lock=(), " +
|
||||
"usb=(), " +
|
||||
"xr-spatial-tracking=()"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Middleware.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -186,6 +240,37 @@ func (m *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// SecurityHeaders returns middleware that sets the security response
|
||||
// headers required for production internet exposure on every response.
|
||||
//
|
||||
// The headers are set before the request reaches the next handler so
|
||||
// that they are present on every response, including panics recovered
|
||||
// by chi's Recoverer and timeouts produced by chi's Timeout.
|
||||
func (m *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
header := writer.Header()
|
||||
header.Set("Strict-Transport-Security", hstsValue)
|
||||
header.Set("Content-Security-Policy", cspValue)
|
||||
header.Set("X-Frame-Options", frameOptionsValue)
|
||||
header.Set(
|
||||
"X-Content-Type-Options",
|
||||
contentTypeOptionsValue,
|
||||
)
|
||||
header.Set("Referrer-Policy", referrerPolicyValue)
|
||||
header.Set(
|
||||
"Permissions-Policy",
|
||||
permissionsPolicyValue,
|
||||
)
|
||||
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MetricsAuth returns basic auth middleware for /metrics.
|
||||
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||
if m.params.Config.MetricsUsername == "" {
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/fx/fxtest"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/handlers"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
"sneak.berlin/go/dnswatcher/internal/middleware"
|
||||
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
)
|
||||
|
||||
// Expected security header values, spelled out literally so that any
|
||||
// change to the middleware has to be made deliberately here as well.
|
||||
const (
|
||||
wantHSTS = "max-age=31536000; includeSubDomains"
|
||||
|
||||
wantCSP = "default-src 'self'; " +
|
||||
"script-src 'none'; " +
|
||||
"style-src 'self'; " +
|
||||
"img-src 'self'; " +
|
||||
"font-src 'none'; " +
|
||||
"connect-src 'none'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'none'; " +
|
||||
"form-action 'none'; " +
|
||||
"frame-ancestors 'none'"
|
||||
|
||||
wantFrameOptions = "DENY"
|
||||
|
||||
wantContentTypeOptions = "nosniff"
|
||||
|
||||
wantReferrerPolicy = "no-referrer"
|
||||
|
||||
wantPermissionsPolicy = "accelerometer=(), " +
|
||||
"autoplay=(), " +
|
||||
"camera=(), " +
|
||||
"display-capture=(), " +
|
||||
"encrypted-media=(), " +
|
||||
"fullscreen=(), " +
|
||||
"geolocation=(), " +
|
||||
"gyroscope=(), " +
|
||||
"magnetometer=(), " +
|
||||
"microphone=(), " +
|
||||
"midi=(), " +
|
||||
"payment=(), " +
|
||||
"picture-in-picture=(), " +
|
||||
"publickey-credentials-get=(), " +
|
||||
"screen-wake-lock=(), " +
|
||||
"usb=(), " +
|
||||
"xr-spatial-tracking=()"
|
||||
)
|
||||
|
||||
// stylesheetPath is the only subresource the dashboard loads.
|
||||
const stylesheetPath = "/s/css/tailwind.min.css"
|
||||
|
||||
// newTestLogger builds a logger for direct component construction.
|
||||
func newTestLogger(t *testing.T) *logger.Logger {
|
||||
t.Helper()
|
||||
|
||||
glob, err := globals.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("globals.New: %v", err)
|
||||
}
|
||||
|
||||
log, err := logger.New(nil, logger.Params{Globals: glob})
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New: %v", err)
|
||||
}
|
||||
|
||||
return log
|
||||
}
|
||||
|
||||
// newTestMiddleware builds a Middleware without an fx application.
|
||||
func newTestMiddleware(t *testing.T) *middleware.Middleware {
|
||||
t.Helper()
|
||||
|
||||
glob, err := globals.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("globals.New: %v", err)
|
||||
}
|
||||
|
||||
mw, err := middleware.New(nil, middleware.Params{
|
||||
Logger: newTestLogger(t),
|
||||
Globals: glob,
|
||||
Config: &config.Config{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("middleware.New: %v", err)
|
||||
}
|
||||
|
||||
return mw
|
||||
}
|
||||
|
||||
// serveWithSecurityHeaders runs a GET through SecurityHeaders and
|
||||
// returns the recorded response.
|
||||
func serveWithSecurityHeaders(
|
||||
t *testing.T,
|
||||
target string,
|
||||
handler http.Handler,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
mw := newTestMiddleware(t)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, target, nil,
|
||||
)
|
||||
|
||||
mw.SecurityHeaders()(handler).ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// okHandler writes a trivial 200 response.
|
||||
func okHandler() http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"hsts",
|
||||
"Strict-Transport-Security",
|
||||
wantHSTS,
|
||||
},
|
||||
{
|
||||
"csp",
|
||||
"Content-Security-Policy",
|
||||
wantCSP,
|
||||
},
|
||||
{
|
||||
"frame options",
|
||||
"X-Frame-Options",
|
||||
wantFrameOptions,
|
||||
},
|
||||
{
|
||||
"content type options",
|
||||
"X-Content-Type-Options",
|
||||
wantContentTypeOptions,
|
||||
},
|
||||
{
|
||||
"referrer policy",
|
||||
"Referrer-Policy",
|
||||
wantReferrerPolicy,
|
||||
},
|
||||
{
|
||||
"permissions policy",
|
||||
"Permissions-Policy",
|
||||
wantPermissionsPolicy,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := serveWithSecurityHeaders(t, "/", okHandler())
|
||||
|
||||
got := rec.Header().Get(tt.header)
|
||||
if got != tt.want {
|
||||
t.Errorf(
|
||||
"%s = %q, want %q",
|
||||
tt.header, got, tt.want,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityHeadersCSPDirectives guards the properties the repo
|
||||
// policy requires of the content security policy itself.
|
||||
func TestSecurityHeadersCSPDirectives(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := serveWithSecurityHeaders(t, "/", okHandler())
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
|
||||
forbidden := []string{"unsafe-inline", "unsafe-eval"}
|
||||
for _, directive := range forbidden {
|
||||
if strings.Contains(csp, directive) {
|
||||
t.Errorf("CSP must not contain %q: %q", directive, csp)
|
||||
}
|
||||
}
|
||||
|
||||
required := []string{
|
||||
"default-src 'self'",
|
||||
"script-src 'none'",
|
||||
"style-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
}
|
||||
for _, directive := range required {
|
||||
if !strings.Contains(csp, directive) {
|
||||
t.Errorf("CSP must contain %q: %q", directive, csp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityHeadersOnErrorResponse verifies the headers are emitted
|
||||
// even when the wrapped handler fails, since they are set before the
|
||||
// handler runs.
|
||||
func TestSecurityHeadersOnErrorResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
failing := http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
http.Error(
|
||||
writer,
|
||||
"boom",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
})
|
||||
|
||||
rec := serveWithSecurityHeaders(t, "/api/v1/status", failing)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get(
|
||||
"X-Content-Type-Options",
|
||||
); got != wantContentTypeOptions {
|
||||
t.Errorf(
|
||||
"X-Content-Type-Options = %q, want %q",
|
||||
got, wantContentTypeOptions,
|
||||
)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get(
|
||||
"Strict-Transport-Security",
|
||||
); got != wantHSTS {
|
||||
t.Errorf(
|
||||
"Strict-Transport-Security = %q, want %q",
|
||||
got, wantHSTS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestHandlers builds real Handlers with empty monitoring state.
|
||||
func newTestHandlers(t *testing.T) *handlers.Handlers {
|
||||
t.Helper()
|
||||
|
||||
glob, err := globals.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("globals.New: %v", err)
|
||||
}
|
||||
|
||||
log := newTestLogger(t)
|
||||
|
||||
notifier, err := notify.New(fxtest.NewLifecycle(t), notify.Params{
|
||||
Logger: log,
|
||||
Config: &config.Config{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("notify.New: %v", err)
|
||||
}
|
||||
|
||||
hnd, err := handlers.New(nil, handlers.Params{
|
||||
Logger: log,
|
||||
Globals: glob,
|
||||
State: state.NewForTest(),
|
||||
Notify: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handlers.New: %v", err)
|
||||
}
|
||||
|
||||
return hnd
|
||||
}
|
||||
|
||||
// TestDashboardRendersWithSecurityHeaders renders the real dashboard
|
||||
// through the middleware and checks that the policy still permits the
|
||||
// one stylesheet the page loads.
|
||||
func TestDashboardRendersWithSecurityHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mw := newTestMiddleware(t)
|
||||
hnd := newTestHandlers(t)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(mw.SecurityHeaders())
|
||||
router.Get("/", hnd.HandleDashboard())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, stylesheetPath) {
|
||||
t.Errorf("dashboard does not reference %q", stylesheetPath)
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "dnswatcher") {
|
||||
t.Errorf("dashboard body looks empty: %d bytes", len(body))
|
||||
}
|
||||
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
if csp != wantCSP {
|
||||
t.Errorf("CSP = %q, want %q", csp, wantCSP)
|
||||
}
|
||||
|
||||
// The stylesheet is same-origin, so style-src 'self' allows it.
|
||||
if !strings.Contains(csp, "style-src 'self'") {
|
||||
t.Errorf("CSP would block %q: %q", stylesheetPath, csp)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user