diff --git a/api/middleware/securityHeaders.go b/api/middleware/securityHeaders.go new file mode 100644 index 0000000..6bc359b --- /dev/null +++ b/api/middleware/securityHeaders.go @@ -0,0 +1,33 @@ +package middleware + +import "net/http" + +const contentSecurityPolicy = "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + + "font-src 'self' https://fonts.gstatic.com; " + + "img-src 'self' data: blob: https:; " + + "connect-src 'self'; " + + "object-src 'none'; " + + "base-uri 'self'; " + + "form-action 'self'; " + + "frame-ancestors 'none'" + +const strictTransportSecurity = "max-age=31536000" + +func SecurityHeaders(hsts bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", contentSecurityPolicy) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set("Cross-Origin-Opener-Policy", "same-origin") + if hsts { + h.Set("Strict-Transport-Security", strictTransportSecurity) + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/api/router.go b/api/router.go index 00e600a..32c9a09 100644 --- a/api/router.go +++ b/api/router.go @@ -280,6 +280,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux { frontend := InitFrontendRoutes(settings.Environment) apiRouter := chi.NewRouter() + apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction())) enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins) apiRouter.Use(chimiddleware.GetHead) apiRouter.Get("/healthz", GetHealth) diff --git a/docs/deployment.md b/docs/deployment.md index 248c3c8..71e3901 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -270,6 +270,12 @@ If RenewCMS runs on the same host as the proxy, consider firewalling port 8080. Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to the proxy's own address as seen by RenewCMS, typically `127.0.0.1` when the proxy and RenewCMS share a host, so the login/password-reset rate limiter honors the forwarded client IP instead of the proxy's own address. +### Security headers + +RenewCMS sends `Content-Security-Policy` (including `frame-ancestors 'none'`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy` itself, so the proxy doesn't need to add them. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security: max-age=31536000` (browsers ignore it over plain HTTP, so serve the site over HTTPS only). Don't add the same headers in the proxy, or they will be duplicated. + +The CSP allows Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`) and `https:` images, because the SPA loads its fonts from Google and articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only. + ## Backups Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running: diff --git a/test/security_headers_test.go b/test/security_headers_test.go new file mode 100644 index 0000000..bb772a3 --- /dev/null +++ b/test/security_headers_test.go @@ -0,0 +1,62 @@ +package test + +import ( + "RenewCMS/api/middleware" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func serveWithSecurityHeaders(hsts bool) http.Header { + handler := middleware.SecurityHeaders(hsts)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + return rec.Result().Header +} + +func TestSecurityHeadersAlwaysSetBaseline(t *testing.T) { + headers := serveWithSecurityHeaders(false) + + expected := map[string]string{ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Cross-Origin-Opener-Policy": "same-origin", + } + for name, want := range expected { + if got := headers.Get(name); got != want { + t.Errorf("expected %s %q, got %q", name, want, got) + } + } +} + +func TestSecurityHeadersContentSecurityPolicy(t *testing.T) { + csp := serveWithSecurityHeaders(false).Get("Content-Security-Policy") + + for _, directive := range []string{ + "default-src 'self'", + "script-src 'self';", + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'none'", + } { + if !strings.Contains(csp, directive) { + t.Errorf("expected CSP to contain %q, got %q", directive, csp) + } + } + if strings.Contains(csp, "script-src 'self' 'unsafe") { + t.Errorf("script-src must not allow unsafe sources, got %q", csp) + } +} + +func TestSecurityHeadersHSTSOnlyWhenEnabled(t *testing.T) { + if got := serveWithSecurityHeaders(false).Get("Strict-Transport-Security"); got != "" { + t.Errorf("expected no HSTS header, got %q", got) + } + if got := serveWithSecurityHeaders(true).Get("Strict-Transport-Security"); got != "max-age=31536000" { + t.Errorf("expected HSTS max-age=31536000, got %q", got) + } +}