diff --git a/go.mod b/go.mod index ac36b9c..c6f95a7 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,11 @@ go 1.25 require ( github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/docker/docker v27.3.1+incompatible + github.com/docker/go-connections v0.6.0 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 github.com/google/uuid v1.6.0 + github.com/gorilla/csrf v1.7.3 github.com/gorilla/sessions v1.4.0 github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.32 @@ -27,7 +29,6 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect diff --git a/go.sum b/go.sum index 0355283..12df127 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= +github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 2887139..d2a4910 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -29,8 +29,8 @@ const ( func (h *Handlers) HandleAppNew() http.HandlerFunc { tmpl := templates.GetParsed() - return func(writer http.ResponseWriter, _ *http.Request) { - data := h.addGlobals(map[string]any{}) + return func(writer http.ResponseWriter, request *http.Request) { + data := h.addGlobals(map[string]any{}, request) err := tmpl.ExecuteTemplate(writer, "app_new.html", data) if err != nil { @@ -57,12 +57,12 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { branch := request.FormValue("branch") dockerfilePath := request.FormValue("dockerfile_path") - data := map[string]any{ + data := h.addGlobals(map[string]any{ "Name": name, "RepoURL": repoURL, "Branch": branch, "DockerfilePath": dockerfilePath, - } + }, request) if name == "" || repoURL == "" { data["Error"] = "Name and repository URL are required" @@ -150,7 +150,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc { "WebhookURL": webhookURL, "DeployKey": deployKey, "Success": request.URL.Query().Get("success"), - }) + }, request) err := tmpl.ExecuteTemplate(writer, "app_detail.html", data) if err != nil { @@ -183,7 +183,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc { data := h.addGlobals(map[string]any{ "App": application, - }) + }, request) err := tmpl.ExecuteTemplate(writer, "app_edit.html", data) if err != nil { @@ -241,10 +241,10 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { if saveErr != nil { h.log.Error("failed to update app", "error", saveErr) - data := map[string]any{ + data := h.addGlobals(map[string]any{ "App": application, "Error": "Failed to update app", - } + }, request) _ = tmpl.ExecuteTemplate(writer, "app_edit.html", data) return @@ -337,7 +337,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc { data := h.addGlobals(map[string]any{ "App": application, "Deployments": deployments, - }) + }, request) err := tmpl.ExecuteTemplate(writer, "deployments.html", data) if err != nil { diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index e3dd5c0..b88e81c 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -10,8 +10,8 @@ import ( func (h *Handlers) HandleLoginGET() http.HandlerFunc { tmpl := templates.GetParsed() - return func(writer http.ResponseWriter, _ *http.Request) { - data := h.addGlobals(map[string]any{}) + return func(writer http.ResponseWriter, request *http.Request) { + data := h.addGlobals(map[string]any{}, request) err := tmpl.ExecuteTemplate(writer, "login.html", data) if err != nil { @@ -38,7 +38,7 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc { data := h.addGlobals(map[string]any{ "Username": username, - }) + }, request) if username == "" || password == "" { data["Error"] = "Username and password are required" diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 76fd64f..a85c4da 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -67,7 +67,7 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc { data := h.addGlobals(map[string]any{ "AppStats": appStats, - }) + }, request) execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data) if execErr != nil { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index b23fb44..b5a3af5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -3,9 +3,11 @@ package handlers import ( "encoding/json" + "html/template" "log/slog" "net/http" + "github.com/gorilla/csrf" "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/database" @@ -64,11 +66,18 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) { }, nil } -// addGlobals adds version info to template data map. -func (h *Handlers) addGlobals(data map[string]any) map[string]any { +// addGlobals adds version info and CSRF token to template data map. +func (h *Handlers) addGlobals( + data map[string]any, + request *http.Request, +) map[string]any { data["Version"] = h.globals.Version data["Appname"] = h.globals.Appname + if request != nil { + data["CSRFField"] = template.HTML(csrf.TemplateField(request)) //nolint:gosec // csrf.TemplateField produces safe HTML + } + return data } diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 0286799..fd7805f 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -15,8 +15,8 @@ const ( func (h *Handlers) HandleSetupGET() http.HandlerFunc { tmpl := templates.GetParsed() - return func(writer http.ResponseWriter, _ *http.Request) { - data := h.addGlobals(map[string]any{}) + return func(writer http.ResponseWriter, request *http.Request) { + data := h.addGlobals(map[string]any{}, request) err := tmpl.ExecuteTemplate(writer, "setup.html", data) if err != nil { @@ -54,13 +54,14 @@ func validateSetupForm(formData setupFormData) string { func (h *Handlers) renderSetupError( tmpl *templates.TemplateExecutor, writer http.ResponseWriter, + request *http.Request, username string, errorMsg string, ) { data := h.addGlobals(map[string]any{ "Username": username, "Error": errorMsg, - }) + }, request) _ = tmpl.ExecuteTemplate(writer, "setup.html", data) } @@ -83,7 +84,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc { } if validationErr := validateSetupForm(formData); validationErr != "" { - h.renderSetupError(tmpl, writer, formData.username, validationErr) + h.renderSetupError(tmpl, writer, request, formData.username, validationErr) return } @@ -95,7 +96,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc { ) if createErr != nil { h.log.Error("failed to create user", "error", createErr) - h.renderSetupError(tmpl, writer, formData.username, "Failed to create user") + h.renderSetupError(tmpl, writer, request, formData.username, "Failed to create user") return } @@ -106,6 +107,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc { h.renderSetupError( tmpl, writer, + request, formData.username, "Failed to create session", ) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 7173609..637b51d 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -10,6 +10,7 @@ import ( "github.com/99designs/basicauth-go" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "github.com/gorilla/csrf" "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/config" @@ -152,6 +153,15 @@ func (m *Middleware) SessionAuth() func(http.Handler) http.Handler { } } +// CSRF returns CSRF protection middleware using gorilla/csrf. +func (m *Middleware) CSRF() func(http.Handler) http.Handler { + return csrf.Protect( + []byte(m.params.Config.SessionSecret), + csrf.Secure(false), // Allow HTTP for development; reverse proxy handles TLS + csrf.Path("/"), + ) +} + // SetupRequired returns middleware that redirects to setup if no user exists. func (m *Middleware) SetupRequired() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { diff --git a/internal/server/routes.go b/internal/server/routes.go index 3fe3e4a..860d0b9 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -37,18 +37,22 @@ func (s *Server) SetupRoutes() { http.FileServer(http.FS(static.Static)), )) - // Public routes - s.router.Get("/login", s.handlers.HandleLoginGET()) - s.router.Post("/login", s.handlers.HandleLoginPOST()) - s.router.Get("/setup", s.handlers.HandleSetupGET()) - s.router.Post("/setup", s.handlers.HandleSetupPOST()) - - // Webhook endpoint (uses secret for auth, not session) + // Webhook endpoint (uses secret for auth, not session — no CSRF) s.router.Post("/webhook/{secret}", s.handlers.HandleWebhook()) - // Protected routes (require session auth) + // All HTML-serving routes get CSRF protection s.router.Group(func(r chi.Router) { - r.Use(s.mw.SessionAuth()) + r.Use(s.mw.CSRF()) + + // Public routes + r.Get("/login", s.handlers.HandleLoginGET()) + r.Post("/login", s.handlers.HandleLoginPOST()) + r.Get("/setup", s.handlers.HandleSetupGET()) + r.Post("/setup", s.handlers.HandleSetupPOST()) + + // Protected routes (require session auth) + r.Group(func(r chi.Router) { + r.Use(s.mw.SessionAuth()) // Dashboard r.Get("/", s.handlers.HandleDashboard()) @@ -90,6 +94,7 @@ func (s *Server) SetupRoutes() { // Ports r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) + }) }) // Metrics endpoint (optional, with basic auth) diff --git a/templates/app_detail.html b/templates/app_detail.html index caefa22..f965efa 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -35,6 +35,7 @@
Edit
+ {{ .CSRFField }} @@ -106,6 +107,7 @@ {{.Value}} + {{ .CSRFField }}
@@ -116,6 +118,7 @@
{{end}}
+ {{ .CSRFField }} @@ -149,6 +152,7 @@ {{.Value}} + {{ .CSRFField }}
@@ -158,6 +162,7 @@
+ {{ .CSRFField }} @@ -192,6 +197,7 @@ + {{ .CSRFField }}
@@ -202,6 +208,7 @@ {{end}}
+ {{ .CSRFField }}
@@ -244,6 +251,7 @@ + {{ .CSRFField }}
@@ -254,6 +262,7 @@ {{end}}
+ {{ .CSRFField }}
@@ -339,6 +348,7 @@

Danger Zone

Deleting this app will remove all configuration and deployment history. This action cannot be undone.

+ {{ .CSRFField }}
diff --git a/templates/app_edit.html b/templates/app_edit.html index f4f1e99..cd68c0e 100644 --- a/templates/app_edit.html +++ b/templates/app_edit.html @@ -21,6 +21,7 @@ {{template "alert-error" .}}
+ {{ .CSRFField }}
+ {{ .CSRFField }}
+ {{ .CSRFField }}
diff --git a/templates/dashboard.html b/templates/dashboard.html index 14ecd09..bf96b60 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -69,6 +69,7 @@ View Edit
+ {{ .CSRFField }}
diff --git a/templates/deployments.html b/templates/deployments.html index 3f83911..ed3b5d2 100644 --- a/templates/deployments.html +++ b/templates/deployments.html @@ -18,6 +18,7 @@

Deployment History

+ {{ .CSRFField }} @@ -103,6 +104,7 @@

Deploy your application to see the deployment history here.

+ {{ .CSRFField }} diff --git a/templates/login.html b/templates/login.html index 1e9b997..169472b 100644 --- a/templates/login.html +++ b/templates/login.html @@ -14,6 +14,7 @@ {{template "alert-error" .}} + {{ .CSRFField }}
+ {{ .CSRFField }}