mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(webview): refuse link-local and cloud-metadata targets on the resolved address
The web-tab proxy, its Test probe and its WebSocket relay accepted any http(s) host. A live PoC relayed an IMDSv2-shaped PUT with custom headers to a loopback echo server through a capability and no cookie, and 169.254.169.254 (decimal, hex, IPv6-mapped, or via a DNS name) was as valid a dashboard as any other. Loopback and RFC1918 stay allowed on purpose: a localhost Grafana is the feature. Only link-local and the fixed cloud-metadata addresses are refused (169.254.0.0/16, fe80::/10, fd00:ec2::254, 168.63.129.16, 100.100.100.200, metadata.google.internal), at three stages that are each load-bearing: - the Zod schema, so a save gets a clear refusal; - a synchronous hostname check at every connect site, because net.connect skips DNS for an IP literal and a lookup hook never sees one; - a `lookup` hook on an undici Agent (webviewFetch) and on the ws client, which judges the RESOLVED addresses of a name and refuses when any is blocked. This is what closes DNS rebinding, which a hostname-string check cannot. Adds undici@^6 so the proxy runs the package's own fetch with the package's own Agent; a package Agent handed to Node's bundled fetch can mismatch protocols. Verified live on an isolated beta: 169.254.169.254.nip.io (a real name resolving to the metadata address) is refused by probe, proxy (403) and WS relay (4003), while 127.0.0.1.nip.io still passes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WKtW48T1UjAaecHAJxKobE
This commit is contained in:
Generated
+10
@@ -32,6 +32,7 @@
|
|||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"undici": "^6.28.0",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
@@ -11550,6 +11551,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "6.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||||
|
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
|||||||
@@ -102,6 +102,7 @@
|
|||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"undici": "^6.28.0",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { WebSocket as WsClient } from 'ws';
|
import { WebSocket as WsClient } from 'ws';
|
||||||
import type { WebSocket } from 'ws';
|
import type { ClientOptions as WsClientOptions, WebSocket } from 'ws';
|
||||||
|
import type { Response as UndiciResponse } from 'undici';
|
||||||
import { getDataDir } from '../../config/instance.js';
|
import { getDataDir } from '../../config/instance.js';
|
||||||
import {
|
import {
|
||||||
MAX_LIVE_WEBVIEW_FRAMES,
|
MAX_LIVE_WEBVIEW_FRAMES,
|
||||||
@@ -47,6 +48,8 @@ import {
|
|||||||
} from '../../config/webview-limits.js';
|
} from '../../config/webview-limits.js';
|
||||||
import { readWebviews, writeWebviews } from '../../webview-store.js';
|
import { readWebviews, writeWebviews } from '../../webview-store.js';
|
||||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||||
|
import { egressBlockedReason, webviewEgressLookup, webviewFetch, type EgressLookup } from '../webview-egress.js';
|
||||||
|
import { blockedWebviewHostReason } from '../webview-egress-policy.js';
|
||||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||||
import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js';
|
import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js';
|
||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
@@ -293,7 +296,7 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(target.href, {
|
const response = await webviewFetch(target, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS),
|
signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS),
|
||||||
@@ -326,6 +329,12 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
|
|||||||
reason,
|
reason,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const blocked = egressBlockedReason(err);
|
||||||
|
if (blocked) {
|
||||||
|
// Refused by policy, not unreachable: say so, or the user reads it as a
|
||||||
|
// network problem and starts debugging their firewall.
|
||||||
|
return { reachable: false, framable: false, recommendedMode: 'proxy', reason: blocked };
|
||||||
|
}
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return {
|
return {
|
||||||
reachable: false,
|
reachable: false,
|
||||||
@@ -476,19 +485,19 @@ async function proxyRequest(
|
|||||||
// string (it can carry the dashboard's tokens).
|
// string (it can carry the dashboard's tokens).
|
||||||
const logTarget = `${req.method} ${upstream.origin}${upstream.pathname}`;
|
const logTarget = `${req.method} ${upstream.origin}${upstream.pathname}`;
|
||||||
|
|
||||||
let response: Response;
|
let response: UndiciResponse;
|
||||||
try {
|
try {
|
||||||
response = await fetch(upstream.href, {
|
response = await webviewFetch(upstream, {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers,
|
headers,
|
||||||
body: hasBody ? (req.body as Readable) : undefined,
|
body: hasBody ? (req.body as Readable) : undefined,
|
||||||
// Required by undici whenever the body is a stream.
|
// Required by undici whenever the body is a stream.
|
||||||
...(hasBody ? { duplex: 'half' } : {}),
|
...(hasBody ? { duplex: 'half' as const } : {}),
|
||||||
// Redirects are rewritten into the proxy prefix instead of followed, so the
|
// Redirects are rewritten into the proxy prefix instead of followed, so the
|
||||||
// browser's URL stays inside the frame and relative assets keep resolving.
|
// browser's URL stays inside the frame and relative assets keep resolving.
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
} as RequestInit);
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsed = Date.now() - startedAt;
|
const elapsed = Date.now() - startedAt;
|
||||||
if (clientGone) {
|
if (clientGone) {
|
||||||
@@ -496,6 +505,13 @@ async function proxyRequest(
|
|||||||
// failure, so no warn (it would read as the dashboard being broken).
|
// failure, so no warn (it would read as the dashboard being broken).
|
||||||
return reply;
|
return reply;
|
||||||
}
|
}
|
||||||
|
const blocked = egressBlockedReason(err);
|
||||||
|
if (blocked) {
|
||||||
|
// Policy refusal, distinct from "unreachable": a record saved before the
|
||||||
|
// egress rule existed, or a name that now resolves into a blocked range.
|
||||||
|
console.warn(`[Webview] refused by egress policy: ${logTarget} (webview "${webview.name}"): ${blocked}`);
|
||||||
|
return reply.code(403).type('text/plain').send(`Forbidden: ${blocked}`);
|
||||||
|
}
|
||||||
if (headerTimedOut) {
|
if (headerTimedOut) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[Webview] upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms: ` +
|
`[Webview] upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms: ` +
|
||||||
@@ -644,6 +660,13 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An IP literal never reaches the lookup hook (net.connect skips DNS for it),
|
||||||
|
// so the literal form is judged here and the resolved form in the lookup.
|
||||||
|
if (blockedWebviewHostReason(upstream.hostname)) {
|
||||||
|
socket.close(4003, 'Forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
socketCounts.set(webview.id, live + 1);
|
socketCounts.set(webview.id, live + 1);
|
||||||
let released = false;
|
let released = false;
|
||||||
const release = () => {
|
const release = () => {
|
||||||
@@ -655,16 +678,21 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
|||||||
};
|
};
|
||||||
|
|
||||||
const protocols = req.headers['sec-websocket-protocol'];
|
const protocols = req.headers['sec-websocket-protocol'];
|
||||||
|
// `lookup` is absent from ws's ClientOptions typings but flows through
|
||||||
|
// http.request to net.connect untouched, which is where the resolved
|
||||||
|
// address is judged (see webview-egress.ts).
|
||||||
|
const upstreamOptions: WsClientOptions & { lookup: EgressLookup } = {
|
||||||
|
headers: {
|
||||||
|
origin: upstream.origin,
|
||||||
|
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||||
|
},
|
||||||
|
handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
||||||
|
lookup: webviewEgressLookup,
|
||||||
|
};
|
||||||
const upstreamSocket = new WsClient(
|
const upstreamSocket = new WsClient(
|
||||||
upstreamWebSocketUrl(upstream),
|
upstreamWebSocketUrl(upstream),
|
||||||
protocols ? String(protocols).split(/,\s*/) : [],
|
protocols ? String(protocols).split(/,\s*/) : [],
|
||||||
{
|
upstreamOptions
|
||||||
headers: {
|
|
||||||
origin: upstream.origin,
|
|
||||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
|
||||||
},
|
|
||||||
handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Buffer anything the browser sends before the upstream handshake completes,
|
// Buffer anything the browser sends before the upstream handshake completes,
|
||||||
@@ -703,9 +731,14 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa
|
|||||||
socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||||
upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||||
socket.on('error', () => closeBoth());
|
socket.on('error', () => closeBoth());
|
||||||
upstreamSocket.on('error', () => {
|
upstreamSocket.on('error', (err: Error) => {
|
||||||
release();
|
release();
|
||||||
if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error');
|
if (socket.readyState !== socket.OPEN) return;
|
||||||
|
// A name that resolved into a blocked range fails inside the connect, so it
|
||||||
|
// surfaces here rather than at the sync check above; report it as the same
|
||||||
|
// policy refusal, not as the dashboard being broken.
|
||||||
|
if (egressBlockedReason(err)) socket.close(4003, 'Forbidden');
|
||||||
|
else socket.close(1011, 'Upstream error');
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
||||||
import { isValidWebviewUrl } from './webview-proxy.js';
|
import { isValidWebviewUrl } from './webview-proxy.js';
|
||||||
|
import { isBlockedWebviewUrl } from './webview-egress-policy.js';
|
||||||
import {
|
import {
|
||||||
MAX_TERMINAL_BUFFER_BYTES,
|
MAX_TERMINAL_BUFFER_BYTES,
|
||||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||||
@@ -1762,6 +1763,13 @@ const webviewUrlSchema = z
|
|||||||
.max(2000, 'URL too long (max 2000 chars)')
|
.max(2000, 'URL too long (max 2000 chars)')
|
||||||
.refine(isValidWebviewUrl, {
|
.refine(isValidWebviewUrl, {
|
||||||
message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials',
|
message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials',
|
||||||
|
})
|
||||||
|
// Egress policy (`webview-egress-policy.ts`): no dashboard lives at a link-local
|
||||||
|
// or cloud-metadata address, while an IAM credential does. Refused at save time
|
||||||
|
// for the clear message; the proxy re-judges the RESOLVED address at connect time.
|
||||||
|
.refine((url) => !isBlockedWebviewUrl(url), {
|
||||||
|
message:
|
||||||
|
'Blocked URL: link-local and cloud-metadata addresses (169.254.0.0/16, metadata.google.internal, ...) cannot be dashboards',
|
||||||
});
|
});
|
||||||
|
|
||||||
const WebviewBaseSchema = z.object({
|
const WebviewBaseSchema = z.object({
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Egress policy for the web-tab proxy: which upstream ADDRESSES
|
||||||
|
* a saved dashboard URL may never resolve to.
|
||||||
|
*
|
||||||
|
* Pure (no IO), so the same predicate serves three call sites that see the target
|
||||||
|
* at different stages: the Zod schema (a URL being saved), the sync check on a
|
||||||
|
* hostname that is already an IP literal (Node's `net.connect` skips DNS for
|
||||||
|
* those, so a lookup hook never sees them), and the DNS lookup hook that judges
|
||||||
|
* the RESOLVED addresses of a name (`webview-egress.ts`), which is what closes
|
||||||
|
* the rebinding hole a hostname-string check alone leaves open.
|
||||||
|
*
|
||||||
|
* What is blocked, and only this: link-local ranges and the fixed cloud-metadata
|
||||||
|
* addresses that live there or beside them. Loopback and RFC1918 are deliberately
|
||||||
|
* ALLOWED: a `localhost` Grafana or a LAN Home Assistant is the documented use
|
||||||
|
* case for web tabs (`docs/web-tabs.md`), and the proxy's reach into the server's
|
||||||
|
* own network is a documented property, not a bug. Nothing a person would
|
||||||
|
* embed as a dashboard lives at 169.254.169.254, while an IAM credential does.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hostnames that are metadata-service aliases on the clouds that define them.
|
||||||
|
* Belt and braces: each also RESOLVES to a blocked address, which the lookup hook
|
||||||
|
* catches, but naming them here gives the user a clear refusal at save time
|
||||||
|
* instead of a DNS-shaped failure at open time.
|
||||||
|
*/
|
||||||
|
const BLOCKED_HOSTNAMES = new Set([
|
||||||
|
'metadata.google.internal', // GCP
|
||||||
|
'metadata', // GCP short alias (resolves on every GCE VM)
|
||||||
|
'instance-data', // AWS legacy IMDS alias
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Fixed single-address metadata endpoints outside the link-local range. */
|
||||||
|
const BLOCKED_IPV4_HOSTS = new Set([
|
||||||
|
'168.63.129.16', // Azure WireServer (IMDS helper, DHCP/heartbeat endpoint)
|
||||||
|
'100.100.100.200', // Alibaba Cloud metadata
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parseIpv4(host: string): [number, number, number, number] | null {
|
||||||
|
const parts = host.split('.');
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
const nums = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN));
|
||||||
|
if (nums.some((n) => Number.isNaN(n) || n > 255)) return null;
|
||||||
|
return nums as [number, number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv4(host: string): boolean {
|
||||||
|
const octets = parseIpv4(host);
|
||||||
|
if (!octets) return false;
|
||||||
|
const [a, b] = octets;
|
||||||
|
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local, incl. 169.254.169.254 (AWS/Azure/GCP/OpenStack/Oracle/DO)
|
||||||
|
return BLOCKED_IPV4_HOSTS.has(octets.join('.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand an IPv6 literal into its eight 16-bit groups. Accepts the compressed
|
||||||
|
* forms `URL.hostname` and DNS produce (`::1`, `::ffff:7f00:1`, `fd00:ec2::254`)
|
||||||
|
* plus a dotted IPv4 tail (`::ffff:127.0.0.1`). Returns null for anything it
|
||||||
|
* cannot parse, and the caller treats null as "not blocked" because every caller
|
||||||
|
* gates on `isIP()` first, so null only ever means a zone id or a form Node itself
|
||||||
|
* would refuse to connect to.
|
||||||
|
*/
|
||||||
|
function expandIpv6(raw: string): number[] | null {
|
||||||
|
let text = raw.toLowerCase();
|
||||||
|
const zone = text.indexOf('%');
|
||||||
|
if (zone !== -1) text = text.slice(0, zone);
|
||||||
|
|
||||||
|
const lastColon = text.lastIndexOf(':');
|
||||||
|
const tail = text.slice(lastColon + 1);
|
||||||
|
if (tail.includes('.')) {
|
||||||
|
const v4 = parseIpv4(tail);
|
||||||
|
if (!v4) return null;
|
||||||
|
const hi = ((v4[0] << 8) | v4[1]).toString(16);
|
||||||
|
const lo = ((v4[2] << 8) | v4[3]).toString(16);
|
||||||
|
text = `${text.slice(0, lastColon + 1)}${hi}:${lo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const halves = text.split('::');
|
||||||
|
if (halves.length > 2) return null;
|
||||||
|
const head = halves[0] === '' ? [] : halves[0].split(':');
|
||||||
|
const rest = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : [];
|
||||||
|
const missing = 8 - head.length - rest.length;
|
||||||
|
if (halves.length === 2 ? missing < 1 : missing !== 0) return null;
|
||||||
|
const groups = halves.length === 2 ? [...head, ...new Array<string>(missing).fill('0'), ...rest] : head;
|
||||||
|
if (groups.length !== 8) return null;
|
||||||
|
const out = groups.map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : NaN));
|
||||||
|
return out.some((n) => Number.isNaN(n)) ? null : out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv6(host: string): boolean {
|
||||||
|
const groups = expandIpv6(host);
|
||||||
|
if (!groups) return false;
|
||||||
|
// fe80::/10 link-local.
|
||||||
|
if ((groups[0] & 0xffc0) === 0xfe80) return true;
|
||||||
|
// fd00:ec2::254, the AWS IMDS IPv6 endpoint.
|
||||||
|
if (
|
||||||
|
groups[0] === 0xfd00 &&
|
||||||
|
groups[1] === 0x0ec2 &&
|
||||||
|
groups[2] === 0 &&
|
||||||
|
groups[3] === 0 &&
|
||||||
|
groups[4] === 0 &&
|
||||||
|
groups[5] === 0 &&
|
||||||
|
groups[6] === 0 &&
|
||||||
|
groups[7] === 0x0254
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// IPv4-mapped (::ffff:a.b.c.d): judge the embedded IPv4.
|
||||||
|
if (
|
||||||
|
groups[0] === 0 &&
|
||||||
|
groups[1] === 0 &&
|
||||||
|
groups[2] === 0 &&
|
||||||
|
groups[3] === 0 &&
|
||||||
|
groups[4] === 0 &&
|
||||||
|
groups[5] === 0xffff
|
||||||
|
) {
|
||||||
|
const v4 = `${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`;
|
||||||
|
return isBlockedIpv4(v4);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `address` (an IP literal, bracket-free) is one the proxy must never
|
||||||
|
* connect to. Non-IP input is never blocked here: names are judged by
|
||||||
|
* `isBlockedWebviewHostname()` at save time and by their resolved addresses at
|
||||||
|
* connect time.
|
||||||
|
*/
|
||||||
|
export function isBlockedEgressAddress(address: string): boolean {
|
||||||
|
const kind = isIP(address);
|
||||||
|
if (kind === 4) return isBlockedIpv4(address);
|
||||||
|
if (kind === 6) return isBlockedIpv6(address);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Judge a URL hostname as `URL.hostname` hands it over: IPv6 literals arrive in
|
||||||
|
* brackets, names may carry a trailing dot, and case is irrelevant.
|
||||||
|
*
|
||||||
|
* @returns a short human-readable reason when blocked, null when allowed.
|
||||||
|
*/
|
||||||
|
export function blockedWebviewHostReason(hostname: string): string | null {
|
||||||
|
const host = hostname
|
||||||
|
.replace(/^\[|\]$/g, '')
|
||||||
|
.replace(/\.$/, '')
|
||||||
|
.toLowerCase();
|
||||||
|
if (isBlockedEgressAddress(host)) return `${host} is a link-local or cloud-metadata address`;
|
||||||
|
if (BLOCKED_HOSTNAMES.has(host)) return `${host} is a cloud-metadata hostname`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema-friendly boolean form of `blockedWebviewHostReason()` over a raw URL string. */
|
||||||
|
export function isBlockedWebviewUrl(raw: string): boolean {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw.trim());
|
||||||
|
} catch {
|
||||||
|
return false; // not this predicate's job; the URL shape check rejects it
|
||||||
|
}
|
||||||
|
return blockedWebviewHostReason(url.hostname) !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Guarded egress for the web-tab proxy: the IO half of the policy in
|
||||||
|
* `webview-egress-policy.ts`.
|
||||||
|
*
|
||||||
|
* Three outbound paths exist for a saved dashboard URL (the "Test" probe, the
|
||||||
|
* HTTP proxy, the WebSocket relay), and all three must judge the RESOLVED address
|
||||||
|
* rather than the hostname string, or a name pointing at 169.254.169.254 (an
|
||||||
|
* attacker's own DNS, or `metadata.google.internal` on GCP) walks straight past
|
||||||
|
* a literal-only check. So:
|
||||||
|
*
|
||||||
|
* - `createEgressLookup()` is a `net.connect`-shaped `lookup` that resolves with
|
||||||
|
* `all: true` and refuses when ANY returned address is blocked (Happy Eyeballs
|
||||||
|
* may otherwise pick the one we did not inspect).
|
||||||
|
* - `webviewFetch()` runs undici's own `fetch` through an `Agent` whose connector
|
||||||
|
* uses that lookup. undici's fetch rather than Node's global one, and undici's
|
||||||
|
* Agent rather than a dispatcher handed to the global fetch, so the two are
|
||||||
|
* always the same undici version: Node bundles its own copy, and a mismatched
|
||||||
|
* dispatch protocol between the two fails in ways no test here would catch.
|
||||||
|
* - The WebSocket relay passes the same lookup to `ws`, which forwards it to
|
||||||
|
* `http.request`.
|
||||||
|
*
|
||||||
|
* ⚠️ A lookup hook never sees an IP LITERAL: Node's `net.connect` skips DNS for
|
||||||
|
* those. Every caller therefore runs `blockedWebviewHostReason()` on the URL's
|
||||||
|
* hostname synchronously BEFORE connecting, and `webviewFetch()` does it for its
|
||||||
|
* own callers. Neither half is redundant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as dns, type LookupAddress, type LookupOptions } from 'node:dns';
|
||||||
|
import type { LookupFunction } from 'node:net';
|
||||||
|
import { Agent, fetch as undiciFetch, type RequestInit, type Response } from 'undici';
|
||||||
|
import { blockedWebviewHostReason, isBlockedEgressAddress } from './webview-egress-policy.js';
|
||||||
|
|
||||||
|
export const EGRESS_BLOCKED_CODE = 'CODEMAN_EGRESS_BLOCKED';
|
||||||
|
|
||||||
|
/** Thrown (or delivered as the lookup error) when a target resolves into a blocked range. */
|
||||||
|
export class WebviewEgressBlockedError extends Error {
|
||||||
|
readonly code = EGRESS_BLOCKED_CODE;
|
||||||
|
constructor(reason: string) {
|
||||||
|
super(`Blocked: ${reason}; the web-tab proxy never relays to link-local or cloud-metadata addresses`);
|
||||||
|
this.name = 'WebviewEgressBlockedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The refusal message when `err`, or anything in its `cause` chain, is an egress
|
||||||
|
* refusal; null otherwise. undici's fetch wraps a connect failure as
|
||||||
|
* `TypeError('fetch failed', { cause })`, so the interesting error is one level
|
||||||
|
* down, and callers want ITS message, not "fetch failed".
|
||||||
|
*/
|
||||||
|
export function egressBlockedReason(err: unknown): string | null {
|
||||||
|
let current: unknown = err;
|
||||||
|
for (let depth = 0; depth < 8 && current && typeof current === 'object'; depth++) {
|
||||||
|
const candidate = current as { code?: unknown; message?: unknown; cause?: unknown };
|
||||||
|
if (candidate.code === EGRESS_BLOCKED_CODE) {
|
||||||
|
return typeof candidate.message === 'string' ? candidate.message : 'Blocked by egress policy';
|
||||||
|
}
|
||||||
|
current = candidate.cause;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Boolean form of `egressBlockedReason()`. */
|
||||||
|
export function isEgressBlockedError(err: unknown): boolean {
|
||||||
|
return egressBlockedReason(err) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `net.connect`'s `lookup` signature, which undici's connector and `ws` both forward to it. */
|
||||||
|
export type EgressLookup = LookupFunction;
|
||||||
|
|
||||||
|
/** Resolver seam for tests: what the lookup consults for a name's addresses. */
|
||||||
|
export type ResolveAll = (hostname: string, options: LookupOptions) => Promise<LookupAddress[]>;
|
||||||
|
|
||||||
|
const defaultResolveAll: ResolveAll = (hostname, options) => {
|
||||||
|
const family = typeof options.family === 'string' ? Number(options.family.replace(/^IPv/i, '')) : options.family;
|
||||||
|
return dns.lookup(hostname, {
|
||||||
|
...(family === 4 || family === 6 ? { family } : {}),
|
||||||
|
...(options.hints !== undefined ? { hints: options.hints } : {}),
|
||||||
|
all: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a `lookup` for `net.connect` / undici's connector / `ws` that refuses
|
||||||
|
* blocked resolved addresses. Every address is inspected, not just the first:
|
||||||
|
* with `autoSelectFamily` Node races the whole list.
|
||||||
|
*/
|
||||||
|
export function createEgressLookup(resolve: ResolveAll = defaultResolveAll): EgressLookup {
|
||||||
|
return (hostname, options, callback) => {
|
||||||
|
// Node's callback type carries a non-optional address; on error `net` reads
|
||||||
|
// only `err`, so the placeholder values are never looked at.
|
||||||
|
const fail = (err: NodeJS.ErrnoException) => callback(err, '', 0);
|
||||||
|
resolve(hostname, options ?? {}).then(
|
||||||
|
(addresses) => {
|
||||||
|
const blocked = addresses.find((entry) => isBlockedEgressAddress(entry.address));
|
||||||
|
if (blocked) {
|
||||||
|
fail(new WebviewEgressBlockedError(`${hostname} resolves to ${blocked.address}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options?.all) {
|
||||||
|
callback(null, addresses, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = addresses[0];
|
||||||
|
if (!first) {
|
||||||
|
const notFound: NodeJS.ErrnoException = new Error(`getaddrinfo ENOTFOUND ${hostname}`);
|
||||||
|
notFound.code = 'ENOTFOUND';
|
||||||
|
fail(notFound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, first.address, first.family);
|
||||||
|
},
|
||||||
|
(err: NodeJS.ErrnoException) => fail(err)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process-wide lookup for the WebSocket relay (and anything else `net`-shaped). */
|
||||||
|
export const webviewEgressLookup: EgressLookup = createEgressLookup();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An undici `Agent` whose connections resolve through `lookup`. Exported as a
|
||||||
|
* factory so a test can inject a resolver and prove the hook is honoured
|
||||||
|
* end-to-end; production uses the lazily-built singleton below.
|
||||||
|
*/
|
||||||
|
export function createWebviewDispatcher(lookup: EgressLookup = webviewEgressLookup): Agent {
|
||||||
|
return new Agent({ connect: { lookup } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let dispatcher: Agent | undefined;
|
||||||
|
function webviewDispatcher(): Agent {
|
||||||
|
dispatcher ??= createWebviewDispatcher();
|
||||||
|
return dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fetch` for dashboard targets. Refuses a blocked IP literal synchronously (the
|
||||||
|
* lookup hook never sees one) and routes everything else through the guarded
|
||||||
|
* Agent, where a name resolving into a blocked range fails the connect with a
|
||||||
|
* `WebviewEgressBlockedError` as the `cause` of undici's `fetch failed` TypeError.
|
||||||
|
* Check either shape with `isEgressBlockedError()`.
|
||||||
|
*/
|
||||||
|
export function webviewFetch(target: URL, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const reason = blockedWebviewHostReason(target.hostname);
|
||||||
|
if (reason) return Promise.reject(new WebviewEgressBlockedError(reason));
|
||||||
|
return undiciFetch(target.href, { ...init, dispatcher: webviewDispatcher() });
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js';
|
|||||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
import { webviewCapabilities } from '../../src/webview-capabilities.js';
|
import { webviewCapabilities } from '../../src/webview-capabilities.js';
|
||||||
import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js';
|
import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js';
|
||||||
|
import { writeWebviews } from '../../src/webview-store.js';
|
||||||
import { TabLayoutService } from '../../src/tab-layout-service.js';
|
import { TabLayoutService } from '../../src/tab-layout-service.js';
|
||||||
import type { TabLayout } from '../../src/tab-layout.js';
|
import type { TabLayout } from '../../src/tab-layout.js';
|
||||||
|
|
||||||
@@ -286,3 +287,59 @@ describe('POST /api/webviews/probe', () => {
|
|||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('egress policy: link-local and cloud-metadata targets', () => {
|
||||||
|
it('refuses to SAVE a metadata address, in every spelling, with a message that says why', async () => {
|
||||||
|
for (const url of [
|
||||||
|
'http://169.254.169.254/latest/meta-data/',
|
||||||
|
'http://2852039166/', // decimal form of 169.254.169.254
|
||||||
|
'http://[fd00:ec2::254]/',
|
||||||
|
'http://metadata.google.internal/computeMetadata/v1/',
|
||||||
|
]) {
|
||||||
|
const res = await create({ name: 'IMDS', url });
|
||||||
|
expect(res.statusCode, url).toBe(400);
|
||||||
|
expect(res.body, url).toMatch(/Blocked URL/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still saves the loopback dashboards the feature exists for', async () => {
|
||||||
|
expect((await create({ name: 'Grafana', url: 'http://127.0.0.1:4000/' })).statusCode).toBe(200);
|
||||||
|
expect((await create({ name: 'Local', url: 'http://localhost:3080/' })).statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the probe refuses the same targets up front, before any connection is attempted', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/webviews/probe',
|
||||||
|
payload: { url: 'http://169.254.169.254/' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body).toMatch(/Blocked URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the proxy refuses a record saved before the rule existed with a 403, never a relay', async () => {
|
||||||
|
// Written straight to the store: the schema would refuse it today, which is
|
||||||
|
// exactly why the proxy must judge the target again at connect time.
|
||||||
|
await writeWebviews(tmpDir, [
|
||||||
|
{
|
||||||
|
id: 'legacy-imds',
|
||||||
|
name: 'legacy',
|
||||||
|
url: 'http://169.254.169.254/',
|
||||||
|
embedMode: 'proxy',
|
||||||
|
trusted: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const cap = webviewCapabilities.mint('legacy-imds', undefined);
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
const res = await app.inject({ method: 'GET', url: `/webview/${cap}/latest/meta-data/` });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.body).toMatch(/link-local or cloud-metadata/);
|
||||||
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('refused by egress policy'));
|
||||||
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
|
webviewCapabilities.revokeWebview('legacy-imds');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Egress policy for the web-tab proxy (src/web/webview-egress-policy.ts).
|
||||||
|
*
|
||||||
|
* The proxy reaches whatever the server can reach ON PURPOSE (a localhost
|
||||||
|
* Grafana is the documented use case), so this policy blocks only the ranges no
|
||||||
|
* dashboard lives in and a cloud credential does: link-local and the fixed
|
||||||
|
* metadata endpoints. Both halves are pinned: what is refused, and what must
|
||||||
|
* stay allowed so the feature keeps working.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
blockedWebviewHostReason,
|
||||||
|
isBlockedEgressAddress,
|
||||||
|
isBlockedWebviewUrl,
|
||||||
|
} from '../src/web/webview-egress-policy.js';
|
||||||
|
|
||||||
|
describe('isBlockedEgressAddress', () => {
|
||||||
|
it('blocks the IPv4 link-local range, which every major cloud puts IMDS in', () => {
|
||||||
|
expect(isBlockedEgressAddress('169.254.169.254')).toBe(true);
|
||||||
|
expect(isBlockedEgressAddress('169.254.0.23')).toBe(true); // Tencent metadata
|
||||||
|
expect(isBlockedEgressAddress('169.254.255.255')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks the fixed metadata endpoints outside link-local', () => {
|
||||||
|
expect(isBlockedEgressAddress('168.63.129.16')).toBe(true); // Azure WireServer
|
||||||
|
expect(isBlockedEgressAddress('100.100.100.200')).toBe(true); // Alibaba Cloud
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks IPv6 link-local and the AWS IMDS IPv6 endpoint in every spelling', () => {
|
||||||
|
expect(isBlockedEgressAddress('fe80::1')).toBe(true);
|
||||||
|
expect(isBlockedEgressAddress('FE80::1%eth0')).toBe(true);
|
||||||
|
expect(isBlockedEgressAddress('febf:ffff::1')).toBe(true);
|
||||||
|
expect(isBlockedEgressAddress('fd00:ec2::254')).toBe(true);
|
||||||
|
expect(isBlockedEgressAddress('fd00:0ec2:0000:0000:0000:0000:0000:0254')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('judges the embedded IPv4 of a mapped address, dotted or hex', () => {
|
||||||
|
expect(isBlockedEgressAddress('::ffff:169.254.169.254')).toBe(true);
|
||||||
|
expect(isBlockedEgressAddress('::ffff:a9fe:a9fe')).toBe(true); // URL.hostname's form
|
||||||
|
expect(isBlockedEgressAddress('::ffff:127.0.0.1')).toBe(false);
|
||||||
|
expect(isBlockedEgressAddress('::ffff:7f00:1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ALLOWS loopback and private ranges: localhost dashboards are the feature', () => {
|
||||||
|
expect(isBlockedEgressAddress('127.0.0.1')).toBe(false);
|
||||||
|
expect(isBlockedEgressAddress('::1')).toBe(false);
|
||||||
|
expect(isBlockedEgressAddress('10.0.0.5')).toBe(false);
|
||||||
|
expect(isBlockedEgressAddress('192.168.1.20')).toBe(false);
|
||||||
|
expect(isBlockedEgressAddress('172.16.0.9')).toBe(false);
|
||||||
|
expect(isBlockedEgressAddress('100.64.0.1')).toBe(false); // tailnet CGNAT range
|
||||||
|
expect(isBlockedEgressAddress('fd7a:115c:a1e0::1')).toBe(false); // tailnet ULA
|
||||||
|
expect(isBlockedEgressAddress('fd00:ec2::255')).toBe(false); // neighbour of the AWS address
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never blocks a name: names are judged by what they resolve to', () => {
|
||||||
|
expect(isBlockedEgressAddress('metadata.google.internal')).toBe(false);
|
||||||
|
expect(isBlockedEgressAddress('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blockedWebviewHostReason', () => {
|
||||||
|
it('accepts URL.hostname forms: bracketed IPv6, trailing dot, mixed case', () => {
|
||||||
|
expect(blockedWebviewHostReason('[fe80::1]')).toMatch(/link-local/);
|
||||||
|
expect(blockedWebviewHostReason('[::ffff:a9fe:a9fe]')).toMatch(/link-local/);
|
||||||
|
expect(blockedWebviewHostReason('METADATA.GOOGLE.INTERNAL.')).toMatch(/metadata hostname/);
|
||||||
|
expect(blockedWebviewHostReason('[::1]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the cloud metadata aliases even though they would also fail resolution', () => {
|
||||||
|
expect(blockedWebviewHostReason('metadata')).not.toBeNull();
|
||||||
|
expect(blockedWebviewHostReason('instance-data')).not.toBeNull();
|
||||||
|
expect(blockedWebviewHostReason('metadata.example.com')).toBeNull();
|
||||||
|
expect(blockedWebviewHostReason('grafana.internal')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isBlockedWebviewUrl (schema refine)', () => {
|
||||||
|
it('sees through the URL normalisations an attacker would lean on', () => {
|
||||||
|
// Decimal and hex hosts normalise to dotted quads inside `new URL`.
|
||||||
|
expect(isBlockedWebviewUrl('http://2852039166/latest/meta-data/')).toBe(true); // 169.254.169.254
|
||||||
|
expect(isBlockedWebviewUrl('http://0xa9fea9fe/')).toBe(true);
|
||||||
|
expect(isBlockedWebviewUrl('http://169.254.169.254:80/')).toBe(true);
|
||||||
|
expect(isBlockedWebviewUrl('http://[fd00:ec2::254]/')).toBe(true);
|
||||||
|
expect(isBlockedWebviewUrl('http://metadata.google.internal/computeMetadata/v1/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves every documented dashboard shape alone', () => {
|
||||||
|
expect(isBlockedWebviewUrl('http://127.0.0.1:4000/grafana/')).toBe(false);
|
||||||
|
expect(isBlockedWebviewUrl('http://localhost:3080/')).toBe(false);
|
||||||
|
expect(isBlockedWebviewUrl('https://homeassistant.tailf80371.ts.net/')).toBe(false);
|
||||||
|
expect(isBlockedWebviewUrl('http://192.168.1.20:9000/')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not the URL-shape check: garbage is someone else's refusal", () => {
|
||||||
|
expect(isBlockedWebviewUrl('not a url')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Guarded egress for the web-tab proxy (src/web/webview-egress.ts).
|
||||||
|
*
|
||||||
|
* The policy is judged on RESOLVED addresses through a `lookup` hook, because a
|
||||||
|
* hostname-string check cannot see where `metadata.google.internal`, or an
|
||||||
|
* attacker's own DNS name, actually points. These tests inject a resolver and
|
||||||
|
* drive a real undici Agent against a real local HTTP server, so what is pinned
|
||||||
|
* is that undici honours the hook end-to-end, not that a helper returns a value.
|
||||||
|
* Port: ephemeral (server.listen(0)).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { createServer, type Server } from 'node:http';
|
||||||
|
import type { LookupAddress } from 'node:dns';
|
||||||
|
import { fetch as undiciFetch } from 'undici';
|
||||||
|
import {
|
||||||
|
createEgressLookup,
|
||||||
|
createWebviewDispatcher,
|
||||||
|
egressBlockedReason,
|
||||||
|
isEgressBlockedError,
|
||||||
|
webviewFetch,
|
||||||
|
WebviewEgressBlockedError,
|
||||||
|
type EgressLookup,
|
||||||
|
} from '../src/web/webview-egress.js';
|
||||||
|
|
||||||
|
type LookupCallbackArgs = Parameters<Parameters<EgressLookup>[2]>;
|
||||||
|
|
||||||
|
const NAMES: Record<string, LookupAddress[]> = {
|
||||||
|
'dash.test': [{ address: '127.0.0.1', family: 4 }],
|
||||||
|
'meta.test': [{ address: '169.254.169.254', family: 4 }],
|
||||||
|
// Happy Eyeballs shape: one fine address and one blocked one.
|
||||||
|
'mixed.test': [
|
||||||
|
{ address: '127.0.0.1', family: 4 },
|
||||||
|
{ address: 'fd00:ec2::254', family: 6 },
|
||||||
|
],
|
||||||
|
'nowhere.test': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeResolve = async (hostname: string): Promise<LookupAddress[]> => {
|
||||||
|
const found = NAMES[hostname];
|
||||||
|
if (!found) {
|
||||||
|
const err: NodeJS.ErrnoException = new Error(`getaddrinfo ENOTFOUND ${hostname}`);
|
||||||
|
err.code = 'ENOTFOUND';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
|
||||||
|
function callLookup(hostname: string, options: { all?: boolean }): Promise<LookupCallbackArgs> {
|
||||||
|
const lookup = createEgressLookup(fakeResolve);
|
||||||
|
return new Promise((resolve) => lookup(hostname, options, (...args) => resolve(args)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createEgressLookup', () => {
|
||||||
|
it("answers in net.connect's single-address shape when `all` is not requested", async () => {
|
||||||
|
const [err, address, family] = await callLookup('dash.test', {});
|
||||||
|
expect(err).toBeNull();
|
||||||
|
expect(address).toBe('127.0.0.1');
|
||||||
|
expect(family).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers the array shape autoSelectFamily asks for', async () => {
|
||||||
|
const [err, addresses] = await callLookup('dash.test', { all: true });
|
||||||
|
expect(err).toBeNull();
|
||||||
|
expect(addresses).toEqual([{ address: '127.0.0.1', family: 4 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a name that resolves into a blocked range, naming both', async () => {
|
||||||
|
const [err] = await callLookup('meta.test', {});
|
||||||
|
expect(err).toBeInstanceOf(WebviewEgressBlockedError);
|
||||||
|
expect(err?.message).toContain('meta.test resolves to 169.254.169.254');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when ANY resolved address is blocked, not just the first', async () => {
|
||||||
|
const [err] = await callLookup('mixed.test', { all: true });
|
||||||
|
expect(err).toBeInstanceOf(WebviewEgressBlockedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes resolver errors and empty answers through as ordinary DNS failures', async () => {
|
||||||
|
const [notFound] = await callLookup('unknown.test', {});
|
||||||
|
expect(notFound?.code).toBe('ENOTFOUND');
|
||||||
|
expect(isEgressBlockedError(notFound)).toBe(false);
|
||||||
|
const [empty] = await callLookup('nowhere.test', {});
|
||||||
|
expect(empty?.code).toBe('ENOTFOUND');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('guarded undici Agent (end-to-end against a local upstream)', () => {
|
||||||
|
let upstream: Server;
|
||||||
|
let port: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
upstream = createServer((req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||||
|
res.end(`served ${req.headers.host ?? ''}`);
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||||
|
port = (upstream.address() as { port: number }).port;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connects through the hook: a name resolving to loopback reaches the server', async () => {
|
||||||
|
const dispatcher = createWebviewDispatcher(createEgressLookup(fakeResolve));
|
||||||
|
try {
|
||||||
|
const res = await undiciFetch(`http://dash.test:${port}/`, { dispatcher });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.text()).toBe(`served dash.test:${port}`);
|
||||||
|
} finally {
|
||||||
|
await dispatcher.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the connect when the name resolves into a blocked range, with the reason as the cause', async () => {
|
||||||
|
const dispatcher = createWebviewDispatcher(createEgressLookup(fakeResolve));
|
||||||
|
try {
|
||||||
|
const attempt = undiciFetch(`http://meta.test:${port}/latest/meta-data/`, { dispatcher });
|
||||||
|
await expect(attempt).rejects.toThrow();
|
||||||
|
const err = await attempt.catch((e: unknown) => e);
|
||||||
|
expect(isEgressBlockedError(err)).toBe(true);
|
||||||
|
expect(egressBlockedReason(err)).toContain('169.254.169.254');
|
||||||
|
} finally {
|
||||||
|
await dispatcher.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('webviewFetch', () => {
|
||||||
|
it('refuses a blocked IP literal synchronously, since net.connect never consults lookup for one', async () => {
|
||||||
|
const attempt = webviewFetch(new URL('http://169.254.169.254/latest/meta-data/'));
|
||||||
|
await expect(attempt).rejects.toBeInstanceOf(WebviewEgressBlockedError);
|
||||||
|
const err = await attempt.catch((e: unknown) => e);
|
||||||
|
expect(egressBlockedReason(err)).toMatch(/169\.254\.169\.254/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the bracketed IPv6 and the alias forms the same way', async () => {
|
||||||
|
await expect(webviewFetch(new URL('http://[fd00:ec2::254]/'))).rejects.toBeInstanceOf(WebviewEgressBlockedError);
|
||||||
|
await expect(webviewFetch(new URL('http://metadata.google.internal/'))).rejects.toBeInstanceOf(
|
||||||
|
WebviewEgressBlockedError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('egressBlockedReason', () => {
|
||||||
|
it('walks a cause chain and ignores unrelated errors', () => {
|
||||||
|
const inner = new WebviewEgressBlockedError('x resolves to 169.254.1.1');
|
||||||
|
const wrapped = new TypeError('fetch failed', { cause: inner });
|
||||||
|
expect(egressBlockedReason(wrapped)).toBe(inner.message);
|
||||||
|
expect(egressBlockedReason(new Error('ECONNREFUSED'))).toBeNull();
|
||||||
|
expect(egressBlockedReason(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user