mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
5bc6fb08db
Guest /ws upgrades must pass Go access-link validation; RustDesk OIDC auth-query rejects omitted id/uuid when pending has them; bump tar and golang.org/x/text for audit/govulncheck.
252 lines
11 KiB
JavaScript
252 lines
11 KiB
JavaScript
/**
|
|
* Security tests for WebSocket relay upgrade handler.
|
|
*
|
|
* Verifies that /ws/rendezvous and /ws/relay reject unauthenticated requests
|
|
* and that only sessions with a valid userId are allowed to upgrade.
|
|
*
|
|
* These tests cover BD-2026-003 (wsRelay session validation bypass).
|
|
*/
|
|
|
|
const http = require('http');
|
|
const net = require('net');
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Helper: send a raw HTTP upgrade request to a server and collect the response
|
|
// Returns Promise<{ statusLine: string, headers: string, raw: string }>
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
function rawUpgrade(serverAddress, path, headers = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const { port } = serverAddress;
|
|
const socket = net.createConnection(port, '127.0.0.1');
|
|
let data = '';
|
|
|
|
socket.setTimeout(3000);
|
|
socket.on('timeout', () => { socket.destroy(); reject(new Error('timeout')); });
|
|
socket.on('error', reject);
|
|
|
|
socket.on('data', chunk => {
|
|
data += chunk.toString();
|
|
// Once we have the response line+headers, stop
|
|
if (data.includes('\r\n\r\n')) {
|
|
socket.destroy();
|
|
const [statusLine, ...rest] = data.split('\r\n');
|
|
resolve({ statusLine, headers: rest.join('\r\n'), raw: data });
|
|
}
|
|
});
|
|
|
|
socket.on('connect', () => {
|
|
const headerLines = Object.entries(headers)
|
|
.map(([k, v]) => `${k}: ${v}`)
|
|
.join('\r\n');
|
|
|
|
socket.write(
|
|
`GET ${path} HTTP/1.1\r\n` +
|
|
`Host: 127.0.0.1:${port}\r\n` +
|
|
`Upgrade: websocket\r\n` +
|
|
`Connection: Upgrade\r\n` +
|
|
`Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n` +
|
|
`Sec-WebSocket-Version: 13\r\n` +
|
|
(headerLines ? headerLines + '\r\n' : '') +
|
|
'\r\n'
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Build a minimal HTTP server wired with initWsProxy
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
function buildServer(sessionStub) {
|
|
const server = http.createServer((req, res) => {
|
|
res.writeHead(404); res.end();
|
|
});
|
|
|
|
const { initWsProxy } = require('../services/wsRelay');
|
|
initWsProxy(server, sessionStub);
|
|
return server;
|
|
}
|
|
|
|
describe('wsRelay — security: session validation on WS upgrade', () => {
|
|
let server;
|
|
let address;
|
|
|
|
afterEach(done => {
|
|
if (server && server.listening) {
|
|
// Force-close keep-alive or unhandled connections before calling done()
|
|
if (typeof server.closeAllConnections === 'function') {
|
|
server.closeAllConnections();
|
|
}
|
|
server.close(done);
|
|
} else {
|
|
done();
|
|
}
|
|
});
|
|
|
|
// Clears module cache so each test gets a fresh initWsProxy instance
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
});
|
|
|
|
// ── Test: unauthenticated request is rejected with 401 ────────────────────
|
|
test('rejects upgrade without a session (no cookie)', async () => {
|
|
const noSession = (req, _res, next) => {
|
|
// Simulate session store finding no session
|
|
req.session = null;
|
|
next();
|
|
};
|
|
|
|
const { initWsProxy } = require('../services/wsRelay');
|
|
server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
|
|
initWsProxy(server, noSession);
|
|
|
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
address = server.address();
|
|
|
|
const result = await rawUpgrade(address, '/ws/rendezvous');
|
|
expect(result.statusLine).toBe('HTTP/1.1 401 Unauthorized');
|
|
});
|
|
|
|
// ── Test: empty session (no userId) is rejected with 401 ─────────────────
|
|
test('rejects upgrade when session exists but has no userId', async () => {
|
|
const emptySession = (req, _res, next) => {
|
|
req.session = {}; // session populated, but no userId
|
|
next();
|
|
};
|
|
|
|
const { initWsProxy } = require('../services/wsRelay');
|
|
server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
|
|
initWsProxy(server, emptySession);
|
|
|
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
address = server.address();
|
|
|
|
const result = await rawUpgrade(address, '/ws/rendezvous');
|
|
expect(result.statusLine).toBe('HTTP/1.1 401 Unauthorized');
|
|
});
|
|
|
|
// ── Test: relay path also rejected without session ────────────────────────
|
|
test('rejects /ws/relay upgrade without userId', async () => {
|
|
const emptySession = (req, _res, next) => {
|
|
req.session = {};
|
|
next();
|
|
};
|
|
|
|
const { initWsProxy } = require('../services/wsRelay');
|
|
server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
|
|
initWsProxy(server, emptySession);
|
|
|
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
address = server.address();
|
|
|
|
const result = await rawUpgrade(address, '/ws/relay');
|
|
expect(result.statusLine).toBe('HTTP/1.1 401 Unauthorized');
|
|
});
|
|
|
|
// ── Test: missing sessionMiddleware returns 503 ───────────────────────────
|
|
test('returns 503 when sessionMiddleware is not provided', async () => {
|
|
const { initWsProxy } = require('../services/wsRelay');
|
|
server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
|
|
initWsProxy(server, undefined); // no middleware
|
|
|
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
address = server.address();
|
|
|
|
const result = await rawUpgrade(address, '/ws/rendezvous');
|
|
expect(result.statusLine).toBe('HTTP/1.1 503 Service Unavailable');
|
|
});
|
|
|
|
// ── Test: authenticated session is passed through (101 upgrade attempted) ─
|
|
test('allows upgrade when session has a valid userId', async () => {
|
|
// Middleware that simulates an authenticated session
|
|
const authSession = (req, _res, next) => {
|
|
req.session = { userId: 42, user: { username: 'admin', role: 'admin' } };
|
|
next();
|
|
};
|
|
|
|
const { initWsProxy } = require('../services/wsRelay');
|
|
server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
|
|
initWsProxy(server, authSession);
|
|
|
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
address = server.address();
|
|
|
|
// The upgrade will attempt to connect to the Go server TCP port which may
|
|
// be unavailable in CI. We expect either:
|
|
// 101 Switching Protocols — server running, upgrade accepted
|
|
// 502 / 503 / closed — backend not available (CI)
|
|
// What we must NOT see is 401 Unauthorized.
|
|
let statusLine = '';
|
|
try {
|
|
const result = await rawUpgrade(address, '/ws/rendezvous');
|
|
statusLine = result.statusLine;
|
|
} catch {
|
|
// Socket closed by server before sending a response = backend refused
|
|
// the TCP proxy target. That's fine — we just need to confirm auth passed.
|
|
statusLine = 'SOCKET_CLOSED';
|
|
}
|
|
|
|
expect(statusLine).not.toBe('HTTP/1.1 401 Unauthorized');
|
|
expect(statusLine).not.toBe('HTTP/1.1 503 Service Unavailable');
|
|
});
|
|
|
|
// ── Test: non-empty guest= without valid grant is rejected ───────────────
|
|
test('rejects upgrade when guest query is present but token is invalid', async () => {
|
|
jest.resetModules();
|
|
jest.doMock('../services/betterdeskApi', () => ({
|
|
apiClient: {
|
|
get: jest.fn().mockResolvedValue({ data: { valid: false, error: 'invalid' } }),
|
|
},
|
|
}));
|
|
|
|
const noSession = (req, _res, next) => {
|
|
req.session = null;
|
|
next();
|
|
};
|
|
|
|
const { initWsProxy } = require('../services/wsRelay');
|
|
server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
|
|
initWsProxy(server, noSession);
|
|
|
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
address = server.address();
|
|
|
|
const result = await rawUpgrade(address, '/ws/rendezvous?guest=not-a-real-token');
|
|
expect(result.statusLine).toBe('HTTP/1.1 401 Unauthorized');
|
|
});
|
|
|
|
// ── Test: non-ws-relay paths are not handled by initWsProxy ──────────────
|
|
test('does not intercept paths outside /ws/rendezvous and /ws/relay', () => {
|
|
const EventEmitter = require('events');
|
|
const { initWsProxy } = require('../services/wsRelay');
|
|
|
|
// Use an EventEmitter as a stand-in for an HTTP server — no TCP socket needed
|
|
const fakeServer = new EventEmitter();
|
|
|
|
const middlewareCalled = [];
|
|
const stubMiddleware = (req, _res, next) => {
|
|
middlewareCalled.push(req.url);
|
|
next();
|
|
};
|
|
|
|
initWsProxy(fakeServer, stubMiddleware);
|
|
|
|
// Simulate an upgrade event for a path that wsRelay does NOT own
|
|
const fakeSocket = { write: jest.fn(), destroy: jest.fn() };
|
|
const fakeRequest = {
|
|
url: '/ws/chat',
|
|
headers: { host: 'localhost:5000' },
|
|
socket: { remoteAddress: '127.0.0.1' }
|
|
};
|
|
|
|
fakeServer.emit('upgrade', fakeRequest, fakeSocket, Buffer.alloc(0));
|
|
|
|
// wsRelay returns early for non-owned paths — session middleware never called
|
|
expect(middlewareCalled).toHaveLength(0);
|
|
// No 401 or 503 written to socket for an unrelated path
|
|
expect(fakeSocket.write).not.toHaveBeenCalled();
|
|
|
|
// No server to close — no async cleanup needed
|
|
server = null;
|
|
});
|
|
});
|