fix: authenticate terminal WebSockets synchronously on Bun

This commit is contained in:
Bohdan Triapitsyn
2026-09-03 11:49:34 +03:00
parent e885afbe89
commit dfb3c614c8
4 changed files with 57 additions and 26 deletions
@@ -103,7 +103,7 @@ export const createRequestSecurityRuntime = (deps) => {
return { origins, hosts }; return { origins, hosts };
}; };
const isRequestOriginAllowed = async (req) => { const isRequestOriginAllowed = (req) => {
const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : ''; const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : '';
if (!originHeader) { if (!originHeader) {
return false; return false;
@@ -120,15 +120,21 @@ export const createRequestSecurityRuntime = (deps) => {
return false; return false;
} }
const candidates = await getRequestOriginCandidates(req); const forwardedHostHeader = req.headers['x-forwarded-host'];
if (candidates.origins.has(origin.origin)) return true; const forwardedHost = (Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader || '')
.split(',')[0].trim().toLowerCase();
const hostHeader = req.headers.host;
const host = forwardedHost || (Array.isArray(hostHeader) ? hostHeader[0] : hostHeader || '').trim().toLowerCase();
if (host && host === origin.host.toLowerCase()) return true;
// TLS commonly ends at a cloud edge before an HTTP hop to OpenChamber. // TLS commonly ends at a cloud edge before an HTTP hop to OpenChamber.
// In that setup the browser's Origin is https while a generic reverse // In that setup the browser's Origin is https while a generic reverse
// proxy reports the upstream request as http. The external host remains // proxy reports the upstream request as http. The external host remains
// authoritative, so compare it directly instead of requiring the proxy to // authoritative, so compare it directly instead of requiring the proxy to
// preserve the browser-facing protocol. // preserve the browser-facing protocol.
return candidates.hosts.has(origin.host.toLowerCase()); return getRequestOriginCandidates(req).then((candidates) => (
candidates.origins.has(origin.origin) || candidates.hosts.has(origin.host.toLowerCase())
));
}; };
return { return {
@@ -9,55 +9,55 @@ describe('request security runtime', () => {
test('allows packaged client origins for remote client transports', async () => { test('allows packaged client origins for remote client transports', async () => {
const runtime = createRuntime(); const runtime = createRuntime();
await expect(runtime.isRequestOriginAllowed({ expect(await runtime.isRequestOriginAllowed({
headers: { headers: {
origin: 'openchamber-ui://app', origin: 'openchamber-ui://app',
host: '192.168.1.130:1202', host: '192.168.1.130:1202',
}, },
socket: {}, socket: {},
})).resolves.toBe(true); })).toBe(true);
await expect(runtime.isRequestOriginAllowed({ expect(await runtime.isRequestOriginAllowed({
headers: { headers: {
origin: 'capacitor://localhost', origin: 'capacitor://localhost',
host: '192.168.1.130:1202', host: '192.168.1.130:1202',
}, },
socket: {}, socket: {},
})).resolves.toBe(true); })).toBe(true);
// Android Capacitor WebView (androidScheme 'https') reports this origin. // Android Capacitor WebView (androidScheme 'https') reports this origin.
await expect(runtime.isRequestOriginAllowed({ expect(await runtime.isRequestOriginAllowed({
headers: { headers: {
origin: 'https://localhost', origin: 'https://localhost',
host: '192.168.1.130:1202', host: '192.168.1.130:1202',
}, },
socket: {}, socket: {},
})).resolves.toBe(true); })).toBe(true);
}); });
test('rejects unknown origins', async () => { test('rejects unknown origins', async () => {
const runtime = createRuntime(); const runtime = createRuntime();
await expect(runtime.isRequestOriginAllowed({ expect(await runtime.isRequestOriginAllowed({
headers: { headers: {
origin: 'https://evil.example.com', origin: 'https://evil.example.com',
host: '192.168.1.130:1202', host: '192.168.1.130:1202',
}, },
socket: {}, socket: {},
})).resolves.toBe(false); })).toBe(false);
}); });
test('allows the external host when TLS terminates before an HTTP proxy hop', async () => { test('allows the external host when TLS terminates before an HTTP proxy hop', async () => {
const runtime = createRuntime(); const runtime = createRuntime();
await expect(runtime.isRequestOriginAllowed({ expect(await runtime.isRequestOriginAllowed({
headers: { headers: {
origin: 'https://devchamber.example.com', origin: 'https://devchamber.example.com',
host: 'devchamber.example.com', host: 'devchamber.example.com',
'x-forwarded-proto': 'http', 'x-forwarded-proto': 'http',
}, },
socket: {}, socket: {},
})).resolves.toBe(true); })).toBe(true);
}); });
test('uses the forwarded external host without trusting a different origin', async () => { test('uses the forwarded external host without trusting a different origin', async () => {
@@ -71,13 +71,13 @@ describe('request security runtime', () => {
socket: {}, socket: {},
}; };
await expect(runtime.isRequestOriginAllowed({ expect(await runtime.isRequestOriginAllowed({
...request, ...request,
headers: { ...request.headers, origin: 'https://devchamber.example.com' }, headers: { ...request.headers, origin: 'https://devchamber.example.com' },
})).resolves.toBe(true); })).toBe(true);
await expect(runtime.isRequestOriginAllowed({ expect(await runtime.isRequestOriginAllowed({
...request, ...request,
headers: { ...request.headers, origin: 'https://evil.example.com' }, headers: { ...request.headers, origin: 'https://evil.example.com' },
})).resolves.toBe(false); })).toBe(false);
}); });
}); });
+30 -7
View File
@@ -276,16 +276,39 @@ export function createTerminalRuntime({
const upgradeHandler = (req, socket, head) => { const upgradeHandler = (req, socket, head) => {
if (parseRequestPathname(req.url) !== TERMINAL_WS_PATH) return; if (parseRequestPathname(req.url) !== TERMINAL_WS_PATH) return;
void (async () => { const accept = () => {
if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; }
try { try {
if (uiAuthController?.enabled) {
if (!await uiAuthController.ensureSessionToken(req, null)) { rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); return; }
if (!await isRequestOriginAllowed(req)) { rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); return; }
}
if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; }
wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req)); wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req));
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); } } catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
})(); };
const checkOrigin = () => {
try {
const result = isRequestOriginAllowed(req);
if (!(result instanceof Promise)) {
if (result) accept();
else rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
return;
}
void result.then((allowed) => {
if (allowed) accept();
else rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
}).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'));
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
};
if (!uiAuthController?.enabled) { accept(); return; }
try {
const result = uiAuthController.ensureSessionToken(req, null);
if (!(result instanceof Promise)) {
if (result) checkOrigin();
else rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
return;
}
void result.then((sessionToken) => {
if (sessionToken) checkOrigin();
else rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
}).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'));
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
}; };
server.on('upgrade', upgradeHandler); server.on('upgrade', upgradeHandler);
+3 -1
View File
@@ -984,7 +984,9 @@ export const createUiAuth = ({
handlePasskeyList, handlePasskeyList,
handlePasskeyRevoke, handlePasskeyRevoke,
handleResetAuth, handleResetAuth,
ensureSessionToken: async (req, _res) => { ensureSessionToken: (req, _res) => {
const urlAuth = authenticateUrlAuthToken(req);
if (urlAuth) return clientSessionToken(urlAuth);
return resolveAuthenticatedSessionToken(req); return resolveAuthenticatedSessionToken(req);
}, },
dispose, dispose,