fix: authenticate terminal WebSockets synchronously on Bun
This commit is contained in:
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user