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:
@@ -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/"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user