[grade=B] fix(auth): use host-only session cookies on custom TLDs
Codex: urn:ump:7c22nwh67kot23f6czg5ax7e47hu2r7vowjpz3q6z63o73ti67vq
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -227,6 +227,10 @@ module.exports = function configureMiddleware(app, {
|
|||||||
ipSessions.delete(getClientIP(req));
|
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) {
|
function setSessionCookie(res, durationKey) {
|
||||||
const durationMs = SESSION_DURATIONS[durationKey];
|
const durationMs = SESSION_DURATIONS[durationKey];
|
||||||
if (!durationMs) return;
|
if (!durationMs) return;
|
||||||
@@ -235,9 +239,8 @@ module.exports = function configureMiddleware(app, {
|
|||||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
const key = cryptoUtils.loadOrCreateKey();
|
const key = cryptoUtils.loadOrCreateKey();
|
||||||
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
||||||
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
|
||||||
res.setHeader('Set-Cookie',
|
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) {
|
function clearSessionCookie(res) {
|
||||||
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
|
||||||
res.setHeader('Set-Cookie',
|
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
|
// + 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
|
// 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
|
// 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
|
// IP cache misses even when the cookie is valid. The host-only cookie is
|
||||||
// persisted HMAC key (loadOrCreateKey()), scoped to .sami via Domain attr,
|
// signed with a persisted HMAC key (loadOrCreateKey()). Cross-subdomain
|
||||||
// HttpOnly + Secure + SameSite=Lax — it's a stronger credential than the IP
|
// authentication uses the one-time SSO handoff because browsers reject
|
||||||
// cache. Ref: skill auth-and-monitoring-pitfalls.md "TOTP session validation
|
// Domain=.sami. HttpOnly + Secure + SameSite=Lax makes it a stronger
|
||||||
// IP-key issue" (FIXED 2026-07-21).
|
// 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) {
|
function isSessionValid(req) {
|
||||||
const cookies = parseCookies(req.headers.cookie);
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) {
|
if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) {
|
||||||
@@ -333,18 +336,7 @@ module.exports = function configureMiddleware(app, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setHostOnlySessionCookie(res, durationKey) {
|
function setHostOnlySessionCookie(res, durationKey) {
|
||||||
const durationMs = SESSION_DURATIONS[durationKey];
|
setSessionCookie(res, 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`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public routes (bypass TOTP and JWT auth) ──
|
// ── Public routes (bypass TOTP and JWT auth) ──
|
||||||
|
|||||||
Reference in New Issue
Block a user