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