Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ import (
// see that helper for the Lax-vs-Strict rationale.
const CookieName = "sieve_session"

// CSRFCookieName carries the plaintext CSRF token. Unlike the session
// cookie this one is intentionally NOT HttpOnly: the page-side script
// has to read it to inject `csrf_token` form fields into POST forms
// and `X-CSRF-Token` headers into fetch calls. The plaintext is only
// useful if the attacker already has the session cookie (the server
// verifies token+session as a pair), so cookie-readability adds no
// new surface. SameSite=Lax matches the session cookie so the OAuth
// callback flow still receives both.
const CSRFCookieName = "sieve_csrf"

// DefaultIdleTimeout is the documented sliding-window expiry. Settings
// can override via the session.idle_timeout_minutes key.
const DefaultIdleTimeout = 8 * time.Hour
Expand Down Expand Up @@ -293,6 +303,35 @@ func ClearCookie(secure bool) *http.Cookie {
return c
}

// NewCSRFCookie returns the http.Cookie that carries the plaintext CSRF
// token to the browser so admin templates can echo it back into
// state-changing requests. HttpOnly is deliberately false — the page-
// side script reads this cookie to inject the `csrf_token` hidden input
// into every form before submit and to set the `X-CSRF-Token` header on
// fetch calls. SameSite=Lax matches the session cookie so the OAuth
// callback flow still receives both.
func NewCSRFCookie(plaintext string, secure bool) *http.Cookie {
return &http.Cookie{
Name: CSRFCookieName,
Value: plaintext,
Path: "/",
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
Secure: secure,
}
}

// ClearCSRFCookie returns a deletion cookie for the plaintext CSRF
// cookie. Used by /logout alongside ClearCookie() for the session.
// Implementation note: Go's net/http maps MaxAge < 0 to a
// "Max-Age=0" Set-Cookie attribute, which is the wire-level signal
// browsers treat as immediate deletion. The Go field value isn't 0.
func ClearCSRFCookie(secure bool) *http.Cookie {
c := NewCSRFCookie("", secure)
c.MaxAge = -1
return c
}

// --- helpers ---

func randString(n int) (string, error) {
Expand Down
18 changes: 18 additions & 0 deletions internal/web/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,21 @@ func (s *Server) requireOperatorSession(next http.Handler) http.Handler {
}
}

// Surface the plaintext CSRF token from its cookie onto the
// session-in-context so handlers/templates can echo it into
// forms. Lookup() returns the session with CSRFToken empty
// (only the hash is in the DB); the plaintext lives in the
// CSRFCookieName cookie set at Issue time. Missing cookie is
// not fatal here — the cookie is independent of the session
// cookie and may legitimately be absent on older sessions
// issued before this code was deployed; templates render with
// an empty CSRFToken in that case and the next form submit
// will fail closed at the verify gate above. Operator just
// re-logs in.
if csrfCookie, err := r.Cookie(session.CSRFCookieName); err == nil && csrfCookie.Value != "" {
sess.CSRFToken = csrfCookie.Value
}

ctx := context.WithValue(r.Context(), sessionCtxKey{}, sess)
next.ServeHTTP(w, r.WithContext(ctx))
})
Expand Down Expand Up @@ -307,6 +322,7 @@ func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
// so the cookie is still sent on plaintext loopback.
secure := r.TLS != nil
http.SetCookie(w, session.NewCookie(sess.Plaintext, secure))
http.SetCookie(w, session.NewCSRFCookie(sess.CSRFToken, secure))
http.Redirect(w, r, "/", http.StatusSeeOther)
Comment thread
murbard marked this conversation as resolved.
}

Expand All @@ -332,6 +348,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
}
secure := r.TLS != nil
http.SetCookie(w, session.ClearCookie(secure))
http.SetCookie(w, session.ClearCSRFCookie(secure))
http.Redirect(w, r, "/login", http.StatusSeeOther)
}

Expand Down Expand Up @@ -420,6 +437,7 @@ func (s *Server) handleSetupPost(w http.ResponseWriter, r *http.Request) {
s.logLoginAttempt(name, "setup.ok", ip)
secure := r.TLS != nil
http.SetCookie(w, session.NewCookie(sess.Plaintext, secure))
http.SetCookie(w, session.NewCSRFCookie(sess.CSRFToken, secure))
http.Redirect(w, r, "/", http.StatusSeeOther)
}

