fix: missing security headers on every response, including the SPA and uploaded images

This commit is contained in:
fsylvain committed 2026-10-05 16:19:37 +02:00
1 parent d8f171b8cc
commit 1d4f4bd2cc
4 files changed
+102

No files matched your search

+33
View File
@@ -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)
})
}
}
+1
View File
@@ -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)
+6
View File
@@ -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:
+62
View File
@@ -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)
}
}