diff --git a/packages/web/server/lib/security/request-security.js b/packages/web/server/lib/security/request-security.js index f61e2bef..37ab64f5 100644 --- a/packages/web/server/lib/security/request-security.js +++ b/packages/web/server/lib/security/request-security.js @@ -103,7 +103,7 @@ export const createRequestSecurityRuntime = (deps) => { return { origins, hosts }; }; - const isRequestOriginAllowed = async (req) => { + const isRequestOriginAllowed = (req) => { const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : ''; if (!originHeader) { return false; @@ -120,15 +120,21 @@ export const createRequestSecurityRuntime = (deps) => { return false; } - const candidates = await getRequestOriginCandidates(req); - if (candidates.origins.has(origin.origin)) return true; + const forwardedHostHeader = req.headers['x-forwarded-host']; + 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. // In that setup the browser's Origin is https while a generic reverse // proxy reports the upstream request as http. The external host remains // authoritative, so compare it directly instead of requiring the proxy to // 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 { diff --git a/packages/web/server/lib/security/request-security.test.js b/packages/web/server/lib/security/request-security.test.js index 05b6a1dd..c948bf36 100644 --- a/packages/web/server/lib/security/request-security.test.js +++ b/packages/web/server/lib/security/request-security.test.js @@ -9,55 +9,55 @@ describe('request security runtime', () => { test('allows packaged client origins for remote client transports', async () => { const runtime = createRuntime(); - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'openchamber-ui://app', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(true); + })).toBe(true); - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'capacitor://localhost', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(true); + })).toBe(true); // Android Capacitor WebView (androidScheme 'https') reports this origin. - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'https://localhost', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(true); + })).toBe(true); }); test('rejects unknown origins', async () => { const runtime = createRuntime(); - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'https://evil.example.com', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(false); + })).toBe(false); }); test('allows the external host when TLS terminates before an HTTP proxy hop', async () => { const runtime = createRuntime(); - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'https://devchamber.example.com', host: 'devchamber.example.com', 'x-forwarded-proto': 'http', }, socket: {}, - })).resolves.toBe(true); + })).toBe(true); }); test('uses the forwarded external host without trusting a different origin', async () => { @@ -71,13 +71,13 @@ describe('request security runtime', () => { socket: {}, }; - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ ...request, headers: { ...request.headers, origin: 'https://devchamber.example.com' }, - })).resolves.toBe(true); - await expect(runtime.isRequestOriginAllowed({ + })).toBe(true); + expect(await runtime.isRequestOriginAllowed({ ...request, headers: { ...request.headers, origin: 'https://evil.example.com' }, - })).resolves.toBe(false); + })).toBe(false); }); }); diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index d018b6fe..f8c7da28 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -276,16 +276,39 @@ export function createTerminalRuntime({ const upgradeHandler = (req, socket, head) => { if (parseRequestPathname(req.url) !== TERMINAL_WS_PATH) return; - void (async () => { + const accept = () => { + if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; } 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)); } 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); diff --git a/packages/web/server/lib/ui-auth/ui-auth.js b/packages/web/server/lib/ui-auth/ui-auth.js index ea3e0b99..aa61edde 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.js +++ b/packages/web/server/lib/ui-auth/ui-auth.js @@ -984,7 +984,9 @@ export const createUiAuth = ({ handlePasskeyList, handlePasskeyRevoke, handleResetAuth, - ensureSessionToken: async (req, _res) => { + ensureSessionToken: (req, _res) => { + const urlAuth = authenticateUrlAuthToken(req); + if (urlAuth) return clientSessionToken(urlAuth); return resolveAuthenticatedSessionToken(req); }, dispose,