Expand Down
50 changes: 44 additions & 6 deletions internal/web/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,17 +135,29 @@ func TestLogin_HappyPath(t *testing.T) {
body := readAll(t, resp.Body)
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
}
// Session cookie set; redirect to /.
hasCookie := false
// Session cookie + sieve_csrf cookie set; redirect to /.
var sessionCookie, csrfCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == session.CookieName {
hasCookie = true
break
switch c.Name {
case session.CookieName:
sessionCookie = c
case session.CSRFCookieName:
csrfCookie = c
}
}
if !hasCookie {
if sessionCookie == nil {
t.Error("login did not set session cookie")
}
if csrfCookie == nil {
t.Error("login did not set sieve_csrf cookie")
} else {
if csrfCookie.HttpOnly {
t.Error("sieve_csrf cookie must be non-HttpOnly (page script needs to read it)")
}
if csrfCookie.Value == "" {
t.Error("sieve_csrf cookie value must not be empty after a successful login")
}
}
if resp.Header.Get("Location") != "/" {
t.Errorf("Location = %q, want /", resp.Header.Get("Location"))
}
Expand All @@ -169,6 +181,9 @@ func TestLogin_WrongCredential(t *testing.T) {
if c.Name == session.CookieName {
t.Error("failed login must NOT set session cookie")
}
if c.Name == session.CSRFCookieName {
t.Error("failed login must NOT set sieve_csrf cookie")
}
}
}

Expand Down Expand Up @@ -208,6 +223,29 @@ func TestLogout_DeletesCookieAndSession(t *testing.T) {
if _, err := env.Session.Lookup(cookie.Value); err == nil {
t.Error("logout did not delete the session row")
}
// Both the session cookie and the sieve_csrf cookie should carry
// a deletion directive (MaxAge<0 / empty value) so the browser
// drops them. Without clearing sieve_csrf, a subsequent login
// would race against the stale cookie still being sent up.
var sessionCleared, csrfCleared bool
for _, c := range resp.Cookies() {
switch c.Name {
case session.CookieName:
if c.MaxAge < 0 || c.Value == "" {
sessionCleared = true
}
case session.CSRFCookieName:
if c.MaxAge < 0 || c.Value == "" {
csrfCleared = true
}
}
}
if !sessionCleared {
t.Error("logout did not clear the session cookie")
}
if !csrfCleared {
t.Error("logout did not clear the sieve_csrf cookie")
}
}

// --- Middleware tests ---
Expand Down
30 changes: 21 additions & 9 deletions internal/web/connection_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ type connectionEditData struct {
// HTTPProxyBaseline carries the static deny-list info displayed
// alongside the http_proxy edit form. Read-only; not a form field.
HTTPProxyBaseline *httpProxyBaselineView

// CSRFToken is the plaintext token used by nav.html to seed
// window.SIEVE_CSRF, which the delegated submit handler echoes
// back into every POST form as `csrf_token`. Populated by render()
// from the session-in-context — handlers don't set it directly.
CSRFToken string
Comment thread
murbard marked this conversation as resolved.
}

// editFieldView is the template-friendly projection of a single editable
Expand Down Expand Up @@ -111,7 +117,7 @@ func (s *Server) handleConnectionEditPage(w http.ResponseWriter, r *http.Request
if r.URL.Query().Get("saved") == "1" {
data.Success = true
}
s.render(w, "connection_edit", data)
s.render(w, r, "connection_edit", data)
}

