feat(gateway): allow secured LAN console access
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user