Skip to content

Commit 116cf67

Browse files
ersinkocclaude
andcommitted
fix(security): WS https self-origins + prod origin warning (WS-001)
getGatewaySelfOrigins() only emitted http:// origins, so a gateway served over TLS on its own port rejected its own same-origin WebSocket upgrades. Add the https:// localhost/127.0.0.1 self-origins, and warn at startup when running in production with no WS_ALLOWED_ORIGINS/CORS_ORIGINS configured (the localhost-only fallback otherwise rejects all WS connections from a real domain). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5f547ff commit 116cf67

2 files changed

Lines changed: 52 additions & 1 deletion

File tree

packages/gateway/src/ws/server.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,29 @@ describe('WSGateway', () => {
422422
expect(socket.close).not.toHaveBeenCalled();
423423
});
424424

425+
it('allows the gateway https self-origin over TLS (WS-001)', async () => {
426+
const prevPort = process.env.PORT;
427+
process.env.PORT = '9443'; // self-origin derives from PORT
428+
try {
429+
const gw = new WSGateway({ allowedOrigins: [] });
430+
gw.start();
431+
432+
const handler = getConnectionHandler();
433+
const socket = createMockSocket();
434+
const request = createMockRequest('/', {
435+
origin: 'https://localhost:9443',
436+
});
437+
438+
await handler(socket, request);
439+
440+
expect(mockSessionManager.create).toHaveBeenCalled();
441+
expect(socket.close).not.toHaveBeenCalled();
442+
} finally {
443+
if (prevPort === undefined) delete process.env.PORT;
444+
else process.env.PORT = prevPort;
445+
}
446+
});
447+
425448
it('rejects when no origin header even with empty allowlist (CSWSH defense)', async () => {
426449
const gw = new WSGateway({ allowedOrigins: [] });
427450
gw.start();

packages/gateway/src/ws/server.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,16 +174,36 @@ const DEFAULT_LOCALHOST_WS_ORIGINS = [
174174
'http://127.0.0.1:4173',
175175
];
176176

177+
/**
178+
* The gateway's own origin(s), derived from PORT. A WebSocket upgrade whose
179+
* Origin matches the server that served the page is same-origin and can never
180+
* be a cross-site hijack, so these are always allowed — including when the
181+
* gateway serves the built UI on its own port (e.g. http://127.0.0.1:8200).
182+
* Production deployments behind a domain still need WS_ALLOWED_ORIGINS.
183+
*/
184+
function getGatewaySelfOrigins(): string[] {
185+
const port = process.env.PORT ?? String(WS_PORT);
186+
// WS-001: include https:// variants so a gateway served over TLS on its own
187+
// port still recognises its same-origin (the http:// only list rejected it).
188+
return [
189+
`http://localhost:${port}`,
190+
`http://127.0.0.1:${port}`,
191+
`https://localhost:${port}`,
192+
`https://127.0.0.1:${port}`,
193+
];
194+
}
195+
177196
/**
178197
* Validate WebSocket origin against allowed origins.
179198
* - Always requires the browser to send an `Origin` header (CSWSH defense).
180199
* - Empty configured allowlist falls back to localhost-only, never allow-all.
200+
* - The gateway's own same-origin is always permitted (UI served by gateway).
181201
*/
182202
function isOriginAllowed(origin: string | undefined, allowedOrigins: string[]): boolean {
183203
const effective = allowedOrigins.length > 0 ? allowedOrigins : DEFAULT_LOCALHOST_WS_ORIGINS;
184204
// No origin header — reject. Browsers always send Origin on WS upgrades.
185205
if (!origin) return false;
186-
return effective.some((allowed) => origin === allowed);
206+
return effective.includes(origin) || getGatewaySelfOrigins().includes(origin);
187207
}
188208

189209
/**
@@ -1348,5 +1368,13 @@ if (_rawWsOrigins && _rawWsOrigins.includes('*')) {
13481368
'[WARN] WS: origins env contains "*" — stripped. WS connections will use the configured non-wildcard origins (or localhost defaults).'
13491369
);
13501370
}
1371+
// WS-001: in production with no explicit allowlist, the gateway falls back to
1372+
// localhost-only origins, so WS upgrades from a real domain are rejected. Warn
1373+
// loudly rather than failing silently.
1374+
if (process.env.NODE_ENV === 'production' && _wsAllowedOrigins.length === 0) {
1375+
log.warn(
1376+
'[WARN] WS: no WS_ALLOWED_ORIGINS/CORS_ORIGINS set in production — WebSocket connections from your domain will be rejected (localhost-only fallback). Set WS_ALLOWED_ORIGINS to your site origin(s).'
1377+
);
1378+
}
13511379

13521380
export const wsGateway = new WSGateway({ allowedOrigins: _wsAllowedOrigins });

0 commit comments

Comments
 (0)