diff --git a/docker/coolify-terminal/terminal-server.js b/docker/coolify-terminal/terminal-server.js index cdf327b93a..96df75dfb7 100755 --- a/docker/coolify-terminal/terminal-server.js +++ b/docker/coolify-terminal/terminal-server.js @@ -1,7 +1,6 @@ import { WebSocketServer } from 'ws'; import http from 'http'; import pty from 'node-pty'; -import { parseCookie } from 'cookie'; import 'dotenv/config'; import { TERMINAL_CLOSE_CODES, @@ -112,25 +111,22 @@ const server = http.createServer((req, res) => { } }); -const getAuthHeaders = (req) => { - const cookieHeader = req.headers.cookie || ''; - return getTerminalAuthHeaders(cookieHeader, parseCookie(cookieHeader)['XSRF-TOKEN']); -} - const verifyClient = async (req) => { - const authHeaders = getAuthHeaders(req); + const authHeaderCandidates = getTerminalAuthHeaders(req.headers.cookie); const requestContext = { remoteAddress: req.socket?.remoteAddress, origin: req.headers.origin, - hasAuthTokens: Boolean(authHeaders), + xsrfTokenCount: authHeaderCandidates.length, }; logTerminal('log', 'Verifying websocket client.', requestContext); // Authenticate with Laravel backend - const result = await authenticateTerminalUpgrade(authHeaders, postToCoolify); + const result = await authenticateTerminalUpgrade(authHeaderCandidates, postToCoolify); if (result.authenticated) { + // The connection handler sends its requests with the XSRF token that Coolify accepted. + req.terminalAuthHeaders = result.authHeaders; logTerminal('log', 'Websocket client authentication succeeded.', requestContext); } else if (result.error) { logTerminal('error', 'Websocket client authentication failed.', { @@ -162,7 +158,7 @@ wss.on('connection', async (ws, req) => { const userId = generateUserId(); ws.userId = userId; - const authHeaders = getAuthHeaders(req); + const authHeaders = req.terminalAuthHeaders; const userSession = { ws, userId, diff --git a/docker/coolify-terminal/terminal-utils.js b/docker/coolify-terminal/terminal-utils.js index 0c7afcb012..ac2325f0da 100644 --- a/docker/coolify-terminal/terminal-utils.js +++ b/docker/coolify-terminal/terminal-utils.js @@ -1,3 +1,5 @@ +import { parseCookie } from 'cookie'; + export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60; /** @@ -14,35 +16,54 @@ export const TERMINAL_CLOSE_CODES = Object.freeze({ export const TERMINAL_REJECTED_SOCKET_TERMINATE_MS = 2000; /** - * Builds the headers that authenticate terminal requests to Coolify. The whole - * browser Cookie header is forwarded, so Laravel finds its session cookie by - * its own name (SESSION_COOKIE or the slug of APP_NAME). - * - * @param {string} cookieHeader - * @param {string | undefined} xsrfToken - * @returns {{ 'Cookie': string, 'X-XSRF-TOKEN': string } | null} null when a required value is missing + * Limits the Coolify requests that one WebSocket upgrade can cause. */ -export function getTerminalAuthHeaders(cookieHeader, xsrfToken) { - if (!cookieHeader || !xsrfToken) { - return null; +export const MAX_XSRF_TOKEN_CANDIDATES = 5; + +/** + * Builds the header sets that can authenticate terminal requests to Coolify, one + * for each XSRF-TOKEN cookie. The whole browser Cookie header is forwarded, so + * Laravel finds its session cookie by its own name (SESSION_COOKIE or the slug + * of APP_NAME). + * + * The browser sends more than one XSRF-TOKEN cookie when a Laravel app on a + * parent domain also sets one (for example SESSION_DOMAIN=.example.com). Their + * order in the header does not show which one is Coolify's, so the caller must + * try each one. + * + * @param {string | undefined} cookieHeader + * @returns {Array<{ 'Cookie': string, 'X-XSRF-TOKEN': string }>} empty when a required value is missing + */ +export function getTerminalAuthHeaders(cookieHeader) { + if (!cookieHeader) { + return []; } - return { - 'Cookie': cookieHeader, - 'X-XSRF-TOKEN': xsrfToken, - }; + // parseCookie() keeps only the first cookie of a name, so parse each pair alone. + const xsrfTokens = cookieHeader + .split(';') + .map((pair) => parseCookie(pair)['XSRF-TOKEN']) + .filter(Boolean); + + return [...new Set(xsrfTokens)] + .slice(0, MAX_XSRF_TOKEN_CANDIDATES) + .map((xsrfToken) => ({ + 'Cookie': cookieHeader, + 'X-XSRF-TOKEN': xsrfToken, + })); } /** * Authenticates a terminal WebSocket upgrade with the browser's Laravel session - * cookie and XSRF token. + * cookie and XSRF token. Returns the headers that Coolify accepted, for the + * requests that follow on the same connection. * - * @param {{ 'Cookie': string, 'X-XSRF-TOKEN': string } | null} authHeaders from getTerminalAuthHeaders() + * @param {Array<{ 'Cookie': string, 'X-XSRF-TOKEN': string }>} authHeaderCandidates from getTerminalAuthHeaders() * @param {(path: string, headers: object) => Promise<{ status: number }>} postToCoolify - * @returns {Promise<{ authenticated: true } | { authenticated: false, closeCode: number, reason: string, status?: number, error?: Error }>} + * @returns {Promise<{ authenticated: true, authHeaders: object } | { authenticated: false, closeCode: number, reason: string, status?: number, error?: Error }>} */ -export async function authenticateTerminalUpgrade(authHeaders, postToCoolify) { - if (!authHeaders) { +export async function authenticateTerminalUpgrade(authHeaderCandidates, postToCoolify) { + if (authHeaderCandidates.length === 0) { return { authenticated: false, closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, @@ -50,19 +71,18 @@ export async function authenticateTerminalUpgrade(authHeaders, postToCoolify) { }; } + let status; + try { - const response = await postToCoolify('/terminal/auth', authHeaders); + for (const authHeaders of authHeaderCandidates) { + const response = await postToCoolify('/terminal/auth', authHeaders); - if (response.status === 200) { - return { authenticated: true }; + if (response.status === 200) { + return { authenticated: true, authHeaders }; + } + + status = response.status; } - - return { - authenticated: false, - closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, - reason: 'Unauthorized: Invalid credentials', - status: response.status, - }; } catch (error) { return { authenticated: false, @@ -71,6 +91,13 @@ export async function authenticateTerminalUpgrade(authHeaders, postToCoolify) { error, }; } + + return { + authenticated: false, + closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, + reason: 'Unauthorized: Invalid credentials', + status, + }; } /** diff --git a/docker/coolify-terminal/terminal-utils.test.js b/docker/coolify-terminal/terminal-utils.test.js index 9f12e96b68..48f628707d 100644 --- a/docker/coolify-terminal/terminal-utils.test.js +++ b/docker/coolify-terminal/terminal-utils.test.js @@ -314,10 +314,24 @@ test('getTerminalAuthHeaders forwards the whole cookie header so Laravel reads i // the terminal server must not guess that name (issue #10549). const cookieHeader = 'theme=dark; coolify_prod_session=session-value; XSRF-TOKEN=xsrf-value'; - assert.deepEqual(getTerminalAuthHeaders(cookieHeader, 'xsrf-value'), { + assert.deepEqual(getTerminalAuthHeaders(cookieHeader), [{ 'Cookie': cookieHeader, 'X-XSRF-TOKEN': 'xsrf-value', - }); + }]); +}); + +test('getTerminalAuthHeaders makes one candidate for each XSRF-TOKEN cookie', () => { + // A Laravel app on a sibling subdomain with SESSION_DOMAIN=.example.com sets a + // second XSRF-TOKEN cookie that the browser also sends to Coolify (issue #10397). + const cookieHeader = 'XSRF-TOKEN=sibling%3D; coolify_session=session; XSRF-TOKEN=coolify%3D; XSRF-TOKEN=sibling%3D'; + + assert.deepEqual(getTerminalAuthHeaders(cookieHeader).map((headers) => headers['X-XSRF-TOKEN']), ['sibling=', 'coolify=']); +}); + +test('getTerminalAuthHeaders limits the number of XSRF-TOKEN candidates', () => { + const cookieHeader = Array.from({ length: 50 }, (_, index) => `XSRF-TOKEN=token-${index}`).join('; '); + + assert.deepEqual(getTerminalAuthHeaders(cookieHeader).map((headers) => headers['X-XSRF-TOKEN']), ['token-0', 'token-1', 'token-2', 'token-3', 'token-4']); }); test('authenticateTerminalUpgrade rejects missing cookies with the auth close code without calling Coolify', async () => { @@ -327,11 +341,8 @@ test('authenticateTerminalUpgrade rejects missing cookies with the auth close co return { status: 200 }; }; - for (const authHeaders of [ - getTerminalAuthHeaders('', 'xsrf'), - getTerminalAuthHeaders('coolify_session=session', undefined), - ]) { - assert.deepEqual(await authenticateTerminalUpgrade(authHeaders, postToCoolify), { + for (const cookieHeader of ['', undefined, 'coolify_session=session', 'coolify_session=session; XSRF-TOKEN=']) { + assert.deepEqual(await authenticateTerminalUpgrade(getTerminalAuthHeaders(cookieHeader), postToCoolify), { authenticated: false, closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, reason: 'Unauthorized: Missing required tokens', @@ -346,36 +357,50 @@ test('authenticateTerminalUpgrade rejects missing cookies with the auth close co test('authenticateTerminalUpgrade checks the Laravel session exactly like the previous verifyClient', async () => { const requests = []; const result = await authenticateTerminalUpgrade( - getTerminalAuthHeaders('coolify_session=session-value; XSRF-TOKEN=xsrf-value', 'xsrf-value'), + getTerminalAuthHeaders('coolify_session=session-value; XSRF-TOKEN=xsrf-value'), async (path, headers) => { requests.push([path, headers]); return { status: 200 }; }, ); - assert.deepEqual(result, { authenticated: true }); - assert.deepEqual(requests, [[ - '/terminal/auth', - { 'Cookie': 'coolify_session=session-value; XSRF-TOKEN=xsrf-value', 'X-XSRF-TOKEN': 'xsrf-value' }, - ]]); + const authHeaders = { 'Cookie': 'coolify_session=session-value; XSRF-TOKEN=xsrf-value', 'X-XSRF-TOKEN': 'xsrf-value' }; + assert.deepEqual(result, { authenticated: true, authHeaders }); + assert.deepEqual(requests, [['/terminal/auth', authHeaders]]); +}); + +test('authenticateTerminalUpgrade tries the next XSRF-TOKEN when Coolify rejects one', async () => { + const triedTokens = []; + const result = await authenticateTerminalUpgrade( + getTerminalAuthHeaders('XSRF-TOKEN=sibling; coolify_session=session; XSRF-TOKEN=coolify; XSRF-TOKEN=other'), + async (path, headers) => { + triedTokens.push(headers['X-XSRF-TOKEN']); + return { status: headers['X-XSRF-TOKEN'] === 'coolify' ? 200 : 419 }; + }, + ); + + assert.equal(result.authenticated, true); + assert.equal(result.authHeaders['X-XSRF-TOKEN'], 'coolify'); + assert.deepEqual(triedTokens, ['sibling', 'coolify']); }); test('authenticateTerminalUpgrade rejects every non-200 Coolify response', async () => { for (const status of [201, 204, 302, 401, 403, 419, 500]) { const result = await authenticateTerminalUpgrade( - getTerminalAuthHeaders('coolify_session=session; XSRF-TOKEN=xsrf', 'xsrf'), + getTerminalAuthHeaders('coolify_session=session; XSRF-TOKEN=xsrf; XSRF-TOKEN=other'), async () => ({ status }), ); assert.equal(result.authenticated, false, `status ${status} must not authenticate`); assert.equal(result.closeCode, TERMINAL_CLOSE_CODES.AUTH_REJECTED); assert.equal(result.reason, 'Unauthorized: Invalid credentials'); + assert.equal(result.status, status); } }); test('authenticateTerminalUpgrade does not authenticate when Coolify is unreachable', async () => { const result = await authenticateTerminalUpgrade( - getTerminalAuthHeaders('coolify_session=session; XSRF-TOKEN=xsrf', 'xsrf'), + getTerminalAuthHeaders('coolify_session=session; XSRF-TOKEN=xsrf'), async () => { throw new Error('connect ECONNREFUSED'); },