Harden for public deployment behind a reverse proxy

The session cookie now sets the Secure attribute (server.secure_cookies, default true). Adds chi RealIP and Recoverer middleware plus a securityHeaders middleware that emits a Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy on every response. HSTS is intentionally left to the TLS-terminating proxy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-14 12:10:50 -07:00
parent 1ae2c50b9a
commit fd1682e11b
4 changed files with 91 additions and 10 deletions

View File

@@ -12,6 +12,7 @@ import (
"github.com/a-h/templ"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"veola/internal/apify"
"veola/internal/auth"
@@ -43,6 +44,14 @@ func New(cfg *config.Config, store *db.Store, am *auth.Manager, ap *apify.Client
// Routes returns the chi router with everything wired up.
func (a *App) Routes() http.Handler {
r := chi.NewRouter()
// Applied to everything, including static assets and error responses.
// RealIP rewrites RemoteAddr from X-Forwarded-For — safe only because
// Veola is expected to sit behind a trusted proxy (Traefik) that sets
// it; Traefik must be configured to strip client-supplied values.
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(securityHeaders)
fs := http.FileServer(http.Dir("./static"))
r.Handle("/static/*", http.StripPrefix("/static/", fs))
@@ -85,6 +94,7 @@ func (a *App) Routes() http.Handler {
r.With(a.Auth.CSRFProtect).Post("/settings/password", a.PostPasswordChange)
r.With(a.Auth.CSRFProtect).Post("/settings/test-ntfy", a.PostTestNtfy)
r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify)
r.With(a.Auth.CSRFProtect).Post("/settings/test-ebay", a.PostTestEbay)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users", a.PostCreateUser)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/delete", a.PostDeleteUser)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword)
@@ -114,6 +124,38 @@ func (a *App) setupGate(next http.Handler) http.Handler {
})
}
// securityHeaders sets defensive response headers on every request.
//
// Scripts and styles are restricted to 'self': Tailwind is vendored
// (static/css/tailwind.css), htmx and Chart.js are vendored, and the only
// inline <script> (the price chart) was externalized to static/js. The sole
// remote origins are Google Fonts (stylesheet + font files), per the agreed
// deviation to keep fonts on a CDN.
//
// img-src additionally allows any https: origin: listing thumbnails come from
// an open-ended set of marketplace CDNs (eBay, Amazon, Mercari, Yahoo, ...)
// that cannot be enumerated. Images execute no code, so this is a low-risk
// relaxation while script/style/connect stay locked to 'self'.
//
// HSTS is intentionally omitted: it belongs at the TLS-terminating proxy.
func securityHeaders(next http.Handler) http.Handler {
const csp = "default-src 'self'; " +
"img-src 'self' data: https:; " +
"font-src 'self' https://fonts.gstatic.com; " +
"style-src 'self' https://fonts.googleapis.com; " +
"script-src 'self'; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; object-src 'none'; form-action 'self'"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", csp)
next.ServeHTTP(w, r)
})
}
func isStaticPath(p string) bool {
return len(p) >= 8 && p[:8] == "/static/"
}