From b1dc8fcc4eabb3ff182ec4a208b3287981e9b934 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 15 Feb 2026 14:17:55 -0800 Subject: [PATCH 1/2] Add CSRF protection to state-changing POST endpoints Add gorilla/csrf middleware to protect all HTML-serving routes against cross-site request forgery attacks. The webhook endpoint is excluded since it uses secret-based authentication. Changes: - Add gorilla/csrf v1.7.3 dependency - Add CSRF() middleware method using session secret as key - Apply CSRF middleware to all HTML route groups in routes.go - Pass CSRF token to all templates via addGlobals helper - Add {{ .CSRFField }} / {{ $.CSRFField }} hidden inputs to all forms Closes #11 --- go.mod | 3 ++- go.sum | 2 ++ internal/handlers/app.go | 18 +++++++++--------- internal/handlers/auth.go | 6 +++--- internal/handlers/dashboard.go | 2 +- internal/handlers/handlers.go | 13 +++++++++++-- internal/handlers/setup.go | 12 +++++++----- internal/middleware/middleware.go | 10 ++++++++++ internal/server/routes.go | 23 ++++++++++++++--------- templates/app_detail.html | 10 ++++++++++ templates/app_edit.html | 1 + templates/app_new.html | 1 + templates/base.html | 1 + templates/dashboard.html | 1 + templates/deployments.html | 2 ++ templates/login.html | 1 + templates/setup.html | 1 + 17 files changed, 77 insertions(+), 30 deletions(-) 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 }}
Date: Sun, 15 Feb 2026 20:48:43 -0800 Subject: [PATCH 2/2] rewrite log viewer panes: smart auto-scroll with follow button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track scroll position per log pane (container logs, build logs, deployment cards) - Auto-scroll to bottom only when user is already at bottom (tail-follow) - When user scrolls up to review earlier output, pause auto-scroll - Show a '↓ Follow' button when auto-scroll is paused; clicking resumes - Only scroll on actual content changes (skip no-op updates) - Use overflow-y: auto for proper scrollable containers - Add break-words to prevent horizontal overflow on long lines Closes #17 --- static/js/app.js | 69 ++++++++++++++++++++++++++++++-------- templates/app_detail.html | 26 +++++++++++--- templates/deployments.html | 13 +++++-- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index b2bc46d..cd567a9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -61,15 +61,21 @@ document.addEventListener("alpine:init", () => { */ scrollToBottom(el) { if (el) { - // Use double RAF to ensure DOM has fully updated and reflowed requestAnimationFrame(() => { - requestAnimationFrame(() => { - el.scrollTop = el.scrollHeight; - }); + el.scrollTop = el.scrollHeight; }); } }, + /** + * Check if a scrollable element is at (or near) the bottom. + * Tolerance of 30px accounts for rounding and partial lines. + */ + isScrolledToBottom(el, tolerance = 30) { + if (!el) return true; + return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance; + }, + /** * Copy text to clipboard */ @@ -176,11 +182,27 @@ document.addEventListener("alpine:init", () => { showBuildLogs: !!config.initialDeploymentId, deploying: false, deployments: [], + // Track whether user wants auto-scroll (per log pane) + _containerAutoScroll: true, + _buildAutoScroll: true, init() { this.deploying = Alpine.store("utils").isDeploying(this.appStatus); this.fetchAll(); setInterval(() => this.fetchAll(), 1000); + + // Set up scroll listeners after DOM is ready + this.$nextTick(() => { + this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll'); + this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll'); + }); + }, + + _initScrollTracking(el, flag) { + if (!el) return; + el.addEventListener('scroll', () => { + this[flag] = Alpine.store("utils").isScrolledToBottom(el); + }, { passive: true }); }, fetchAll() { @@ -214,11 +236,15 @@ document.addEventListener("alpine:init", () => { try { const res = await fetch(`/apps/${this.appId}/container-logs`); const data = await res.json(); - this.containerLogs = data.logs || "No logs available"; + const newLogs = data.logs || "No logs available"; + const changed = newLogs !== this.containerLogs; + this.containerLogs = newLogs; this.containerStatus = data.status; - this.$nextTick(() => { - Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper); - }); + if (changed && this._containerAutoScroll) { + this.$nextTick(() => { + Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper); + }); + } } catch (err) { this.containerLogs = "Failed to fetch logs"; } @@ -231,11 +257,15 @@ document.addEventListener("alpine:init", () => { `/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`, ); const data = await res.json(); - this.buildLogs = data.logs || "No build logs available"; + const newLogs = data.logs || "No build logs available"; + const changed = newLogs !== this.buildLogs; + this.buildLogs = newLogs; this.buildStatus = data.status; - this.$nextTick(() => { - Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper); - }); + if (changed && this._buildAutoScroll) { + this.$nextTick(() => { + Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper); + }); + } } catch (err) { this.buildLogs = "Failed to fetch logs"; } @@ -306,12 +336,23 @@ document.addEventListener("alpine:init", () => { logs: "", status: config.status || "", pollInterval: null, + _autoScroll: true, init() { // Read initial logs from script tag (avoids escaping issues) const initialLogsEl = this.$el.querySelector(".initial-logs"); this.logs = initialLogsEl?.textContent || "Loading..."; + // Set up scroll tracking + this.$nextTick(() => { + const wrapper = this.$refs.logsWrapper; + if (wrapper) { + wrapper.addEventListener('scroll', () => { + this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper); + }, { passive: true }); + } + }); + // Only poll if deployment is in progress if (Alpine.store("utils").isDeploying(this.status)) { this.fetchLogs(); @@ -336,8 +377,8 @@ document.addEventListener("alpine:init", () => { this.logs = newLogs; this.status = data.status; - // Scroll to bottom only when content changes - if (logsChanged) { + // Scroll to bottom only when content changes AND user hasn't scrolled up + if (logsChanged && this._autoScroll) { this.$nextTick(() => { Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper); }); diff --git a/templates/app_detail.html b/templates/app_detail.html index caefa22..5685f64 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -279,8 +279,17 @@

Container Logs

-
-

+        
+
+

+            
+
@@ -329,8 +338,17 @@

Last Deployment Build Logs

-
-

+        
+
+

+            
+
diff --git a/templates/deployments.html b/templates/deployments.html index 3f83911..b50dcb3 100644 --- a/templates/deployments.html +++ b/templates/deployments.html @@ -85,8 +85,17 @@ {{end}}
-
-

+                
+
+

+                    
+
{{if .Logs.Valid}}{{end}}