mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Traffic analytics: - Reject enabling unless the server runs a Coolify-managed Traefik or Caddy proxy, and show the reason in the settings UI - Save the proxy configuration before the setting so a failure leaves both unchanged; allow disabling after the proxy was removed - Skip the proxy restart when the proxy is stopped and report that the config applies on next start - Preserve the user's own Traefik --accesslog* flags and restore them when analytics is disabled - Rotate the Traefik access log in the sidecar with BusyBox tools (copytruncate, 5 gzip rotations) - Create the access log before starting Sentinel, which opens it once Terminal: - Detect and surface WebSocket connection rejections in the browser terminal, with shared helpers in terminal-connection.js and terminal-utils.js Database import: - Restore PostgreSQL backups in a single transaction; SQL replace restores go into a temporary database and swap in only on success, leaving the current database untouched on failure
461 lines
19 KiB
JavaScript
461 lines
19 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
MAX_TERMINAL_SESSION_TIMEOUT_SECONDS,
|
|
TERMINAL_CLOSE_CODES,
|
|
authenticateTerminalUpgrade,
|
|
createTerminalUpgradeHandler,
|
|
extractSshArgs,
|
|
extractTargetHost,
|
|
getTerminalProcessEnv,
|
|
getTerminalSessionTimeout,
|
|
isAuthorizedTargetHost,
|
|
normalizeHostForAuthorization,
|
|
rejectTerminalSocket,
|
|
sanitizeSshArgs,
|
|
validateSshArgs,
|
|
} from './terminal-utils.js';
|
|
|
|
test('getTerminalProcessEnv preserves the PATH needed by SSH proxy commands', () => {
|
|
assert.deepEqual(getTerminalProcessEnv({
|
|
PATH: '/usr/local/bin:/usr/bin:/bin',
|
|
APP_KEY: 'must-not-be-inherited',
|
|
}), {
|
|
PATH: '/usr/local/bin:/usr/bin:/bin',
|
|
});
|
|
});
|
|
|
|
test('getTerminalProcessEnv uses the default PATH when PATH is absent', () => {
|
|
assert.deepEqual(getTerminalProcessEnv({
|
|
APP_KEY: 'must-not-be-inherited',
|
|
}), {
|
|
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
|
});
|
|
});
|
|
|
|
test('getTerminalProcessEnv uses the default PATH when PATH is empty', () => {
|
|
assert.deepEqual(getTerminalProcessEnv({
|
|
PATH: '',
|
|
APP_KEY: 'must-not-be-inherited',
|
|
}), {
|
|
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
|
});
|
|
});
|
|
|
|
test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.equal(extractTargetHost(sshArgs), '10.0.0.5');
|
|
});
|
|
|
|
test('extractSshArgs strips shell quotes from port and user host arguments before spawning ssh', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 3600 ssh -p '22' -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.deepEqual(sshArgs.slice(0, 5), ['-p', '22', '-o', 'StrictHostKeyChecking=no', 'root@10.0.0.5']);
|
|
});
|
|
|
|
test('extractSshArgs preserves proxy command as a single normalized ssh option value', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -o StrictHostKeyChecking=no 'root'@'example.com' 'bash -se' << \\\\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.equal(sshArgs[1], 'ProxyCommand=cloudflared access ssh --hostname %h');
|
|
assert.equal(sshArgs[4], 'root@example.com');
|
|
});
|
|
|
|
test('extractSshArgs supports the generated bash or sh fallback command', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 3600 ssh -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\\\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.equal(extractTargetHost(sshArgs), '10.0.0.5');
|
|
});
|
|
|
|
test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => {
|
|
assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true);
|
|
assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true);
|
|
});
|
|
|
|
test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
|
|
assert.equal(normalizeHostForAuthorization("'[2001:db8::10]'"), '2001:db8::10');
|
|
assert.equal(isAuthorizedTargetHost("'[2001:db8::10]'", ['2001:db8::10']), true);
|
|
});
|
|
|
|
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
|
|
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
|
|
});
|
|
|
|
test('validateSshArgs accepts the SSH arguments generated by Coolify', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p '22' 'root'@'10.0.0.5' 'bash -se' << \\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true);
|
|
});
|
|
|
|
test('validateSshArgs rejects an injected ProxyCommand', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 300 ssh -o 'ProxyCommand=/bin/busybox id >/tmp/marker' root@10.0.0.5 'bash -se' << \\ENDSSH\nENDSSH"
|
|
);
|
|
|
|
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false);
|
|
});
|
|
|
|
test('validateSshArgs accepts only the fixed Cloudflare ProxyCommand', () => {
|
|
const validArgs = extractSshArgs(
|
|
"timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@example.com 'bash -se' << \\$abc\necho hi\nabc"
|
|
);
|
|
const maliciousArgs = [...validArgs];
|
|
maliciousArgs[1] = 'ProxyCommand=cloudflared access ssh --hostname %h; id';
|
|
|
|
assert.equal(validateSshArgs(validArgs, ['example.com']), true);
|
|
assert.equal(validateSshArgs(maliciousArgs, ['example.com']), false);
|
|
});
|
|
|
|
test('validateSshArgs rejects unknown SSH options and key paths', () => {
|
|
const baseArgs = extractSshArgs(
|
|
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.equal(validateSshArgs(['-F', '/tmp/config', ...baseArgs], ['10.0.0.5']), false);
|
|
assert.equal(validateSshArgs(['-i', '/tmp/attacker-key', ...baseArgs.slice(2)], ['10.0.0.5']), false);
|
|
assert.equal(validateSshArgs(['-i', '/var/www/html/storage/app/ssh/keys/../ssh_key@victim', ...baseArgs.slice(2)], ['10.0.0.5']), false);
|
|
assert.equal(validateSshArgs(['-o', 'Include=/tmp/config', ...baseArgs], ['10.0.0.5']), false);
|
|
assert.equal(validateSshArgs(['-o', 'IdentityFile=/tmp/key', ...baseArgs], ['10.0.0.5']), false);
|
|
assert.equal(validateSshArgs(['-o', 'PermitLocalCommand=yes', ...baseArgs], ['10.0.0.5']), false);
|
|
assert.equal(validateSshArgs(['-o', 'LocalCommand=id', ...baseArgs], ['10.0.0.5']), false);
|
|
});
|
|
|
|
test('validateSshArgs rejects repeated flags and option-like connection values', () => {
|
|
const baseArgs = extractSshArgs(
|
|
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.equal(validateSshArgs(['-i', baseArgs[1], ...baseArgs], ['10.0.0.5']), false);
|
|
assert.equal(validateSshArgs([...baseArgs.slice(0, -1), '-p', '22', 'root@10.0.0.5'], ['10.0.0.5']), false);
|
|
assert.equal(validateSshArgs([...baseArgs.slice(0, -1), 'root@-oProxyCommand=id'], ['-oproxycommand=id']), false);
|
|
assert.equal(validateSshArgs([...baseArgs.slice(0, -1), '-root@10.0.0.5'], ['10.0.0.5']), false);
|
|
});
|
|
|
|
test('validateSshArgs rejects a destination that begins with an option prefix', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 -evil@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false);
|
|
});
|
|
|
|
test('sanitizeSshArgs removes SSH multiplexing options before spawning SSH', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 3600 ssh -o ControlMaster=auto -o ControlPath=/var/www/html/storage/app/ssh/mux/mux_cm123 -o ControlPersist=3600 -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true);
|
|
assert.deepEqual(sanitizeSshArgs(sshArgs), [
|
|
'-i',
|
|
'/var/www/html/storage/app/ssh/keys/ssh_key@cm123',
|
|
'-o',
|
|
'StrictHostKeyChecking=no',
|
|
'-o',
|
|
'UserKnownHostsFile=/dev/null',
|
|
'-o',
|
|
'PasswordAuthentication=no',
|
|
'-o',
|
|
'ConnectTimeout=10',
|
|
'-o',
|
|
'ServerAliveInterval=20',
|
|
'-o',
|
|
'RequestTTY=yes',
|
|
'-o',
|
|
'LogLevel=ERROR',
|
|
'-p',
|
|
'22',
|
|
'root@10.0.0.5',
|
|
]);
|
|
});
|
|
|
|
|
|
test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => {
|
|
assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
|
|
assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
|
|
assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
|
|
});
|
|
|
|
function createFakeSocket() {
|
|
const listeners = {};
|
|
|
|
return {
|
|
destroyed: false,
|
|
on(event, listener) {
|
|
(listeners[event] ??= []).push(listener);
|
|
},
|
|
removeListener(event, listener) {
|
|
listeners[event] = (listeners[event] ?? []).filter((candidate) => candidate !== listener);
|
|
},
|
|
emit(event, ...args) {
|
|
(listeners[event] ?? []).forEach((listener) => listener(...args));
|
|
},
|
|
destroy() {
|
|
this.destroyed = true;
|
|
},
|
|
listenerCount(event) {
|
|
return (listeners[event] ?? []).length;
|
|
},
|
|
};
|
|
}
|
|
|
|
function createFakeWebSocket() {
|
|
const closeListeners = [];
|
|
const errorListeners = [];
|
|
|
|
return {
|
|
events: [],
|
|
terminated: false,
|
|
on(event, listener) {
|
|
if (event === 'error') {
|
|
errorListeners.push(listener);
|
|
}
|
|
},
|
|
emitError(error) {
|
|
if (errorListeners.length === 0) {
|
|
throw error;
|
|
}
|
|
errorListeners.forEach((listener) => listener(error));
|
|
},
|
|
send(message) {
|
|
this.events.push(['send', message]);
|
|
},
|
|
close(code, reason) {
|
|
this.events.push(['close', code, reason]);
|
|
},
|
|
terminate() {
|
|
this.terminated = true;
|
|
},
|
|
once(event, listener) {
|
|
if (event === 'close') {
|
|
closeListeners.push(listener);
|
|
}
|
|
},
|
|
peerClosed() {
|
|
closeListeners.forEach((listener) => listener());
|
|
},
|
|
};
|
|
}
|
|
|
|
function createFakeWebSocketServer({ handles = true } = {}) {
|
|
return {
|
|
connections: [],
|
|
upgrades: [],
|
|
sockets: [],
|
|
shouldHandle() {
|
|
return handles;
|
|
},
|
|
handleUpgrade(req, socket, head, callback) {
|
|
this.upgrades.push(req);
|
|
if (!handles) {
|
|
return;
|
|
}
|
|
const ws = createFakeWebSocket();
|
|
this.sockets.push(ws);
|
|
callback(ws);
|
|
},
|
|
emit(event, ws, req) {
|
|
if (event === 'connection') {
|
|
this.connections.push([ws, req]);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
|
|
|
test('authenticateTerminalUpgrade rejects missing session cookies with the auth close code without calling Coolify', async () => {
|
|
let requests = 0;
|
|
const postToCoolify = async () => {
|
|
requests++;
|
|
return { status: 200 };
|
|
};
|
|
|
|
for (const session of [
|
|
{ sessionCookieName: 'coolify_session', laravelSession: undefined, xsrfToken: 'xsrf' },
|
|
{ sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: undefined },
|
|
]) {
|
|
assert.deepEqual(await authenticateTerminalUpgrade(session, postToCoolify), {
|
|
authenticated: false,
|
|
closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED,
|
|
reason: 'Unauthorized: Missing required tokens',
|
|
});
|
|
}
|
|
|
|
assert.equal(requests, 0);
|
|
assert.equal(TERMINAL_CLOSE_CODES.AUTH_REJECTED, 4401);
|
|
assert.equal(TERMINAL_CLOSE_CODES.TOKEN_REJECTED, 4403);
|
|
});
|
|
|
|
test('authenticateTerminalUpgrade checks the Laravel session exactly like the previous verifyClient', async () => {
|
|
const requests = [];
|
|
const result = await authenticateTerminalUpgrade(
|
|
{ sessionCookieName: 'coolify_session', laravelSession: 'session-value', xsrfToken: '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', 'X-XSRF-TOKEN': 'xsrf-value' },
|
|
]]);
|
|
});
|
|
|
|
test('authenticateTerminalUpgrade rejects every non-200 Coolify response', async () => {
|
|
for (const status of [201, 204, 302, 401, 403, 419, 500]) {
|
|
const result = await authenticateTerminalUpgrade(
|
|
{ sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: 'xsrf' },
|
|
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');
|
|
}
|
|
});
|
|
|
|
test('authenticateTerminalUpgrade does not authenticate when Coolify is unreachable', async () => {
|
|
const result = await authenticateTerminalUpgrade(
|
|
{ sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: 'xsrf' },
|
|
async () => {
|
|
throw new Error('connect ECONNREFUSED');
|
|
},
|
|
);
|
|
|
|
assert.equal(result.authenticated, false);
|
|
assert.equal(result.closeCode, TERMINAL_CLOSE_CODES.AUTH_UNAVAILABLE);
|
|
assert.equal(result.error.message, 'connect ECONNREFUSED');
|
|
});
|
|
|
|
test('rejectTerminalSocket sends the legacy message before closing with a readable code', () => {
|
|
const ws = createFakeWebSocket();
|
|
|
|
rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.TOKEN_REJECTED, 'Unauthorized: Terminal token was rejected', {
|
|
message: 'Unauthorized: Terminal token was rejected',
|
|
});
|
|
ws.peerClosed();
|
|
|
|
assert.deepEqual(ws.events, [
|
|
['send', 'Unauthorized: Terminal token was rejected'],
|
|
['close', 4403, 'Unauthorized: Terminal token was rejected'],
|
|
]);
|
|
});
|
|
|
|
test('rejectTerminalSocket terminates peers that ignore the close handshake', async () => {
|
|
const ws = createFakeWebSocket();
|
|
|
|
rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.AUTH_REJECTED, 'Unauthorized: Invalid credentials', { terminateAfterMs: 5 });
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
assert.deepEqual(ws.events, [['close', 4401, 'Unauthorized: Invalid credentials']]);
|
|
assert.equal(ws.terminated, true);
|
|
});
|
|
|
|
test('createTerminalUpgradeHandler only emits connection for authenticated upgrades', async () => {
|
|
const wss = createFakeWebSocketServer();
|
|
const req = { url: '/terminal/ws' };
|
|
const handler = createTerminalUpgradeHandler({ wss, authenticate: async () => ({ authenticated: true }) });
|
|
|
|
handler(req, createFakeSocket(), Buffer.alloc(0));
|
|
await flushPromises();
|
|
|
|
assert.equal(wss.connections.length, 1);
|
|
assert.equal(wss.connections[0][1], req);
|
|
assert.deepEqual(wss.sockets[0].events, []);
|
|
});
|
|
|
|
test('createTerminalUpgradeHandler closes rejected upgrades with the auth close code and never emits connection', async () => {
|
|
const wss = createFakeWebSocketServer();
|
|
const rejected = [];
|
|
const handler = createTerminalUpgradeHandler({
|
|
wss,
|
|
authenticate: async () => ({
|
|
authenticated: false,
|
|
closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED,
|
|
reason: 'Unauthorized: Invalid credentials',
|
|
}),
|
|
onRejected: (result) => rejected.push(result.closeCode),
|
|
});
|
|
|
|
handler({ url: '/terminal/ws' }, createFakeSocket(), Buffer.alloc(0));
|
|
await flushPromises();
|
|
|
|
assert.equal(wss.connections.length, 0);
|
|
assert.deepEqual(rejected, [4401]);
|
|
assert.deepEqual(wss.sockets[0].events, [['close', 4401, 'Unauthorized: Invalid credentials']]);
|
|
});
|
|
|
|
test('createTerminalUpgradeHandler treats authentication errors and malformed results as rejections', async () => {
|
|
for (const authenticate of [
|
|
async () => {
|
|
throw new Error('boom');
|
|
},
|
|
async () => ({ authenticated: 'yes', closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, reason: 'Unauthorized' }),
|
|
]) {
|
|
const wss = createFakeWebSocketServer();
|
|
createTerminalUpgradeHandler({ wss, authenticate })({ url: '/terminal/ws' }, createFakeSocket(), Buffer.alloc(0));
|
|
await flushPromises();
|
|
|
|
assert.equal(wss.connections.length, 0);
|
|
assert.equal(wss.sockets[0].events[0][0], 'close');
|
|
assert.notEqual(wss.sockets[0].events[0][1], 1000);
|
|
}
|
|
});
|
|
|
|
test('createTerminalUpgradeHandler does not authenticate requests for other paths', async () => {
|
|
const wss = createFakeWebSocketServer({ handles: false });
|
|
let authenticated = 0;
|
|
|
|
createTerminalUpgradeHandler({ wss, authenticate: async () => ++authenticated })({ url: '/other' }, createFakeSocket(), Buffer.alloc(0));
|
|
await flushPromises();
|
|
|
|
assert.equal(authenticated, 0);
|
|
assert.equal(wss.upgrades.length, 1);
|
|
assert.equal(wss.connections.length, 0);
|
|
});
|
|
|
|
test('createTerminalUpgradeHandler drops sockets that disconnect while authentication is pending', async () => {
|
|
const wss = createFakeWebSocketServer();
|
|
const socket = createFakeSocket();
|
|
let finishAuthentication;
|
|
|
|
createTerminalUpgradeHandler({
|
|
wss,
|
|
authenticate: () => new Promise((resolve) => {
|
|
finishAuthentication = resolve;
|
|
}),
|
|
})({ url: '/terminal/ws' }, socket, Buffer.alloc(0));
|
|
|
|
socket.emit('error', new Error('ECONNRESET'));
|
|
await flushPromises();
|
|
finishAuthentication({ authenticated: true });
|
|
await flushPromises();
|
|
|
|
assert.equal(socket.destroyed, true);
|
|
assert.equal(socket.listenerCount('error'), 0);
|
|
assert.equal(wss.upgrades.length, 0);
|
|
assert.equal(wss.connections.length, 0);
|
|
});
|
|
|
|
test('rejectTerminalSocket handles socket errors so unauthenticated clients cannot crash the server', () => {
|
|
const ws = createFakeWebSocket();
|
|
|
|
rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.AUTH_REJECTED, 'Unauthorized: Invalid credentials');
|
|
|
|
assert.doesNotThrow(() => ws.emitError(new Error('Invalid WebSocket frame: RSV1 must be clear')));
|
|
assert.equal(ws.terminated, true);
|
|
ws.peerClosed();
|
|
});
|