diff --git a/dashcaddy-api/csrf-protection.js b/dashcaddy-api/csrf-protection.js index 1f4c465..ac438c6 100644 --- a/dashcaddy-api/csrf-protection.js +++ b/dashcaddy-api/csrf-protection.js @@ -49,54 +49,72 @@ function parseCookie(cookieHeader) { } /** - * Middleware to set CSRF cookie on requests. - * Preserves existing nonce to avoid invalidating tokens the client has cached. - * New nonce is generated only on first visit (no cookie) or after TOTP login - * (which calls renewCSRFToken). If TOTP is disabled, the nonce is set once - * and never changes. + * Create CSRF middleware with cookie domain support. + * When a TLD (e.g. ".sami") is provided, cookies are set with Domain=.sami + * so they are shared across all subdomains for forward_auth SSO. + * @param {Object} [options] + * @param {string} [options.cookieDomain] - e.g. ".sami" to share cookies across subdomains + * @returns {{ csrfCookieMiddleware: Function, renewCSRFToken: Function }} */ -function csrfCookieMiddleware(req, res, next) { - const cookies = parseCookie(req.headers.cookie); - const existingNonce = cookies[CSRF_COOKIE_NAME]; +function createCSRFMiddleware(options = {}) { + const { cookieDomain } = options; - // Reuse existing nonce; only generate fresh if no cookie exists yet - const csrfNonce = existingNonce || generateToken(); + /** + * Middleware to set CSRF cookie on requests. + * Preserves existing nonce to avoid invalidating tokens the client has cached. + * New nonce is generated only on first visit (no cookie) or after TOTP login + * (which calls renewCSRFToken). If TOTP is disabled, the nonce is set once + * and never changes. + */ + function csrfCookieMiddleware(req, res, next) { + const cookies = parseCookie(req.headers.cookie); + const existingNonce = cookies[CSRF_COOKIE_NAME]; - // Store nonce + signature on request so endpoints can access them - req.csrfToken = signToken(csrfNonce); - req.csrfNonce = csrfNonce; + // Reuse existing nonce; only generate fresh if no cookie exists yet + const csrfNonce = existingNonce || generateToken(); - // Only set cookie if it's new (avoids unnecessary Set-Cookie headers) - if (!existingNonce) { - res.cookie(CSRF_COOKIE_NAME, csrfNonce, { - httpOnly: false, // Must be readable by JavaScript for signing - secure: req.secure || req.protocol === 'https', - sameSite: 'strict', - path: '/', - maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year (effectively permanent) - }); + // Store nonce + signature on request so endpoints can access them + req.csrfToken = signToken(csrfNonce); + req.csrfNonce = csrfNonce; + + // Only set cookie if it's new (avoids unnecessary Set-Cookie headers) + if (!existingNonce) { + const cookieOpts = { + httpOnly: false, // Must be readable by JavaScript for signing + secure: req.secure || req.protocol === 'https', + sameSite: 'strict', + path: '/', + maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year (effectively permanent) + }; + if (cookieDomain) cookieOpts.domain = cookieDomain; + res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts); + } + + next(); } - next(); -} + /** + * Generate a fresh CSRF nonce and set it on the response. + * Called after TOTP login to rotate the token for the new session. + * @param {Object} res - Express response object + * @param {boolean} secure - Whether to set Secure flag on cookie + * @returns {string} The new CSRF signed token + */ + function renewCSRFToken(res, secure) { + const csrfNonce = generateToken(); + const cookieOpts = { + httpOnly: false, + secure: !!secure, + sameSite: 'strict', + path: '/', + maxAge: 365 * 24 * 60 * 60 * 1000 + }; + if (cookieDomain) cookieOpts.domain = cookieDomain; + res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts); + return signToken(csrfNonce); + } -/** - * Generate a fresh CSRF nonce and set it on the response. - * Called after TOTP login to rotate the token for the new session. - * @param {Object} res - Express response object - * @param {boolean} secure - Whether to set Secure flag on cookie - * @returns {string} The new CSRF signed token - */ -function renewCSRFToken(res, secure) { - const csrfNonce = generateToken(); - res.cookie(CSRF_COOKIE_NAME, csrfNonce, { - httpOnly: false, - secure: !!secure, - sameSite: 'strict', - path: '/', - maxAge: 365 * 24 * 60 * 60 * 1000 - }); - return signToken(csrfNonce); + return { csrfCookieMiddleware, renewCSRFToken }; } /** @@ -194,6 +212,9 @@ function csrfValidationMiddleware(req, res, next) { } } +// Default instance (no domain) for backward compatibility with tests +const defaultInstance = createCSRFMiddleware(); + module.exports = { CSRF_TOKEN_LENGTH, CSRF_COOKIE_NAME, @@ -201,7 +222,9 @@ module.exports = { generateToken, signToken, parseCookie, - csrfCookieMiddleware, + createCSRFMiddleware, csrfValidationMiddleware, - renewCSRFToken + // Default instance exports for backward compat + csrfCookieMiddleware: defaultInstance.csrfCookieMiddleware, + renewCSRFToken: defaultInstance.renewCSRFToken }; diff --git a/dashcaddy-api/middleware.js b/dashcaddy-api/middleware.js index 646def4..83af7c0 100644 --- a/dashcaddy-api/middleware.js +++ b/dashcaddy-api/middleware.js @@ -13,7 +13,7 @@ const helmet = require('helmet'); const compression = require('compression'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); -const { csrfCookieMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection'); +const { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection'); const { RATE_LIMITS, LIMITS, APP } = require('./constants'); const { CACHE_CONFIGS, createCache } = require('./cache-config'); @@ -75,7 +75,10 @@ module.exports = function configureMiddleware(app, { // ── Compress responses (gzip/brotli) ── app.use(compression()); - // ── CSRF Protection ── + // ── CSRF protection (cookie domain set to TLD for cross-subdomain SSO) ── + const { csrfCookieMiddleware, renewCSRFToken } = createCSRFMiddleware({ + cookieDomain: siteConfig.tld || undefined + }); app.use(csrfCookieMiddleware); app.use(csrfValidationMiddleware); @@ -221,8 +224,9 @@ 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}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax` + `${SESSION_COOKIE_NAME}=${payloadB64}.${sig}${domainAttr}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax` ); } @@ -253,8 +257,9 @@ 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; Path=/; HttpOnly; SameSite=Lax` + `${SESSION_COOKIE_NAME}=; Max-Age=0${domainAttr}; Path=/; HttpOnly; SameSite=Lax` ); } @@ -428,6 +433,7 @@ module.exports = function configureMiddleware(app, { clearIPSession, clearSessionCookie, isSessionValid, - ipSessions + ipSessions, + renewCSRFToken }; }; diff --git a/dashcaddy-api/routes/auth/index.js b/dashcaddy-api/routes/auth/index.js index 28adeae..8ecfa2f 100644 --- a/dashcaddy-api/routes/auth/index.js +++ b/dashcaddy-api/routes/auth/index.js @@ -27,7 +27,8 @@ module.exports = function(ctx) { fetchT: ctx.fetchT, getServiceById: ctx.getServiceById, licenseManager: ctx.licenseManager, - servicesStateManager: ctx.servicesStateManager + servicesStateManager: ctx.servicesStateManager, + renewCSRFToken: ctx.middlewareResult?.renewCSRFToken }; const { getAppSession, appSessionCache } = initSessionHandlers(deps); diff --git a/dashcaddy-api/routes/auth/session-handlers.js b/dashcaddy-api/routes/auth/session-handlers.js index 2fd6e88..91ff194 100644 --- a/dashcaddy-api/routes/auth/session-handlers.js +++ b/dashcaddy-api/routes/auth/session-handlers.js @@ -121,7 +121,7 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede return null; } default: - loginUrl = `${baseUrl}login`; + loginUrl = `${baseUrl.replace(/\/+$/, '')}/login`; loginBody = `username=${formEncode(username)}&password=${formEncode(password)}&rememberMe=on`; extraHeaders['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; break; @@ -168,7 +168,9 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede const rawCookie = resp.headers.get('set-cookie'); if (rawCookie) { - const cookies = rawCookie.split(/,(?=[^ ])/).map(c => c.split(';')[0].trim()).join('; '); + // headers.get('set-cookie') may return an array (Node http) or string + const cookieStr = Array.isArray(rawCookie) ? rawCookie.join('; ') : rawCookie; + const cookies = cookieStr.split(/,(?=[^ ])/).map(c => c.split(';')[0].trim()).join('; '); appSessionCache.set(serviceId, { cookies, exp: Date.now() + SESSION_TTL.COOKIE_SESSION }); log.info('auth', 'Auto-login successful (fallback), session cached', { serviceId }); return cookies; diff --git a/dashcaddy-api/routes/auth/totp.js b/dashcaddy-api/routes/auth/totp.js index 0bc6042..450c55b 100644 --- a/dashcaddy-api/routes/auth/totp.js +++ b/dashcaddy-api/routes/auth/totp.js @@ -1,5 +1,4 @@ const express = require('express'); -const { renewCSRFToken } = require('../../csrf-protection'); const { ValidationError, AuthenticationError } = require('../../errors'); /** @@ -15,7 +14,7 @@ const { ValidationError, AuthenticationError } = require('../../errors'); * @param {Object} deps.log - Logger instance * @returns {express.Router} */ -module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log }) { +module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken }) { const router = express.Router(); // Ctx shim for backward compatibility diff --git a/dashcaddy-api/src/context/index.js b/dashcaddy-api/src/context/index.js index a25532a..92baa80 100644 --- a/dashcaddy-api/src/context/index.js +++ b/dashcaddy-api/src/context/index.js @@ -160,6 +160,9 @@ function assembleContext({ loadNotificationConfig, resyncHealthChecker, + // Middleware result (exposes renewCSRFToken etc.) + middlewareResult, + // File paths SERVICES_FILE, CONFIG_FILE, diff --git a/dashcaddy-api/src/utils/http.js b/dashcaddy-api/src/utils/http.js index e265526..f82b8b2 100644 --- a/dashcaddy-api/src/utils/http.js +++ b/dashcaddy-api/src/utils/http.js @@ -86,7 +86,13 @@ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { statusText: res.statusMessage, json: () => Promise.resolve(JSON.parse(data)), text: () => Promise.resolve(data), - headers: { get: (k) => res.headers[k.toLowerCase()] }, + headers: { + get: (k) => res.headers[k.toLowerCase()], + getSetCookie: () => { + const sc = res.headers['set-cookie']; + return sc ? (Array.isArray(sc) ? sc : [sc]) : []; + } + }, }); }); }); @@ -142,7 +148,13 @@ function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { statusText: res.statusMessage, json: () => Promise.resolve(JSON.parse(data)), text: () => Promise.resolve(data), - headers: { get: (k) => res.headers[k.toLowerCase()] }, + headers: { + get: (k) => res.headers[k.toLowerCase()], + getSetCookie: () => { + const sc = res.headers['set-cookie']; + return sc ? (Array.isArray(sc) ? sc : [sc]) : []; + } + }, }); }); });