Files
pad/internal/server/middleware_session_ua_test.go
xarmian 3f69b76b06 feat(security): enforce session UA binding under strict mode (TASK-2056) (#912)
Session IP/User-Agent binding was log-only by default, so a stolen
session token granted durable any-origin access. IP-change enforcement
already existed behind PAD_IP_CHANGE_ENFORCE=strict; this extends the
same single toggle to also enforce the User-Agent-hash binding.

When strict enforce is ON, a request whose client IP OR User-Agent hash
no longer matches the session's stored binding now revokes the session
(DeleteSessionIfExists) and rejects the request (401 for API,
revoked-passthrough for public/browser paths), killing the stolen token.
When enforce is OFF (default), behavior is unchanged: UA mismatch is
logged (slog only, no new audit row) and the request proceeds, so
existing self-host users see no behavior change and routine client churn
(browser/WebView updates, DevTools emulation, mobile-app rebuilds) is
tolerated.

The UA hash is stable within a real session, so UA-mismatch enforce
carries fewer false positives than IP enforce (mobile roaming, VPN
toggles, carrier NAT) — documented in the handler comment. Adds the
ActionSessionUAChanged audit action, emitted only in strict mode.

No DB migration: reuses the existing IPChangeEnforce config flag and the
existing session store primitives.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-10 23:32:22 -04:00

170 lines
6.1 KiB
Go

package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// uaBoundSession creates a user + a session bound to a fixed client IP and
// User-Agent, returning the raw session token. Tests drive requests through
// doGetWithCookieUA below, varying only the User-Agent so the IP-binding
// check stays quiet and the UA-binding check is what's under test.
func uaBoundSession(t *testing.T, srv *Server, email, ua string) string {
t.Helper()
user, err := srv.store.CreateUser(models.UserCreate{
Email: email,
Name: "UA Tester",
Password: "pw-test-12345",
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
tok, err := srv.store.CreateSession(user.ID, "test", "192.0.2.1", ua, webSessionTTL)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
return tok
}
// doGetWithCookieUA issues a GET with the session cookie, a fixed client IP
// matching the session binding, and a caller-chosen User-Agent. GET needs no
// CSRF token, keeping the UA-binding assertion isolated.
func doGetWithCookieUA(srv *Server, path, token, ua string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", path, nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("User-Agent", ua)
req.AddCookie(&http.Cookie{Name: sessionCookieName(srv.secureCookies), Value: token})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
}
// countUAChangeEvents returns the number of session_ua_changed rows in the
// audit log.
func countUAChangeEvents(t *testing.T, srv *Server) int {
t.Helper()
acts, err := srv.store.ListAuditLog(models.AuditLogParams{
Action: models.ActionSessionUAChanged,
Limit: 100,
})
if err != nil {
t.Fatalf("list audit log: %v", err)
}
n := 0
for _, a := range acts {
if a.Action == models.ActionSessionUAChanged {
n++
}
}
return n
}
// TestSessionUAChange_LogOnlyDefault verifies that without
// PAD_IP_CHANGE_ENFORCE=strict a session that presents a different
// User-Agent is allowed through, the session survives, and — critically —
// NO audit row is written (log-only mode must preserve the historical
// slog-only behavior exactly, so existing self-host audit feeds are
// untouched).
func TestSessionUAChange_LogOnlyDefault(t *testing.T) {
srv := testServer(t)
const boundUA = "pad-cli/1.0 (session-bound)"
token := uaBoundSession(t, srv, "ua-logonly@example.com", boundUA)
// Matching UA — happy path.
rr := doGetWithCookieUA(srv, "/api/v1/auth/me", token, boundUA)
if rr.Code != http.StatusOK {
t.Fatalf("matching-UA request: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
// Mismatched UA — still allowed in log-only mode.
rr = doGetWithCookieUA(srv, "/api/v1/auth/me", token, "totally-different-agent/9.9")
if rr.Code != http.StatusOK {
t.Fatalf("UA-changed request in log-only mode: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
// No audit row (behavior preserved exactly).
if got := countUAChangeEvents(t, srv); got != 0 {
t.Fatalf("expected 0 session_ua_changed audit rows in log-only mode, got %d", got)
}
// Session survives — the original UA still authenticates afterward.
rr = doGetWithCookieUA(srv, "/api/v1/auth/me", token, boundUA)
if rr.Code != http.StatusOK {
t.Fatalf("post-mismatch matching-UA request: expected 200 (session not revoked), got %d", rr.Code)
}
}
// TestSessionUAChange_StrictRevokes verifies that with strict enforcement a
// session presenting a different User-Agent is rejected (401), the session
// is destroyed, and an ActionSessionUAChanged audit row is written.
func TestSessionUAChange_StrictRevokes(t *testing.T) {
srv := testServer(t)
srv.SetIPChangeEnforce("strict")
const boundUA = "pad-cli/1.0 (session-bound)"
token := uaBoundSession(t, srv, "ua-strict@example.com", boundUA)
// Sanity: matching UA still works in strict mode.
rr := doGetWithCookieUA(srv, "/api/v1/auth/me", token, boundUA)
if rr.Code != http.StatusOK {
t.Fatalf("matching-UA request in strict mode: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
// Mismatched UA — rejected with 401 session_ua_changed.
rr = doGetWithCookieUA(srv, "/api/v1/auth/me", token, "stolen-token-replayer/1.0")
if rr.Code != http.StatusUnauthorized {
t.Fatalf("UA-changed request in strict mode: expected 401, got %d: %s", rr.Code, rr.Body.String())
}
if got := countUAChangeEvents(t, srv); got != 1 {
t.Fatalf("expected 1 session_ua_changed audit row in strict mode, got %d", got)
}
// Session was destroyed — even the ORIGINAL (matching) UA no longer
// authenticates. This is the whole point: the stolen token is dead.
rr = doGetWithCookieUA(srv, "/api/v1/auth/me", token, boundUA)
if rr.Code == http.StatusOK {
t.Fatal("session should have been destroyed after strict UA rejection")
}
}
// TestSessionUAChange_StrictBearerRevokes verifies the same enforcement on
// the CLI session-bearer path (Authorization: Bearer padsess_...), which
// runs through TokenAuth rather than SessionAuth.
func TestSessionUAChange_StrictBearerRevokes(t *testing.T) {
srv := testServer(t)
srv.SetIPChangeEnforce("strict")
const boundUA = "pad-cli/1.0 (session-bound)"
token := uaBoundSession(t, srv, "ua-bearer@example.com", boundUA)
doBearer := func(ua string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/v1/auth/me", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("User-Agent", ua)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
}
// Matching UA works.
if rr := doBearer(boundUA); rr.Code != http.StatusOK {
t.Fatalf("matching-UA bearer request: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
// Mismatched UA — rejected + revoked.
rr := doBearer("stolen-bearer/1.0")
if rr.Code != http.StatusUnauthorized {
t.Fatalf("UA-changed bearer request in strict mode: expected 401, got %d: %s", rr.Code, rr.Body.String())
}
if got := countUAChangeEvents(t, srv); got != 1 {
t.Fatalf("expected 1 session_ua_changed audit row, got %d", got)
}
// Token is dead now, even with the original UA.
if rr := doBearer(boundUA); rr.Code == http.StatusOK {
t.Fatal("bearer session should be destroyed after strict UA rejection")
}
}