feat(gateway): allow secured LAN console access

This commit is contained in:
chick
2026-07-18 19:47:23 +08:00
parent e390069edb
commit d575ab4d9d
4 changed files with 133 additions and 6 deletions
+57 -1
View File
@@ -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);
+56 -4
View File
@@ -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<string>): 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 {
+12 -1
View File
@@ -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,
});
}
+8
View File
@@ -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(