Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,5 @@
"post-commit": "bun x @rubriclab/package post-commit"
},
"type": "module",
"version": "0.0.76"
"version": "0.0.77"
}
55 changes: 55 additions & 0 deletions src/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,58 @@ describe('metering', () => {
await upstream.stop(true)
})
})

describe('browser pages', () => {
async function attempt(
headers: Record<string, string>
): 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 })
})
})
20 changes: 20 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,11 +346,31 @@ interface ProxyHandler {
handle(request: Request): Promise<Response>
}

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 })
Expand Down
Loading