Files
BetterDesk/web-nodejs/tests/wsRelay.security.test.js
UNITRONIX abc00b8804 feat(guest): enforce scoped guest access lifecycle
Bind guest WebSocket sessions to a single target peer, enforce grant lifecycle and atomic usage, add the guest issuer scope, and document the password-less security boundary.\n\nRefs #440\n\nThanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
2026-10-03 10:22:20 +02:00

303 lines
13 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('rejects rendezvous upgrade for a viewer', async () => {
const viewerSession = (req, _res, next) => {
req.session = { userId: 43, user: { username: 'viewer', role: 'viewer' } };
next();
};
const { initWsProxy } = require('../services/wsRelay');
server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
initWsProxy(server, viewerSession);
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 403 Forbidden');
});
// ── 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&peer_id=TARGET01&guest_session=test-session'
);
expect(result.statusLine).toBe('HTTP/1.1 401 Unauthorized');
});
test('rejects guest upgrade without a target binding', async () => {
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=valid-token');
expect(result.statusLine).toBe('HTTP/1.1 403 Forbidden');
});
test('does not trust a guest page when the protobuf target changes', () => {
const protobuf = require('protobufjs');
const root = protobuf.loadSync(require('path').join(__dirname, '../protos/rendezvous.proto'));
const type = root.lookupType('hbb.RendezvousMessage');
const payload = type.encode(type.create({
punchHoleRequest: { id: 'OTHER01' },
})).finish();
const { _guestProtocol } = require('../services/wsRelay');
const message = _guestProtocol.decodeRendezvousPayload(payload);
expect(_guestProtocol.guestTargetMatches(message, 'TARGET01')).toBe(false);
expect(_guestProtocol.guestTargetMatches(message, 'OTHER01')).toBe(true);
});
// ── 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;
});
});