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
99 lines
4.8 KiB
TypeScript
99 lines
4.8 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|