mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39: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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user