From 550e08a791c78db81e3cec95b6418015c26df8d8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 4 Sep 2026 15:21:12 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WKtW48T1UjAaecHAJxKobE --- package-lock.json | 10 ++ package.json | 1 + src/web/routes/webview-routes.ts | 63 ++++++++--- src/web/schemas.ts | 8 ++ src/web/webview-egress-policy.ts | 162 +++++++++++++++++++++++++++++ src/web/webview-egress.ts | 146 ++++++++++++++++++++++++++ test/routes/webview-routes.test.ts | 57 ++++++++++ test/webview-egress-policy.test.ts | 98 +++++++++++++++++ test/webview-egress.test.ts | 154 +++++++++++++++++++++++++++ 9 files changed, 684 insertions(+), 15 deletions(-) create mode 100644 src/web/webview-egress-policy.ts create mode 100644 src/web/webview-egress.ts create mode 100644 test/webview-egress-policy.test.ts create mode 100644 test/webview-egress.test.ts diff --git a/package-lock.json b/package-lock.json index 6c2cfa9d..b08891bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "jpeg-js": "^0.4.4", "node-pty": "^1.1.0", "qrcode": "^1.5.4", + "undici": "^6.28.0", "uuid": "^14.0.0", "web-push": "^3.6.7", "ws": "^8.21.0", @@ -11550,6 +11551,15 @@ "dev": true, "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": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 56d9db70..efd66bba 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "jpeg-js": "^0.4.4", "node-pty": "^1.1.0", "qrcode": "^1.5.4", + "undici": "^6.28.0", "uuid": "^14.0.0", "web-push": "^3.6.7", "ws": "^8.21.0", diff --git a/src/web/routes/webview-routes.ts b/src/web/routes/webview-routes.ts index f8aed284..b39d247c 100644 --- a/src/web/routes/webview-routes.ts +++ b/src/web/routes/webview-routes.ts @@ -33,7 +33,8 @@ import { randomUUID } from 'node:crypto'; import { Readable } from 'node:stream'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; 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 { MAX_LIVE_WEBVIEW_FRAMES, @@ -47,6 +48,8 @@ import { } from '../../config/webview-limits.js'; import { readWebviews, writeWebviews } from '../../webview-store.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 type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; @@ -293,7 +296,7 @@ async function probeUrl(url: string): Promise { } try { - const response = await fetch(target.href, { + const response = await webviewFetch(target, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS), @@ -326,6 +329,12 @@ async function probeUrl(url: string): Promise { reason, }; } 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); return { reachable: false, @@ -476,19 +485,19 @@ async function proxyRequest( // string (it can carry the dashboard's tokens). const logTarget = `${req.method} ${upstream.origin}${upstream.pathname}`; - let response: Response; + let response: UndiciResponse; try { - response = await fetch(upstream.href, { + response = await webviewFetch(upstream, { method: req.method, headers, body: hasBody ? (req.body as Readable) : undefined, // 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 // browser's URL stays inside the frame and relative assets keep resolving. redirect: 'manual', signal: abort.signal, - } as RequestInit); + }); } catch (err) { const elapsed = Date.now() - startedAt; if (clientGone) { @@ -496,6 +505,13 @@ async function proxyRequest( // failure, so no warn (it would read as the dashboard being broken). 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) { console.warn( `[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; } + // 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); let released = false; const release = () => { @@ -655,16 +678,21 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa }; 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( upstreamWebSocketUrl(upstream), protocols ? String(protocols).split(/,\s*/) : [], - { - headers: { - origin: upstream.origin, - ...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}), - }, - handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS, - } + upstreamOptions ); // 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())); upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString())); socket.on('error', () => closeBoth()); - upstreamSocket.on('error', () => { + upstreamSocket.on('error', (err: Error) => { 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'); }); })(); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index d4669ec0..177daffa 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js'; import { isValidWebviewUrl } from './webview-proxy.js'; +import { isBlockedWebviewUrl } from './webview-egress-policy.js'; import { MAX_TERMINAL_BUFFER_BYTES, MAX_TERMINAL_SCROLLBACK_LINES, @@ -1762,6 +1763,13 @@ const webviewUrlSchema = z .max(2000, 'URL too long (max 2000 chars)') .refine(isValidWebviewUrl, { 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({ diff --git a/src/web/webview-egress-policy.ts b/src/web/webview-egress-policy.ts new file mode 100644 index 00000000..6ed7d07d --- /dev/null +++ b/src/web/webview-egress-policy.ts @@ -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(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; +} diff --git a/src/web/webview-egress.ts b/src/web/webview-egress.ts new file mode 100644 index 00000000..8c7e70cc --- /dev/null +++ b/src/web/webview-egress.ts @@ -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; + +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 { + const reason = blockedWebviewHostReason(target.hostname); + if (reason) return Promise.reject(new WebviewEgressBlockedError(reason)); + return undiciFetch(target.href, { ...init, dispatcher: webviewDispatcher() }); +} diff --git a/test/routes/webview-routes.test.ts b/test/routes/webview-routes.test.ts index 490155d2..7e0f7b05 100644 --- a/test/routes/webview-routes.test.ts +++ b/test/routes/webview-routes.test.ts @@ -16,6 +16,7 @@ import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { webviewCapabilities } from '../../src/webview-capabilities.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 type { TabLayout } from '../../src/tab-layout.js'; @@ -286,3 +287,59 @@ describe('POST /api/webviews/probe', () => { 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'); + } + }); +}); diff --git a/test/webview-egress-policy.test.ts b/test/webview-egress-policy.test.ts new file mode 100644 index 00000000..d747f450 --- /dev/null +++ b/test/webview-egress-policy.test.ts @@ -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); + }); +}); diff --git a/test/webview-egress.test.ts b/test/webview-egress.test.ts new file mode 100644 index 00000000..91d4920a --- /dev/null +++ b/test/webview-egress.test.ts @@ -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[2]>; + +const NAMES: Record = { + '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 => { + 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 { + 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((resolve) => upstream.listen(0, '127.0.0.1', resolve)); + port = (upstream.address() as { port: number }).port; + }); + + afterAll(async () => { + await new Promise((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(); + }); +});