From d575ab4d9d7b735c1c598b28d6aee9ce6c803f1f Mon Sep 17 00:00:00 2001 From: chick Date: Sat, 18 Jul 2026 19:47:23 +0800 Subject: [PATCH] feat(gateway): allow secured LAN console access --- apps/api/src/canary-gateway.test.ts | 58 ++++++++++++++++++++++++++- apps/api/src/canary-gateway.ts | 60 ++++++++++++++++++++++++++-- apps/api/src/canary-runtime.ts | 13 +++++- apps/api/src/cutover-orchestrator.ts | 8 ++++ 4 files changed, 133 insertions(+), 6 deletions(-) diff --git a/apps/api/src/canary-gateway.test.ts b/apps/api/src/canary-gateway.test.ts index ed66564..cd93bb5 100644 --- a/apps/api/src/canary-gateway.test.ts +++ b/apps/api/src/canary-gateway.test.ts @@ -117,6 +117,61 @@ describe('same-origin canary gateway', () => { /loopback/i, ); } + expect(() => + createCanaryGateway({ + distDir: '/tmp/dist', + host: '0.0.0.0', + port: 8788, + ownsProductionPort: true, + allowedHosts: ['0.0.0.0'], + }), + ).toThrow(/invalid allowed gateway host/i); + expect(() => + createCanaryGateway({ + distDir: '/tmp/dist', + host: '0.0.0.0', + port: 8788, + ownsProductionPort: true, + allowedHosts: ['00.00.00.00'], + }), + ).toThrow(/invalid allowed gateway host/i); + }); + + it('allows an explicit production LAN bind with an exact Host allowlist', async () => { + const upstream = createServer((_request, response) => response.end('upstream reached')); + servers.push(upstream); + const gateway = createCanaryGateway({ + distDir: await fixtureDist(), + host: '0.0.0.0', + port: 0, + upstreamPort: await listen(upstream), + ownsProductionPort: true, + allowedHosts: ['192.168.2.69'], + }); + gateways.push(gateway); + await gateway.start(); + + const allowed = await rawRequest(gateway.origin, '/', { host: '192.168.2.69:0' }); + expect(allowed.status).toBe(200); + const denied = await rawRequest(gateway.origin, '/', { host: 'evil.test:0' }); + expect(denied.status).toBe(400); + const wildcardHost = await rawRequest(gateway.origin, '/', { host: '0.0.0.0:0' }); + expect(wildcardHost.status).toBe(400); + const wildcardOrigin = await rawRequest(gateway.origin, '/api/v1/instances', { + host: '192.168.2.69:0', + origin: 'http://0.0.0.0:0', + }); + expect(wildcardOrigin.status).toBe(403); + const foreignOrigin = await rawRequest(gateway.origin, '/api/v1/instances', { + host: '192.168.2.69:0', + origin: 'https://evil.example', + }); + expect(foreignOrigin.status).toBe(403); + const allowedOrigin = await rawRequest(gateway.origin, '/api/v1/instances', { + host: '192.168.2.69:0', + origin: 'http://192.168.2.69:0', + }); + expect(allowedOrigin.status).toBe(200); }); it('rejects missing, malformed, and foreign authority before serving or proxying', async () => { @@ -253,9 +308,10 @@ describe('same-origin canary gateway', () => { '/healthz', '/readyz', ]); + const upstreamOrigin = `http://127.0.0.1:${upstreamPort}`; expect( seen.every( - ({ host, origin }) => host === new URL(browserOrigin).host && origin === browserOrigin, + ({ host, origin }) => host === `127.0.0.1:${upstreamPort}` && origin === upstreamOrigin, ), ).toBe(true); expect(seen.every(({ custom }) => custom === undefined)).toBe(true); diff --git a/apps/api/src/canary-gateway.ts b/apps/api/src/canary-gateway.ts index f815755..42085f7 100644 --- a/apps/api/src/canary-gateway.ts +++ b/apps/api/src/canary-gateway.ts @@ -60,6 +60,7 @@ export interface CanaryGatewayOptions { readonly gatewayToken?: string; readonly distDir: string; readonly host?: string; + readonly allowedHosts?: readonly string[]; readonly port?: number; readonly upstreamHost?: string; readonly upstreamPort?: number; @@ -95,13 +96,34 @@ function withoutHopByHop(headers: IncomingHttpHeaders): IncomingHttpHeaders { function trustedUpstreamHeaders( headers: IncomingHttpHeaders, gatewayToken: string | undefined, + upstreamHost: string, + upstreamPort: number, ): IncomingHttpHeaders { const sanitized = withoutHopByHop(headers); + sanitized.host = `${upstreamHost}:${upstreamPort}`; + if (sanitized.origin !== undefined) sanitized.origin = `http://${upstreamHost}:${upstreamPort}`; delete sanitized[GATEWAY_AUTH_HEADER]; if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken; return sanitized; } +function isAllowedBrowserOrigin(origin: string, allowedAuthorities: ReadonlySet): boolean { + try { + const parsed = new URL(origin); + return ( + parsed.protocol === 'http:' && + parsed.username === '' && + parsed.password === '' && + parsed.pathname === '/' && + parsed.search === '' && + parsed.hash === '' && + allowedAuthorities.has(parsed.host.toLowerCase()) + ); + } catch { + return false; + } +} + function isProxyPath(pathname: string): boolean { return pathname === '/healthz' || pathname === '/readyz' || pathname.startsWith('/api/v1/'); } @@ -138,7 +160,7 @@ async function proxyRequest( port: upstreamPort, method: incoming.method, path: incoming.url, - headers: trustedUpstreamHeaders(incoming.headers, gatewayToken), + headers: trustedUpstreamHeaders(incoming.headers, gatewayToken, upstreamHost, upstreamPort), }, (upstreamResponse) => { response.writeHead( @@ -197,8 +219,8 @@ export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGatewa const port = options.port ?? CANARY_DEFAULT_PORT; const upstreamHost = options.upstreamHost ?? CANARY_DEFAULT_UPSTREAM_HOST; const upstreamPort = options.upstreamPort ?? CANARY_DEFAULT_UPSTREAM_PORT; - if (!isNumericLoopback(host)) { - throw new Error('Canary gateway must bind to a loopback host'); + if (!isNumericLoopback(host) && !(options.ownsProductionPort && host === '0.0.0.0')) { + throw new Error('Canary gateway must bind to loopback; production may explicitly bind 0.0.0.0'); } if (!isNumericLoopback(upstreamHost)) { throw new Error('Canary upstream must use a loopback host'); @@ -207,6 +229,22 @@ export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGatewa if (RESERVED_PORTS.has(port) && !(port === PRODUCTION_GATEWAY_PORT && options.ownsProductionPort)) throw new Error(`Port ${port} is reserved and cannot run the canary`); if (!options.distDir) throw new Error('distDir is required'); + const allowedAuthorities = new Set( + options.allowedHosts?.map((allowedHost) => { + const ipv4Octets = /^\d+(?:\.\d+){3}$/u.test(allowedHost) + ? allowedHost.split('.').map(Number) + : undefined; + if ( + allowedHost === '' || + allowedHost.includes(':') || + (ipv4Octets !== undefined && + (ipv4Octets.some((octet) => octet > 255) || ipv4Octets.every((octet) => octet === 0))) || + !/^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\d{1,3}(?:\.\d{1,3}){3})$/iu.test(allowedHost) + ) + throw new Error('Invalid allowed gateway host'); + return `${allowedHost.toLowerCase()}:${port}`; + }), + ); let server: Server | undefined; let activeOrigin = ''; @@ -217,10 +255,24 @@ export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGatewa return sendText(response, 400, 'Bad Request'); } const expectedAuthority = activeOrigin ? new URL(activeOrigin).host : ''; + const acceptedAuthorities = + host === '0.0.0.0' ? allowedAuthorities : new Set([expectedAuthority, ...allowedAuthorities]); const hostHeaders = request.headersDistinct.host; - if (hostHeaders?.length !== 1 || hostHeaders[0] !== expectedAuthority) { + const requestAuthority = hostHeaders?.[0]?.toLowerCase(); + if ( + hostHeaders?.length !== 1 || + requestAuthority === undefined || + !acceptedAuthorities.has(requestAuthority) + ) { return sendText(response, 400, 'Bad Request'); } + const incomingOrigin = request.headers.origin; + if ( + incomingOrigin !== undefined && + !isAllowedBrowserOrigin(incomingOrigin, acceptedAuthorities) + ) { + return sendText(response, 403, 'Forbidden'); + } const rawPathname = request.url.split('?', 1)[0] ?? ''; let unnormalizedPathname: string; try { diff --git a/apps/api/src/canary-runtime.ts b/apps/api/src/canary-runtime.ts index 60af977..09be15a 100644 --- a/apps/api/src/canary-runtime.ts +++ b/apps/api/src/canary-runtime.ts @@ -9,6 +9,8 @@ export interface CanaryRuntimeEnvironment { readonly CANARY_DIST_DIR?: string; readonly CANARY_PORT?: string; readonly CANARY_UPSTREAM_PORT?: string; + readonly MULTI_SIMADMIN_GATEWAY_HOST?: string; + readonly MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS?: string; readonly MULTI_SIMADMIN_GATEWAY_TOKEN?: string; readonly MULTI_SIMADMIN_CUTOVER_ACK?: string; } @@ -54,5 +56,14 @@ export function readProductionGatewayRuntimeOptions( ); } const options = readCanaryRuntimeOptions({ ...environment, CANARY_PORT: '8788' }); - return Object.freeze({ ...options, ownsProductionPort: true }); + const host = environment.MULTI_SIMADMIN_GATEWAY_HOST ?? options.host; + const allowedHosts = environment.MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS?.split(',') + .map((value) => value.trim()) + .filter(Boolean); + return Object.freeze({ + ...options, + ...(host === undefined ? {} : { host }), + ...(allowedHosts === undefined ? {} : { allowedHosts }), + ownsProductionPort: true, + }); } diff --git a/apps/api/src/cutover-orchestrator.ts b/apps/api/src/cutover-orchestrator.ts index 36385bf..8f466d4 100644 --- a/apps/api/src/cutover-orchestrator.ts +++ b/apps/api/src/cutover-orchestrator.ts @@ -417,6 +417,14 @@ export async function cutover( CANARY_DIST_DIR: plan.assetDir, MULTI_SIMADMIN_CUTOVER_ACK: PRODUCTION_CUTOVER_ACK, MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken, + ...(process.env.MULTI_SIMADMIN_GATEWAY_HOST + ? { MULTI_SIMADMIN_GATEWAY_HOST: process.env.MULTI_SIMADMIN_GATEWAY_HOST } + : {}), + ...(process.env.MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS + ? { + MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS: process.env.MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS, + } + : {}), }); if (!(await waitForGateway(system, gatewayPid, gatewayToken))) throw new Error(