From 4d95fddb1b39a7174e09209c2a3fe7ca07ff022c Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Tue, 17 Feb 2026 12:14:34 -0800 Subject: [PATCH 01/21] Fix cached session bug --- internal/auth/store.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/auth/store.go b/internal/auth/store.go index bff5e62..6525a1f 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -126,6 +126,7 @@ func (s *PGStore) Save(r *http.Request, w http.ResponseWriter, session *sessions } result := s.db.Where("token = ?", session.ID).Assign(models.Session{ + UserID: userID, Data: buf.Bytes(), ExpiresAt: expiresAt, }).FirstOrCreate(&dbSession) From 4b8ab0a3cbdaf06185b443107c70717a4f469539 Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Tue, 17 Feb 2026 15:49:23 -0800 Subject: [PATCH 02/21] Update dependencies and Go version to fix CVEs Fixes #10, Fixes #11, Fixes #12, Fixes #13, Fixes #35 Co-Authored-By: Claude Opus 4.6 --- .forgejo/workflows/ci.yaml | 2 +- Dockerfile | 2 +- go.mod | 12 ++++++------ go.sum | 9 +++++---- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index b1ba779..e0c551c 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -15,7 +15,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: '1.25' - name: Run tests run: go test ./... diff --git a/Dockerfile b/Dockerfile index 4c21b03..4baa8d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ COPY internal/handlers/ internal/handlers/ RUN npx @tailwindcss/cli -i web/static/css/input.css -o web/static/css/output.css --minify # Stage 2: Build Go binary -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download diff --git a/go.mod b/go.mod index 02183d1..362ed64 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,19 @@ module github.com/mattnite/forgejo-tickets -go 1.23.0 +go 1.25.3 require ( github.com/gin-gonic/gin v1.11.0 - github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 - github.com/gorilla/csrf v1.7.2 + github.com/gorilla/csrf v1.7.3 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.4.0 + github.com/microcosm-cc/bluemonday v1.0.27 github.com/mrz1836/postmark v1.6.5 github.com/rs/zerolog v1.34.0 + github.com/yuin/goldmark v1.7.16 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc golang.org/x/crypto v0.40.0 golang.org/x/oauth2 v0.25.0 gorm.io/driver/postgres v1.5.11 @@ -44,7 +47,6 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -52,8 +54,6 @@ require ( github.com/quic-go/quic-go v0.54.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - github.com/yuin/goldmark v1.7.16 // indirect - github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/mod v0.25.0 // indirect diff --git a/go.sum b/go.sum index 73c2431..a030945 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2Qx cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae h1:zzGwJfFlFGD94CyyYwCJeSuD32Gj9GTaSi5y9hoVzdY= github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= @@ -37,8 +38,8 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -46,8 +47,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.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= -github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= +github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= From e6cd175c92dbcab475a53425b0fe3520386fe454 Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Tue, 17 Feb 2026 15:50:18 -0800 Subject: [PATCH 03/21] Set Secure flag on session cookie for HTTPS Fixes #9 Co-Authored-By: Claude Opus 4.6 --- cmd/server/main.go | 3 ++- internal/auth/store.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 1f264ed..33771f4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -54,7 +55,7 @@ func main() { log.Info().Str("bot_login", forgejoClient.BotLogin).Msg("forgejo bot login initialized") } - sessionStore := auth.NewPGStore(db, []byte(cfg.SessionSecret)) + sessionStore := auth.NewPGStore(db, strings.HasPrefix(cfg.BaseURL, "https"), []byte(cfg.SessionSecret)) authService := auth.NewService(db, sessionStore, emailClient) ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/auth/store.go b/internal/auth/store.go index 6525a1f..5f8ba26 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -28,7 +28,7 @@ type PGStore struct { options *sessions.Options } -func NewPGStore(db *gorm.DB, keyPairs ...[]byte) *PGStore { +func NewPGStore(db *gorm.DB, secure bool, keyPairs ...[]byte) *PGStore { return &PGStore{ db: db, codecs: securecookie.CodecsFromPairs(keyPairs...), @@ -36,6 +36,7 @@ func NewPGStore(db *gorm.DB, keyPairs ...[]byte) *PGStore { Path: "/", MaxAge: sessionMaxAge, HttpOnly: true, + Secure: secure, SameSite: http.SameSiteLaxMode, }, } From 4a0af136d55a13ba29cf4dd0b8d8cddea8bde6a7 Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Tue, 17 Feb 2026 15:53:31 -0800 Subject: [PATCH 04/21] Add CSRF protection to admin panel Fixes #14 Co-Authored-By: Claude Opus 4.6 --- cmd/server/main.go | 1 + internal/handlers/admin/routes.go | 53 +++++++++++-------- web/templates/pages/admin/repos/edit.html | 1 + web/templates/pages/admin/repos/new.html | 1 + web/templates/pages/admin/tickets/detail.html | 1 + web/templates/pages/admin/users/detail.html | 1 + web/templates/pages/admin/users/new.html | 1 + web/templates/pages/admin/users/pending.html | 2 + 8 files changed, 40 insertions(+), 21 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 1f264ed..06685cb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -76,6 +76,7 @@ func main() { DB: db, Renderer: renderer, Auth: authService, + SessionStore: sessionStore, EmailClient: emailClient, ForgejoClient: forgejoClient, Config: cfg, diff --git a/internal/handlers/admin/routes.go b/internal/handlers/admin/routes.go index 1785be9..0d8d0cd 100644 --- a/internal/handlers/admin/routes.go +++ b/internal/handlers/admin/routes.go @@ -1,6 +1,8 @@ package admin import ( + "strings" + "github.com/gin-gonic/gin" "github.com/mattnite/forgejo-tickets/internal/auth" "github.com/mattnite/forgejo-tickets/internal/config" @@ -15,6 +17,7 @@ type Dependencies struct { DB *gorm.DB Renderer *templates.Renderer Auth *auth.Service + SessionStore *auth.PGStore EmailClient *email.Client ForgejoClient *forgejo.Client Config *config.Config @@ -30,30 +33,38 @@ func NewRouter(deps Dependencies) *gin.Engine { tsAuth := &TailscaleAuth{allowedUsers: deps.Config.TailscaleAllowedUsers} r.Use(tsAuth.Middleware) - dashboardHandler := &DashboardHandler{deps: deps} - r.GET("/", dashboardHandler.Index) + csrfSecret := []byte(deps.Config.SessionSecret) + isSecure := strings.HasPrefix(deps.Config.BaseURL, "https") + csrfMiddleware := middleware.CSRF(csrfSecret, isSecure) - userHandler := &UserHandler{deps: deps} - r.GET("/users", userHandler.List) - r.GET("/users/pending", userHandler.PendingList) - r.GET("/users/new", userHandler.NewForm) - r.GET("/users/:id", userHandler.Detail) - r.POST("/users", userHandler.Create) - r.POST("/users/:id/approve", userHandler.Approve) - r.POST("/users/:id/reject", userHandler.Reject) - r.POST("/users/:id/repos", userHandler.UpdateRepos) + csrf := r.Group("/") + csrf.Use(csrfMiddleware) + { + dashboardHandler := &DashboardHandler{deps: deps} + csrf.GET("/", dashboardHandler.Index) - ticketHandler := &TicketHandler{deps: deps} - r.GET("/tickets", ticketHandler.List) - r.GET("/tickets/:id", ticketHandler.Detail) - r.POST("/tickets/:id/status", ticketHandler.UpdateStatus) + userHandler := &UserHandler{deps: deps} + csrf.GET("/users", userHandler.List) + csrf.GET("/users/pending", userHandler.PendingList) + csrf.GET("/users/new", userHandler.NewForm) + csrf.GET("/users/:id", userHandler.Detail) + csrf.POST("/users", userHandler.Create) + csrf.POST("/users/:id/approve", userHandler.Approve) + csrf.POST("/users/:id/reject", userHandler.Reject) + csrf.POST("/users/:id/repos", userHandler.UpdateRepos) - repoHandler := &RepoHandler{deps: deps} - r.GET("/repos", repoHandler.List) - r.GET("/repos/new", repoHandler.NewForm) - r.POST("/repos", repoHandler.Create) - r.GET("/repos/:id/edit", repoHandler.EditForm) - r.POST("/repos/:id", repoHandler.Update) + ticketHandler := &TicketHandler{deps: deps} + csrf.GET("/tickets", ticketHandler.List) + csrf.GET("/tickets/:id", ticketHandler.Detail) + csrf.POST("/tickets/:id/status", ticketHandler.UpdateStatus) + + repoHandler := &RepoHandler{deps: deps} + csrf.GET("/repos", repoHandler.List) + csrf.GET("/repos/new", repoHandler.NewForm) + csrf.POST("/repos", repoHandler.Create) + csrf.GET("/repos/:id/edit", repoHandler.EditForm) + csrf.POST("/repos/:id", repoHandler.Update) + } return r } diff --git a/web/templates/pages/admin/repos/edit.html b/web/templates/pages/admin/repos/edit.html index c353ed3..ff508ef 100644 --- a/web/templates/pages/admin/repos/edit.html +++ b/web/templates/pages/admin/repos/edit.html @@ -64,6 +64,7 @@ {{end}}
+
+
+
{{range .AllRepos}}