Skip to content

Commit ccf810f

Browse files
author
Codex
committed
fix: replace gorilla/csrf with custom double-submit cookie CSRF
gorilla/csrf was causing 403 on all HTMX POST requests due to complex token masking and HttpOnly cookie requirements that conflicted with JavaScript-based token injection. New approach: simple double-submit cookie pattern: - GET sets a readable _csrf cookie (not HttpOnly) - HTMX reads cookie via document.cookie and sends X-CSRF-Token header - POST validates header matches cookie - No gorilla dependency needed for CSRF
1 parent 7d1534e commit ccf810f

5 files changed

Lines changed: 79 additions & 18 deletions

File tree

internal/web/csrf.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package web
2+
3+
import (
4+
"crypto/rand"
5+
"encoding/base64"
6+
"net/http"
7+
)
8+
9+
const csrfCookieName = "_csrf"
10+
const csrfHeaderName = "X-CSRF-Token"
11+
const csrfTokenLength = 32
12+
13+
// CSRFMiddleware implements double-submit cookie CSRF protection.
14+
// On GET requests: sets a CSRF cookie if not present.
15+
// On POST/PUT/DELETE requests: validates that the X-CSRF-Token header matches the cookie.
16+
func CSRFMiddleware(next http.Handler) http.Handler {
17+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
switch r.Method {
19+
case "GET", "HEAD", "OPTIONS":
20+
// Safe methods — ensure CSRF cookie exists
21+
if _, err := r.Cookie(csrfCookieName); err != nil {
22+
token := generateCSRFToken()
23+
http.SetCookie(w, &http.Cookie{
24+
Name: csrfCookieName,
25+
Value: token,
26+
Path: "/",
27+
HttpOnly: false, // Must be readable by JavaScript
28+
SameSite: http.SameSiteLaxMode,
29+
MaxAge: 86400,
30+
})
31+
}
32+
next.ServeHTTP(w, r)
33+
34+
default:
35+
// Mutation methods — validate token
36+
cookie, err := r.Cookie(csrfCookieName)
37+
if err != nil || cookie.Value == "" {
38+
http.Error(w, "CSRF cookie missing", http.StatusForbidden)
39+
return
40+
}
41+
headerToken := r.Header.Get(csrfHeaderName)
42+
if headerToken == "" {
43+
headerToken = r.FormValue("csrf_token")
44+
}
45+
if headerToken != cookie.Value {
46+
http.Error(w, "CSRF token mismatch", http.StatusForbidden)
47+
return
48+
}
49+
next.ServeHTTP(w, r)
50+
}
51+
})
52+
}
53+
54+
// GetCSRFToken extracts the CSRF token from the request cookie.
55+
// Used by handlers to pass the token to templates.
56+
func GetCSRFToken(r *http.Request) string {
57+
cookie, err := r.Cookie(csrfCookieName)
58+
if err != nil {
59+
return ""
60+
}
61+
return cookie.Value
62+
}
63+
64+
func generateCSRFToken() string {
65+
b := make([]byte, csrfTokenLength)
66+
rand.Read(b)
67+
return base64.URLEncoding.EncodeToString(b)
68+
}

internal/web/handlers/report.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020
"reportbot/internal/web/templates"
2121

2222
"github.com/go-chi/chi/v5"
23-
"github.com/gorilla/csrf"
2423
)
2524

2625
// buildResultCache caches BuildResult per week to avoid re-running the LLM pipeline
@@ -133,7 +132,7 @@ func ReportEditorPage(cfg web.Config, db *sql.DB) http.HandlerFunc {
133132
Sections: sections,
134133
AllSections: allSections,
135134
IsManager: isManager,
136-
CSRFToken: csrf.Token(r),
135+
CSRFToken: web.GetCSRFToken(r),
137136
Mode: mode,
138137
HasClassifications: hasClassifications,
139138
}

internal/web/handlers/server.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010

1111
"github.com/go-chi/chi/v5"
1212
chimw "github.com/go-chi/chi/v5/middleware"
13-
"github.com/gorilla/csrf"
1413

1514
"reportbot/internal/web"
1615
mw "reportbot/internal/web/middleware"
@@ -25,16 +24,10 @@ func NewServer(cfg web.Config, db *sql.DB) *http.Server {
2524
r.Use(chimw.Recoverer)
2625

2726
// Derive Secure flag from base URL scheme
28-
isHTTPS := strings.HasPrefix(cfg.WebBaseURL, "https://")
29-
30-
// CSRF protection
31-
csrfMiddleware := csrf.Protect(
32-
[]byte(cfg.WebSessionSecret),
33-
csrf.Secure(isHTTPS),
34-
csrf.Path("/"),
35-
csrf.RequestHeader("X-CSRF-Token"),
36-
)
37-
r.Use(csrfMiddleware)
27+
_ = strings.HasPrefix(cfg.WebBaseURL, "https://")
28+
29+
// CSRF protection — double-submit cookie (replaces gorilla/csrf)
30+
r.Use(web.CSRFMiddleware)
3831

3932
// Static files (embedded in web package)
4033
staticFS, err := fs.Sub(web.StaticFiles, "static")

internal/web/templates/layout.templ

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ templ Layout(title string, csrfToken string, isManager bool, activePage string)
1414
<body>
1515
<script>
1616
document.body.addEventListener('htmx:configRequest', function(e) {
17-
var token = document.querySelector('meta[name="csrf-token"]');
18-
if (token) {
19-
e.detail.headers['X-CSRF-Token'] = token.content;
17+
// Read CSRF token from cookie (double-submit cookie pattern)
18+
var match = document.cookie.match(/(^|;\s*)_csrf=([^;]*)/);
19+
if (match) {
20+
e.detail.headers['X-CSRF-Token'] = decodeURIComponent(match[2]);
2021
}
2122
});
2223
</script>

internal/web/templates/layout_templ.go

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)