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
346 lines
14 KiB
TypeScript
346 lines
14 KiB
TypeScript
/**
|
|
* CRUD + capability behaviour for /api/webviews.
|
|
*
|
|
* Uses app.inject() (no port) against a temp CODEMAN_DATA_DIR, so nothing touches
|
|
* the developer's real ~/.codeman/webviews.json.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import fastifyWebsocket from '@fastify/websocket';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
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';
|
|
|
|
let app: FastifyInstance;
|
|
let tmpDir: string;
|
|
let savedDataDir: string | undefined;
|
|
const broadcasts: Array<{ event: string; data: unknown }> = [];
|
|
const webviewCreated = vi.fn(async () => {});
|
|
const webviewDeleted = vi.fn(async () => {});
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webviews-'));
|
|
savedDataDir = process.env.CODEMAN_DATA_DIR;
|
|
process.env.CODEMAN_DATA_DIR = tmpDir;
|
|
broadcasts.length = 0;
|
|
webviewCreated.mockClear();
|
|
webviewDeleted.mockClear();
|
|
|
|
app = Fastify({ logger: false });
|
|
await app.register(fastifyCookie);
|
|
// The proxy route declares a wsHandler, so the plugin must be present.
|
|
await app.register(fastifyWebsocket);
|
|
registerWebviewRoutes(app, {
|
|
broadcast: (event: string, data: unknown) => broadcasts.push({ event, data }),
|
|
tabLayouts: { webviewCreated, webviewDeleted },
|
|
} as never);
|
|
installRouteErrorHandler(app);
|
|
await app.ready();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await app.close();
|
|
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
|
else process.env.CODEMAN_DATA_DIR = savedDataDir;
|
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
});
|
|
|
|
const create = (payload: Record<string, unknown>) => app.inject({ method: 'POST', url: '/api/webviews', payload });
|
|
|
|
describe('GET /api/webviews', () => {
|
|
it('starts empty and reports the frame budget the client must honour', async () => {
|
|
const res = await app.inject({ method: 'GET', url: '/api/webviews' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.webviews).toEqual([]);
|
|
expect(typeof body.data.maxLiveFrames).toBe('number');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/webviews', () => {
|
|
it('creates a dashboard that defaults to proxied and sandboxed', async () => {
|
|
const res = await create({ name: 'Grafana', url: 'http://127.0.0.1:4000/' });
|
|
expect(res.statusCode).toBe(200);
|
|
const w = res.json().data;
|
|
// Proxy + untrusted are the safe defaults and must not drift.
|
|
expect(w.embedMode).toBe('proxy');
|
|
expect(w.trusted).toBe(false);
|
|
expect(w.id).toBeTruthy();
|
|
});
|
|
|
|
it('broadcasts the change so other devices re-fetch', async () => {
|
|
await create({ name: 'G', url: 'http://127.0.0.1:4000/' });
|
|
expect(broadcasts.map((b) => b.event)).toContain('webview:changed');
|
|
});
|
|
|
|
it('persists across a fresh read of the store', async () => {
|
|
await create({ name: 'G', url: 'http://127.0.0.1:4000/' });
|
|
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
|
expect(list).toHaveLength(1);
|
|
expect(list[0].name).toBe('G');
|
|
});
|
|
|
|
it('rejects URLs that are not plain http(s)', async () => {
|
|
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'data:text/html,x']) {
|
|
const res = await create({ name: 'bad', url });
|
|
expect(res.statusCode, url).toBe(400);
|
|
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
|
}
|
|
});
|
|
|
|
it('rolls back webview persistence and emits nothing when layout capacity rejects insertion', async () => {
|
|
const refs = Array.from({ length: 512 }, (_, index) => ({ kind: 'session' as const, id: `s-${index}` }));
|
|
const original: TabLayout = {
|
|
version: 9,
|
|
groups: [],
|
|
ungrouped: refs,
|
|
updatedAt: '2026-08-16T00:00:00.000Z',
|
|
};
|
|
let stored = original;
|
|
const live = new Map(refs.map((ref, index) => [ref.id, { id: ref.id, createdAt: index }]));
|
|
const atomicBroadcast = vi.fn();
|
|
const service = new TabLayoutService({
|
|
store: {
|
|
getTabLayout: () => stored,
|
|
setTabLayout: (_owner, layout) => {
|
|
stored = layout;
|
|
},
|
|
getSessions: () => ({}),
|
|
getSessionOrder: () => [],
|
|
} as never,
|
|
sessions: live,
|
|
readWebviews: async () =>
|
|
(await import('../../src/webview-store.js')).readWebviews(tmpDir) as Promise<
|
|
Array<{ id: string; owner?: string }>
|
|
>,
|
|
broadcast: atomicBroadcast,
|
|
broadcastSessionOrder: vi.fn(),
|
|
});
|
|
const atomicApp = Fastify({ logger: false });
|
|
await atomicApp.register(fastifyCookie);
|
|
await atomicApp.register(fastifyWebsocket);
|
|
registerWebviewRoutes(atomicApp, {
|
|
broadcast: atomicBroadcast,
|
|
tabLayouts: service,
|
|
} as never);
|
|
installRouteErrorHandler(atomicApp);
|
|
await atomicApp.ready();
|
|
|
|
const response = await atomicApp.inject({
|
|
method: 'POST',
|
|
url: '/api/webviews',
|
|
payload: { name: 'overflow', url: 'https://example.test/' },
|
|
});
|
|
const list = (await atomicApp.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
expect(list).toEqual([]);
|
|
expect(stored).toEqual(original);
|
|
expect(atomicBroadcast).not.toHaveBeenCalled();
|
|
await atomicApp.close();
|
|
});
|
|
|
|
it('rejects URLs carrying embedded credentials', async () => {
|
|
const res = await create({ name: 'bad', url: 'http://user:pass@host:4000/' });
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('requires a name', async () => {
|
|
expect((await create({ url: 'http://127.0.0.1:4000/' })).statusCode).toBe(400);
|
|
expect((await create({ name: ' ', url: 'http://127.0.0.1:4000/' })).statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('PATCH /api/webviews/:id', () => {
|
|
it('updates fields and revokes the outstanding capability', async () => {
|
|
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
|
const opened = await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
|
const cap = capabilityFromProxyPath(opened.json().data.embedUrl)!;
|
|
expect(webviewCapabilities.resolve(cap)).toBeDefined();
|
|
|
|
const res = await app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/webviews/${id}`,
|
|
payload: { url: 'http://127.0.0.1:4001/' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().data.url).toBe('http://127.0.0.1:4001/');
|
|
// A token minted against the OLD url must not survive the repoint.
|
|
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
|
});
|
|
|
|
it('404s an unknown id', async () => {
|
|
const res = await app.inject({ method: 'PATCH', url: '/api/webviews/nope', payload: { name: 'x' } });
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('still validates the URL on update', async () => {
|
|
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
|
const res = await app.inject({ method: 'PATCH', url: `/api/webviews/${id}`, payload: { url: 'file:///etc' } });
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/webviews/:id', () => {
|
|
it('removes it and revokes its capability', async () => {
|
|
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
|
const opened = await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
|
const cap = capabilityFromProxyPath(opened.json().data.embedUrl)!;
|
|
|
|
expect((await app.inject({ method: 'DELETE', url: `/api/webviews/${id}` })).statusCode).toBe(200);
|
|
expect((await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews).toEqual([]);
|
|
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the exact saved record and emits nothing when layout deletion fails', async () => {
|
|
const created = (await create({ name: 'Keep me', url: 'https://keep.example/' })).json().data;
|
|
broadcasts.length = 0;
|
|
webviewDeleted.mockRejectedValueOnce(new Error('tab layout restoration failed'));
|
|
|
|
const response = await app.inject({ method: 'DELETE', url: `/api/webviews/${created.id}` });
|
|
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
expect(list).toEqual([created]);
|
|
expect(broadcasts).toEqual([]);
|
|
});
|
|
|
|
it('404s an unknown id', async () => {
|
|
expect((await app.inject({ method: 'DELETE', url: '/api/webviews/nope' })).statusCode).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/webviews/:id/open', () => {
|
|
it('mints a same-origin embed path for a proxied dashboard', async () => {
|
|
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
|
const data = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data;
|
|
expect(data.embedUrl).toMatch(/^\/webview\/[A-Za-z0-9_-]{16,}\/$/);
|
|
expect(capabilityFromProxyPath(data.embedUrl)).toBeTruthy();
|
|
});
|
|
|
|
it('returns no embed path in direct mode, where the iframe uses the real URL', async () => {
|
|
const id = (await create({ name: 'G', url: 'https://ok.example/', embedMode: 'direct' })).json().data.id;
|
|
const data = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data;
|
|
expect(data.embedUrl).toBeUndefined();
|
|
expect(data.webview.url).toBe('https://ok.example/');
|
|
});
|
|
|
|
it('reuses the capability across repeated opens instead of leaking one per click', async () => {
|
|
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
|
const first = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data.embedUrl;
|
|
const second = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data.embedUrl;
|
|
expect(second).toBe(first);
|
|
});
|
|
|
|
it('records lastOpenedAt', async () => {
|
|
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
|
await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
|
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
|
expect(typeof list[0].lastOpenedAt).toBe('number');
|
|
});
|
|
|
|
it('404s an unknown id', async () => {
|
|
expect((await app.inject({ method: 'POST', url: '/api/webviews/nope/open' })).statusCode).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('proxy route', () => {
|
|
it('refuses an unknown or expired capability', async () => {
|
|
const res = await app.inject({ method: 'GET', url: `/webview/${'Z'.repeat(32)}/` });
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it('redirects the prefix without a trailing slash, so relative URLs resolve inside it', async () => {
|
|
const cap = 'Y'.repeat(32);
|
|
const res = await app.inject({ method: 'GET', url: `/webview/${cap}` });
|
|
expect(res.statusCode).toBe(302);
|
|
expect(res.headers.location).toBe(`/webview/${cap}/`);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/webviews/probe', () => {
|
|
it('reports an unreachable target as a normal answer, not a 500', async () => {
|
|
// Port 1 is reserved and refuses instantly.
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/webviews/probe',
|
|
payload: { url: 'http://127.0.0.1:1/' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const probe = res.json().data;
|
|
expect(probe.reachable).toBe(false);
|
|
expect(probe.recommendedMode).toBe('proxy');
|
|
});
|
|
|
|
it('rejects an invalid URL up front', async () => {
|
|
const res = await app.inject({ method: 'POST', url: '/api/webviews/probe', payload: { url: 'file:///etc' } });
|
|
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');
|
|
}
|
|
});
|
|
});
|