Add CSRF protection and GET /api/v1/me
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m27s
CI / ci (push) Successful in 1m23s

RequireSession now stores the validated session in the request context
(auth.WithSession / auth.SessionFrom); auth.ActorFrom derives the actor
from it, so there is one context value.

Every /api/v1 request other than GET, HEAD and OPTIONS must carry
X-MealQuest-CSRF equal to the session's csrf_token (constant-time
compare), else 403 {"error":{"code":"csrf"}}. The check runs after
RequireSession, so unauthenticated requests still get 401. A session
with no token is a 500, never a match against a missing header.

GET /api/v1/me returns {name, email, household: {label: "Our kitchen"},
csrfToken} with Cache-Control: no-store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:32:31 -04:00
co-authored by Claude Opus 5.5
parent b2201c5da1
commit 15cc3c5ce4
9 changed files with 306 additions and 23 deletions
+5 -2
View File
@@ -24,7 +24,8 @@ type RouteMounter interface {
// NewRouter returns the HTTP handler for the service. version is reported by
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
// /media; assets is the web build served for every other GET. /health, /auth
// and the web build are public.
// and the web build are public. Requests to /api/v1 other than GET, HEAD and
// OPTIONS also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, auth RouteMounter, sessions SessionLookup, assets fs.FS) (http.Handler, error) {
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -44,15 +45,17 @@ func NewRouter(logger *slog.Logger, version string, auth RouteMounter, sessions
// rather than being added with Use: chi skips a subrouter's middleware
// when it has no routes, which would leave it unguarded.
signedIn := requireSession(logger, sessions)
csrf := requireCSRF(logger)
apiV1, media := chi.NewRouter(), chi.NewRouter()
for _, sub := range []*chi.Mux{apiV1, media} {
sub.MethodNotAllowed(methodNotAllowed)
sub.NotFound(notFound)
}
apiV1.Get("/me", me(logger))
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Mount("/v1", signedIn(apiV1))
r.Mount("/v1", signedIn(csrf(apiV1)))
})
r.Mount("/media", signedIn(media))
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,