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
+57
View File
@@ -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');
}
});
});