Files
Codeman/src/web/webview-egress.ts
T
Codeman maintainer 550e08a791 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
2026-09-04 15:21:12 +02:00

147 lines
6.4 KiB
TypeScript

/**
* @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() });
}