mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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
163 lines
6.2 KiB
TypeScript
163 lines
6.2 KiB
TypeScript
/**
|
|
* @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;
|
|
}
|