mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 09:34:36 +08:00
fix(terminal): try every XSRF-TOKEN cookie when authenticating
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. The terminal server forwarded only the first one, so Laravel answered 419 and the terminal closed with 4401. Try each distinct token (at most five) and keep the accepted one for the requests that follow on the connection. Closes #10397 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
90e7533a65
commit
086dbb8b1e
3 files changed
+102
-54
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user