harden(auth/cookies): __Host- prefix on all three auth cookies (MED-14, BREAKING)

Audit 2026-05-10 — close MED-14 from the HANDOFF.md backend batch
(item 5). The session, CSRF, and OIDC pre-login cookies all carry
the __Host- prefix; browsers now reject any subdomain attempt to
overwrite them.

Cookie name changes (BREAKING — existing sessions invalidate):
  - certctl_session       → __Host-certctl_session
  - certctl_csrf          → __Host-certctl_csrf
  - certctl_oidc_pending  → __Host-certctl_oidc_pending

The __Host- prefix requires Path=/ + Secure + no Domain attribute.
Post-login session + CSRF cookies already met all three. The pre-login
cookie's Path widened from '/auth/oidc/' to '/' to satisfy the prefix;
the cookie lives 10 minutes and is only consumed by the callback
handler, so the wider path scope is harmless.

Files touched:
  - internal/auth/session/domain/types.go — constant rename + comment
  - internal/auth/session/domain/types_test.go — assertion update
  - internal/api/handler/auth_session_oidc.go — pre-login set + clear
    paths widened from /auth/oidc/ to /
  - web/src/api/client.ts — readCSRFCookie now compares against
    '__Host-certctl_csrf'
  - CHANGELOG.md — Unreleased > Security (BREAKING) entry
  - docs/migration/oidc-enable.md — operator-facing detail of the
    one-time re-authentication window + GUI customization guidance

Operator impact: ONE re-login prompt per active session at the deploy
that lands this change. Subsequent logins issue the __Host-prefixed
cookie automatically. Existing bookmarked deep links work without
modification (cookies are path-scoped, not URL-scoped).

Refs: cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 5
      cowork/auth-bundles-audit-2026-05-10.md MED-14
This commit is contained in:
shankar0123
2026-05-10 22:52:53 +00:00
parent 72b54ce850
commit 874419989d
6 changed files with 100 additions and 22 deletions
+12 -6
View File
@@ -243,9 +243,12 @@ func (h *AuthSessionOIDCHandler) LoginInitiate(w http.ResponseWriter, r *http.Re
return
}
http.SetCookie(w, &http.Cookie{
Name: sessiondomain.PreLoginCookieName,
Value: cookieValue,
Path: "/auth/oidc/",
Name: sessiondomain.PreLoginCookieName,
Value: cookieValue,
// Audit 2026-05-10 MED-14 — `__Host-` prefix requires Path=/.
// The cookie lives 10 minutes and is only ever consumed by the
// callback handler; the wider path scope is harmless.
Path: "/",
MaxAge: int((10 * time.Minute).Seconds()),
Secure: h.cookieAttrs.Secure,
HttpOnly: true,
@@ -1104,9 +1107,12 @@ func (h *AuthSessionOIDCHandler) recordAudit(ctx context.Context, action, actor
func (h *AuthSessionOIDCHandler) clearPreLoginCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessiondomain.PreLoginCookieName,
Value: "",
Path: "/auth/oidc/",
Name: sessiondomain.PreLoginCookieName,
Value: "",
// Audit 2026-05-10 MED-14 — Path=/ matches the write site
// post-`__Host-` rename. The browser only clears cookies that
// match the original Set-Cookie's Name+Path+Domain triple.
Path: "/",
MaxAge: -1,
Secure: h.cookieAttrs.Secure,
HttpOnly: true,
+28 -4
View File
@@ -68,19 +68,43 @@ type SessionSigningKey struct {
const (
// PostLoginCookieName is the post-authentication session cookie.
// Set HttpOnly + Secure + SameSite=Lax (or Strict via env var).
PostLoginCookieName = "certctl_session"
//
// Audit 2026-05-10 MED-14 closure — `__Host-` prefix prevents
// subdomain takeover (sibling subdomain can't set a cookie that
// rides through with our origin's requests). The prefix requires:
// - Path=/ (already)
// - Secure (already; HTTPS-only control plane)
// - No Domain attribute (already)
// Existing sessions invalidate on the rolling deploy that lands
// this rename — operators must re-authenticate once. Documented in
// docs/migration/oidc-enable.md + CHANGELOG.md under BREAKING.
PostLoginCookieName = "__Host-certctl_session"
// PreLoginCookieName is the pre-authentication session cookie that
// holds the OIDC state + nonce + PKCE verifier across the IdP
// redirect. 10-minute lifetime, separate from the post-login
// cookie, Path=/auth/oidc/.
PreLoginCookieName = "certctl_oidc_pending"
// cookie.
//
// Audit 2026-05-10 MED-14 — pre-login cookies historically used
// Path=/auth/oidc/ which is INCOMPATIBLE with the `__Host-` prefix
// (which requires Path=/). Path is widened to / here; the cookie
// only lives for 10 minutes (the pre-login TTL), and is only
// consumed by the callback handler, so the wider path scope is
// harmless. The `__Host-` protection (subdomain-takeover defense)
// is the more valuable property.
PreLoginCookieName = "__Host-certctl_oidc_pending"
// CSRFCookieName is the JS-readable cookie holding the CSRF token
// plaintext. Mirrors the SHA-256 hash on the session row. The GUI
// reads this and echoes the value into the X-CSRF-Token header on
// every state-changing request.
CSRFCookieName = "certctl_csrf"
//
// Audit 2026-05-10 MED-14 — `__Host-` prefix applied; the CSRF
// cookie satisfies the requirements identically to the session
// cookie (Path=/, Secure, no Domain). Note this is HttpOnly=false
// (the GUI must read it) — but `__Host-` still applies regardless
// of HttpOnly; the prefix is about scope, not visibility.
CSRFCookieName = "__Host-certctl_csrf"
// CookieFormatVersion is the prefix on every session cookie value.
// Format: `v1.<session_id>.<signing_key_id>.<base64url-no-pad
+12 -6
View File
@@ -200,14 +200,20 @@ func TestCookieNamingConstants(t *testing.T) {
// `certctl_csrf` by name and the back-channel handlers reference
// `certctl_session` directly. A rename without coordinated GUI
// updates would silently break login.
if PostLoginCookieName != "certctl_session" {
t.Errorf("PostLoginCookieName = %q; want certctl_session", PostLoginCookieName)
// Audit 2026-05-10 MED-14 — `__Host-` prefix on all three auth
// cookies. Subdomain-takeover defense: a cookie named `__Host-*`
// can ONLY be set with Path=/ + Secure + no Domain attribute, and
// the browser will reject any subdomain attempt to overwrite. The
// rename is a BREAKING change on the wire — existing sessions
// invalidate on the rolling deploy.
if PostLoginCookieName != "__Host-certctl_session" {
t.Errorf("PostLoginCookieName = %q; want __Host-certctl_session", PostLoginCookieName)
}
if PreLoginCookieName != "certctl_oidc_pending" {
t.Errorf("PreLoginCookieName = %q; want certctl_oidc_pending", PreLoginCookieName)
if PreLoginCookieName != "__Host-certctl_oidc_pending" {
t.Errorf("PreLoginCookieName = %q; want __Host-certctl_oidc_pending", PreLoginCookieName)
}
if CSRFCookieName != "certctl_csrf" {
t.Errorf("CSRFCookieName = %q; want certctl_csrf", CSRFCookieName)
if CSRFCookieName != "__Host-certctl_csrf" {
t.Errorf("CSRFCookieName = %q; want __Host-certctl_csrf", CSRFCookieName)
}
if CookieFormatVersion != "v1" {
t.Errorf("CookieFormatVersion = %q; want v1", CookieFormatVersion)