mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
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:
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<Parameters<EgressLookup>[2]>;
|
||||
|
||||
const NAMES: Record<string, LookupAddress[]> = {
|
||||
'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<LookupAddress[]> => {
|
||||
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<LookupCallbackArgs> {
|
||||
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<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
port = (upstream.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user