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
+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 {