From 179bc3c3cc3ab260b32d3ecc9b4a2795b05ee42c Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 30 Sep 2026 23:12:26 -0400 Subject: [PATCH] proxy refuses web pages --- CHANGELOG.md | 1 + package.json | 2 +- src/proxy.test.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++++ src/proxy.ts | 20 +++++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a22f6d7..e5ec28d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- [2026-09-30] proxy refuses web pages - [2026-09-30] a native macOS app for tokenmaxx, with a menu bar item - [2026-09-30] macos login startup and uninstall all - [2026-09-30] grok accounts diff --git a/package.json b/package.json index 3417230..6ab9ba3 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.76" + "version": "0.0.77" } diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 2e0d3b6..9914790 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -269,3 +269,58 @@ describe('metering', () => { await upstream.stop(true) }) }) + +describe('browser pages', () => { + async function attempt( + headers: Record + ): Promise<{ status: number; injected: number }> { + let injected = 0 + const upstream = Bun.serve({ + fetch: () => new Response('{}', { headers: { 'content-type': 'application/json' } }), + hostname: '127.0.0.1', + port: 0 + }) + const proxy = startProxy({ + source: { + refresh: async () => undefined, + resolve: async () => { + injected += 1 + return { + accountId: 'acct-1', + baseUrl: `http://127.0.0.1:${upstream.port}`, + headers: { authorization: 'Bearer secret' } + } + } + } + }) + const response = await fetch(`http://127.0.0.1:${proxy.port}/anthropic/v1/messages`, { + body: '{}', + headers, + method: 'POST' + }) + await response.arrayBuffer() + await proxy.stop() + await upstream.stop(true) + return { injected, status: response.status } + } + + test('a local client reaches the upstream', async () => { + expect(await attempt({})).toEqual({ injected: 1, status: 200 }) + }) + + test('a cross-site page cannot spend the active account', async () => { + expect(await attempt({ origin: 'https://evil.example' })).toEqual({ injected: 0, status: 403 }) + }) + + test('a dns-rebinding page is refused by its host', async () => { + expect(await attempt({ host: 'evil.example:8459' })).toEqual({ injected: 0, status: 403 }) + }) + + test('a no-origin browser subresource request is refused', async () => { + expect(await attempt({ 'sec-fetch-site': 'cross-site' })).toEqual({ injected: 0, status: 403 }) + }) + + test('a page served from loopback stays allowed', async () => { + expect(await attempt({ origin: 'http://localhost:5173' })).toEqual({ injected: 1, status: 200 }) + }) +}) diff --git a/src/proxy.ts b/src/proxy.ts index c91140c..ea5a641 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -346,11 +346,31 @@ interface ProxyHandler { handle(request: Request): Promise } +const loopbackHosts = new Set(['127.0.0.1', 'localhost', '[::1]']) + +function fromWebPage(request: Request, url: URL): boolean { + if (!loopbackHosts.has(url.hostname)) { + return true + } + const origin = request.headers.get('origin') + if (origin !== null) { + return !URL.canParse(origin) || !loopbackHosts.has(new URL(origin).hostname) + } + const site = request.headers.get('sec-fetch-site') + return site !== null && site !== 'none' +} + function createProxyHandler(options: ProxyOptions): ProxyHandler { const doFetch = options.fetchImplementation ?? fetch return { async handle(request) { const url = new URL(request.url) + if (fromWebPage(request, url)) { + return new Response( + `${proxyFingerprint}: refused — only local clients may use this proxy, not web pages\n`, + { status: 403 } + ) + } const route = routeProvider(url.pathname) if (route === null) { return new Response(`${proxyFingerprint}: unknown route\n`, { status: 404 })