From bd15d71f0055e45b010354bb7df654f6021e1718 Mon Sep 17 00:00:00 2001 From: nic Date: Thu, 16 Oct 2025 16:23:49 -0400 Subject: [PATCH] chore: implementing CSRF --- apps/web/main.go | 82 +++++++++++++++++++++++++++++- go.mod | 11 +++- go.sum | 10 ++++ internal/handlers/web/documents.go | 4 ++ internal/handlers/web/login.go | 4 +- templates/layout.html | 11 ++++ 6 files changed, 118 insertions(+), 4 deletions(-) diff --git a/apps/web/main.go b/apps/web/main.go index 58ecfd2..9d9ccc4 100644 --- a/apps/web/main.go +++ b/apps/web/main.go @@ -4,12 +4,15 @@ import ( "log" "net/http" "os" + "strings" "time" root "marmic/servicetrade-toolbox" "marmic/servicetrade-toolbox/internal/handlers/web" "marmic/servicetrade-toolbox/internal/middleware" + "github.com/gorilla/csrf" + "github.com/gorilla/handlers" "github.com/gorilla/mux" ) @@ -20,6 +23,12 @@ func main() { } r := mux.NewRouter() + // Make app proxy-aware (X-Forwarded-Proto/Host) so downstream middlewares see correct scheme/host + r.Use(handlers.ProxyHeaders) + + // Global security headers middleware + r.Use(middleware.SecurityHeaders) + // Serve embedded static files staticFS, err := root.GetStaticFS() if err != nil { @@ -30,15 +39,84 @@ func main() { // Serve static files // r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) - // Auth routes + // Auth routes (login remains outside CSRF) r.HandleFunc("/login", web.LoginHandler).Methods("GET", "POST") - r.HandleFunc("/logout", web.LogoutHandler).Methods("GET", "POST") + + // CSRF protection for state-changing routes + csrfKey := os.Getenv("CSRF_AUTH_KEY") + if len(csrfKey) < 32 { + log.Println("WARNING: CSRF_AUTH_KEY is not set or too short; using insecure default for development only") + csrfKey = "this-is-a-very-insecure-dev-key-please-set-env" + } + secureCookies := os.Getenv("APP_SECURE_COOKIES") != "false" + disableCSRF := os.Getenv("CSRF_DISABLE") == "true" + // Trusted origins for reverse-proxy/HTTPS setups (comma-separated env) + var trustedOrigins []string + if envTO := os.Getenv("CSRF_TRUSTED_ORIGINS"); envTO != "" { + for _, part := range strings.Split(envTO, ",") { + o := strings.TrimSpace(part) + if o != "" { + trustedOrigins = append(trustedOrigins, o) + } + } + } else { + // Sensible defaults for your known hosts; override via CSRF_TRUSTED_ORIGINS in prod + trustedOrigins = []string{ + "https://dev.toolbox.nicpatterson.info", + "https://toolbox.nicpatterson.info", + "http://localhost:8080", + "http://127.0.0.1:8080", + } + } + + csrfOpts := []csrf.Option{ + csrf.Secure(secureCookies), + csrf.SameSite(csrf.SameSiteLaxMode), + csrf.CookieName("XSRF-TOKEN"), + csrf.HttpOnly(false), // allow htmx script to read and send the token + csrf.Path("/"), + csrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Log the specific CSRF failure reason to help debugging + origin := r.Header.Get("Origin") + referer := r.Header.Get("Referer") + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if xfProto := r.Header.Get("X-Forwarded-Proto"); xfProto != "" { + scheme = xfProto + } + if reason := csrf.FailureReason(r); reason != nil { + log.Printf("CSRF validation failed: %v | host=%s scheme=%s origin=%s referer=%s", reason, r.Host, scheme, origin, referer) + } else { + log.Printf("CSRF validation failed: unknown reason | host=%s scheme=%s origin=%s referer=%s", r.Host, scheme, origin, referer) + } + http.Error(w, "Forbidden - CSRF", http.StatusForbidden) + })), + } + if len(trustedOrigins) > 0 { + log.Printf("CSRF trusted origins: %v", trustedOrigins) + csrfOpts = append(csrfOpts, csrf.TrustedOrigins(trustedOrigins)) + } + csrfMw := csrf.Protect([]byte(csrfKey), csrfOpts...) // Protected routes protected := r.PathPrefix("/").Subrouter() protected.Use(middleware.AuthMiddleware) + if disableCSRF { + log.Println("WARNING: CSRF is DISABLED by environment (CSRF_DISABLE=true) - DO NOT USE IN PRODUCTION") + } else { + // Use standard gorilla/csrf; if origin checks are problematic in certain environments, + // fallback to simple double-submit cookie middleware via CSRF_MODE=simple + if os.Getenv("CSRF_MODE") == "simple" { + protected.Use(middleware.CSRFSimple) + } else { + protected.Use(csrfMw) + } + } protected.HandleFunc("/", web.DashboardHandler).Methods("GET") + protected.HandleFunc("/logout", web.LogoutHandler).Methods("POST") protected.HandleFunc("/jobs", web.JobsHandler).Methods("GET", "POST") protected.HandleFunc("/invoices", web.InvoicesHandler).Methods("GET", "POST") protected.HandleFunc("/ok-invoice/{id}", web.UpdateInvoiceStatusHandler).Methods("PUT") diff --git a/go.mod b/go.mod index df7e9a9..04e70ed 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,13 @@ module marmic/servicetrade-toolbox go 1.22.1 -require github.com/gorilla/mux v1.8.1 +require ( + github.com/gorilla/csrf v1.7.3 + github.com/gorilla/handlers v1.5.2 + github.com/gorilla/mux v1.8.1 +) + +require ( + github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect +) diff --git a/go.sum b/go.sum index 7128337..1176ff2 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,12 @@ +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= +github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= diff --git a/internal/handlers/web/documents.go b/internal/handlers/web/documents.go index d8c7f60..09ef151 100644 --- a/internal/handlers/web/documents.go +++ b/internal/handlers/web/documents.go @@ -18,6 +18,8 @@ import ( "marmic/servicetrade-toolbox/internal/api" "marmic/servicetrade-toolbox/internal/middleware" "marmic/servicetrade-toolbox/internal/utils" + + "github.com/gorilla/csrf" ) // UploadResult represents the result of a single file upload @@ -70,6 +72,8 @@ func DocumentsHandler(w http.ResponseWriter, r *http.Request) { "Title": "Document Uploads", "Session": session, } + // Include CSRF token for htmx headers in partials + data["CSRFToken"] = csrf.Token(r) if r.Header.Get("HX-Request") == "true" { // For HTMX requests, just send the document_upload partial diff --git a/internal/handlers/web/login.go b/internal/handlers/web/login.go index 83531ca..300b47a 100644 --- a/internal/handlers/web/login.go +++ b/internal/handlers/web/login.go @@ -1,6 +1,7 @@ package web import ( + "html" "log" root "marmic/servicetrade-toolbox" "marmic/servicetrade-toolbox/internal/api" @@ -23,7 +24,8 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { err := session.Login(email, password) if err != nil { if r.Header.Get("HX-Request") == "true" { - w.Write([]byte("
Login failed: " + err.Error() + "
")) + safeMsg := html.EscapeString(err.Error()) + w.Write([]byte("
Login failed: " + safeMsg + "
")) } else { http.Error(w, "Login failed", http.StatusUnauthorized) } diff --git a/templates/layout.html b/templates/layout.html index c246776..9062a06 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -6,6 +6,17 @@ ServiceTrade Tools +