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:
Codeman maintainer
2026-09-04 15:21:12 +02:00
parent 99ad9cb236
commit 550e08a791
9 changed files with 684 additions and 15 deletions
+48 -15
View File
@@ -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<WebviewProbe> {
}
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<WebviewProbe> {
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');
});
})();
}
+8
View File
@@ -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({
+162
View File
@@ -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;
}
+146
View File
@@ -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() });
}