// handleConnectionEditSave validates and persists the edit form.
Expand Down Expand Up @@ -139,7 +145,7 @@ func (s *Server) handleConnectionEditSave(w http.ResponseWriter, r *http.Request

meta, ok := s.registry.Meta(conn.ConnectorType)
if !ok {
s.renderEditError(w, conn, "unknown connector type "+conn.ConnectorType)
s.renderEditError(w, r, conn, "unknown connector type "+conn.ConnectorType)
return
}

Expand All @@ -154,7 +160,7 @@ func (s *Server) handleConnectionEditSave(w http.ResponseWriter, r *http.Request
// Render from cfg (the in-progress config carrying every
// field parsed successfully so far) so the operator's typed
// values are preserved in the form.
s.renderEditErrorWithConfig(w, conn, cfg, msg)
s.renderEditErrorWithConfig(w, r, conn, cfg, msg)
return
}

Expand All @@ -164,7 +170,7 @@ func (s *Server) handleConnectionEditSave(w http.ResponseWriter, r *http.Request
// here gives a clearer banner that preserves the rest of the form.
if conn.ConnectorType == "http_proxy" {
if aqp, _ := cfg["auth_query_param"].(string); aqp != "" && !authQueryParamPattern.MatchString(aqp) {
s.renderEditErrorWithConfig(w, conn, cfg,
s.renderEditErrorWithConfig(w, r, conn, cfg,
"auth_query_param must contain only letters, digits, _, -, or . (got "+aqp+")")
return
}
Expand All @@ -173,7 +179,7 @@ func (s *Server) handleConnectionEditSave(w http.ResponseWriter, r *http.Request
if err := s.connections.UpdateConfig(id, cfg); err != nil {
// Render from cfg so the operator's attempted values are
// preserved on the retry.
s.renderEditErrorWithConfig(w, conn, cfg, "save failed: "+err.Error())
s.renderEditErrorWithConfig(w, r, conn, cfg, "save failed: "+err.Error())
return
}

Expand Down Expand Up @@ -325,8 +331,8 @@ func joinStringSliceField(v any) string {
// Use this only when the in-progress config isn't meaningful (e.g.,
// unknown connector type, where there's nothing successfully parsed
// to preserve).
func (s *Server) renderEditError(w http.ResponseWriter, conn *connections.Connection, msg string) {
s.renderEditErrorWithConfig(w, conn, conn.Config, msg)
func (s *Server) renderEditError(w http.ResponseWriter, r *http.Request, conn *connections.Connection, msg string) {
s.renderEditErrorWithConfig(w, r, conn, conn.Config, msg)
}

// renderEditErrorWithConfig re-renders the edit page using a specific
Expand All @@ -338,10 +344,16 @@ func (s *Server) renderEditError(w http.ResponseWriter, conn *connections.Connec
// Sets Content-Type and the 400 status on the ResponseWriter directly
// rather than calling s.render's helper, so we don't trip the
// "superfluous WriteHeader" warning when render writes its own
// content-type header.
func (s *Server) renderEditErrorWithConfig(w http.ResponseWriter, conn *connections.Connection, cfg map[string]any, msg string) {
// content-type header. The CSRFToken is injected manually here too,
// since we bypass s.render — without it, the rendered error page
// would carry an empty window.SIEVE_CSRF and the operator's retry
// POST would 403 at the middleware.
func (s *Server) renderEditErrorWithConfig(w http.ResponseWriter, r *http.Request, conn *connections.Connection, cfg map[string]any, msg string) {
data := s.connectionEditViewFromConfig(conn, cfg)
data.Error = msg
if sess := sessionFromContext(r); sess != nil {
data.CSRFToken = sess.CSRFToken
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
t, ok := s.templates["connection_edit"]
Expand Down
90 changes: 90 additions & 0 deletions internal/web/csrf_form_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package web_test

// Regression tests for the end-to-end CSRF token plumbing on admin
// POST forms. Background: connections form templates (gitlab,
// http_proxy, slack, etc.) historically didn't include a csrf_token
// hidden input and the render path didn't expose the plaintext token
// to nav.html, so every basic Add-Connection POST failed at the
// middleware with "csrf token missing or invalid".
//
// This file pins two properties:
//
// - TestPOSTConnectionsAddPassesCSRF: a POST to /connections/add
// carrying the csrf_token form field (the value the nav.html
// submit handler echoes back from window.SIEVE_CSRF) is accepted
// by the middleware and reaches the handler. We assert the
// documented 303 redirect on success.
// - TestPOSTConnectionsAddMissingCSRFRejected: the negative control
// — the same POST without any CSRF token still 403s with a
// csrf-themed body. Pins that the new plumbing didn't accidentally
// relax the gate.
//
// The new sieve_csrf cookie's set/clear lifecycle is covered alongside
// the session cookie in auth_test.go (TestLogin_HappyPath,
// TestLogin_WrongCredential, TestLogout_DeletesCookieAndSession).

import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)

// TestPOSTConnectionsAddPassesCSRF asserts that an admin POST with the
// session cookie + csrf_token form field passes the middleware. Uses
// the connector_type=mock seed (mock is registered by newTestWebServer)
// so the handler runs to completion and we exercise the real success
// path rather than a 400-on-unknown-connector path.
func TestPOSTConnectionsAddPassesCSRF(t *testing.T) {
handler, env := newTestWebServer(t)

form := url.Values{}
form.Set("connector_type", "mock")
form.Set("id", "smoke-csrf")
form.Set("display_name", "CSRF smoke")
form.Set("csrf_token", env.CSRFToken())

req := httptest.NewRequest(http.MethodPost, "/connections/add", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if c := env.SessionCookie(); c != nil {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

if rec.Code == http.StatusForbidden && strings.Contains(rec.Body.String(), "csrf") {
t.Fatalf("CSRF middleware rejected request that carried the form-field token (body: %s)", rec.Body.String())
}
if rec.Code != http.StatusSeeOther {
t.Fatalf("expected 303 redirect after Add, got %d (body: %s)", rec.Code, rec.Body.String())
}
}

// TestPOSTConnectionsAddMissingCSRFRejected is the negative control:
// without the csrf_token form field (and without the X-CSRF-Token
// header), the middleware MUST reject with 403. This pins the security
// property that the new plumbing didn't accidentally relax.
func TestPOSTConnectionsAddMissingCSRFRejected(t *testing.T) {
handler, env := newTestWebServer(t)

form := url.Values{}
form.Set("connector_type", "mock")
form.Set("id", "smoke-no-csrf")
form.Set("display_name", "CSRF negative control")

req := httptest.NewRequest(http.MethodPost, "/connections/add", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if c := env.SessionCookie(); c != nil {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing CSRF token, got %d (body: %s)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "csrf") {
t.Errorf("expected csrf-related error body, got: %s", rec.Body.String())
}
}
2 changes: 1 addition & 1 deletion internal/web/passphrase_rotation.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ func (s *Server) renderRotationError(w http.ResponseWriter, r *http.Request, sta
// machine.
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
s.render(w, "settings", data)
s.render(w, r, "settings", data)
}

// checkRotationOrigin verifies that the request's Origin header (or
Expand Down
Loading