From 75f835641f1e10d723b49a40b0b73609c80dbd51 Mon Sep 17 00:00:00 2001 From: Krystie Date: Fri, 24 Jul 2026 05:05:41 -0700 Subject: [PATCH] [grade=B] fix(auth): use host-only session cookies on custom TLDs Codex: urn:ump:7c22nwh67kot23f6czg5ax7e47hu2r7vowjpz3q6z63o73ti67vq --- .../__tests__/session-cookie-scope.test.js | 64 +++++++++++++++++++ dashcaddy-api/src/utilities/middleware.js | 34 ++++------ 2 files changed, 77 insertions(+), 21 deletions(-) create mode 100644 dashcaddy-api/__tests__/session-cookie-scope.test.js diff --git a/dashcaddy-api/__tests__/session-cookie-scope.test.js b/dashcaddy-api/__tests__/session-cookie-scope.test.js new file mode 100644 index 0000000..0fbbfbe --- /dev/null +++ b/dashcaddy-api/__tests__/session-cookie-scope.test.js @@ -0,0 +1,64 @@ +'use strict'; + +const configureMiddleware = require('../src/utilities/middleware'); + +function buildSession() { + const app = { + param: jest.fn(), + set: jest.fn(), + use: jest.fn(), + }; + + return configureMiddleware(app, { + siteConfig: { dashboardHost: 'status.sami', tld: '.sami' }, + totpConfig: { enabled: true, sessionDuration: '24h' }, + tailscaleConfig: { enabled: false, requireAuth: false }, + metrics: { recordRequest: jest.fn() }, + auditLogger: { middleware: () => (_req, _res, next) => next() }, + authManager: { verifyJWT: jest.fn(), verifyAPIKey: jest.fn() }, + log: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + cryptoUtils: { loadOrCreateKey: () => Buffer.alloc(32, 7) }, + isValidContainerId: () => true, + isTailscaleIP: () => false, + getTailscaleStatus: async () => null, + }); +} + +function captureCookie(setCookie) { + const headers = {}; + setCookie({ setHeader: (name, value) => { headers[name.toLowerCase()] = value; } }, '24h'); + return headers['set-cookie']; +} + +describe('TOTP session cookie scope', () => { + test('primary login cookie is host-only for custom TLD deployments', () => { + const session = buildSession(); + const cookie = captureCookie(session.setSessionCookie); + + expect(cookie).toContain('dashcaddy_session='); + expect(cookie).toContain('HttpOnly'); + expect(cookie).toContain('Secure'); + expect(cookie).toContain('SameSite=Lax'); + expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i); + }); + + test('SSO exchange uses the same host-only cookie contract', () => { + const session = buildSession(); + const cookie = captureCookie(session.setHostOnlySessionCookie); + + expect(cookie).toContain('dashcaddy_session='); + expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i); + }); + + test('logout clears the host-only secure cookie', () => { + const session = buildSession(); + const headers = {}; + session.clearSessionCookie({ + setHeader: (name, value) => { headers[name.toLowerCase()] = value; }, + }); + + expect(headers['set-cookie']).toContain('Max-Age=0'); + expect(headers['set-cookie']).toContain('Secure'); + expect(headers['set-cookie']).not.toMatch(/(?:^|;)\s*Domain=/i); + }); +}); diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index e549880..d4e9d9d 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -227,6 +227,10 @@ module.exports = function configureMiddleware(app, { ipSessions.delete(getClientIP(req)); } + // Session cookies are intentionally host-only. Browsers reject Domain=.sami + // because .sami is an unregistered custom TLD and therefore treated as a + // public suffix. Cross-subdomain login is handled by the one-time SSO + // handoff below, which mints a separate host-only cookie on each service. function setSessionCookie(res, durationKey) { const durationMs = SESSION_DURATIONS[durationKey]; if (!durationMs) return; @@ -235,9 +239,8 @@ module.exports = function configureMiddleware(app, { const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); const key = cryptoUtils.loadOrCreateKey(); const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url'); - const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : ''; res.setHeader('Set-Cookie', - `${SESSION_COOKIE_NAME}=${payloadB64}.${sig}${domainAttr}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax` + `${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax` ); } @@ -268,9 +271,8 @@ module.exports = function configureMiddleware(app, { } function clearSessionCookie(res) { - const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : ''; res.setHeader('Set-Cookie', - `${SESSION_COOKIE_NAME}=; Max-Age=0${domainAttr}; Path=/; HttpOnly; SameSite=Lax` + `${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax` ); } @@ -278,11 +280,12 @@ module.exports = function configureMiddleware(app, { // + the write-back in this function) caused cross-subdomain SSO breakage when // Caddy on --network host forwards auth to the container: req.ip arrives as // 100.121.150.22 (DNS2's tailnet IP) instead of the user's real IP, so the - // IP cache misses even when the cookie is valid. The cookie is signed with a - // persisted HMAC key (loadOrCreateKey()), scoped to .sami via Domain attr, - // HttpOnly + Secure + SameSite=Lax — it's a stronger credential than the IP - // cache. Ref: skill auth-and-monitoring-pitfalls.md "TOTP session validation - // IP-key issue" (FIXED 2026-07-21). + // IP cache misses even when the cookie is valid. The host-only cookie is + // signed with a persisted HMAC key (loadOrCreateKey()). Cross-subdomain + // authentication uses the one-time SSO handoff because browsers reject + // Domain=.sami. HttpOnly + Secure + SameSite=Lax makes it a stronger + // credential than the IP cache. Ref: skill auth-and-monitoring-pitfalls.md + // "TOTP session validation IP-key issue" (FIXED 2026-07-21). function isSessionValid(req) { const cookies = parseCookies(req.headers.cookie); if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) { @@ -333,18 +336,7 @@ module.exports = function configureMiddleware(app, { } function setHostOnlySessionCookie(res, durationKey) { - const durationMs = SESSION_DURATIONS[durationKey]; - if (!durationMs) return; - const maxAge = Math.floor(durationMs / 1000); - const payload = { v: true, exp: Date.now() + durationMs }; - const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); - const key = cryptoUtils.loadOrCreateKey(); - const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url'); - // No Domain attribute — host-only, so it's always accepted regardless of - // the .sami public-suffix issue described above. - res.setHeader('Set-Cookie', - `${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax` - ); + setSessionCookie(res, durationKey); } // ── Public routes (bypass TOTP and JWT auth) ──