From 7d2a8a3cdb79b378dee210544114c509860584ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 26 Jul 2026 01:08:10 +0800 Subject: [PATCH] fix(server): keep loopback trusted without the desktop process token 4c3b08c0 made `isLocalTrustedRequest` return early on `localAccessTokenConfigured`, so once the desktop shell injects CC_HAHA_LOCAL_ACCESS_TOKEN the loopback address stops being evidence of anything -- only the token grants trust. The system browser never has it. `shell.open`ing the Grok OAuth success page landed on {"error":"Unauthorized","message":"Missing H5 access token"} instead of the success HTML, and /preview-fs links, /local-file links and plain curl went the same way. With H5 access off it was worse: 403 "H5 access is disabled", which no H5 token can clear. The classifier had no way to tell the two apart. A scenario matrix over the policy functions returned the identical verdict for "system browser opens the OAuth page" and "malicious site fetches 127.0.0.1" -- the rule bought its safety by refusing every request that is not the app itself. What that early return actually added over the checks below it was one case: a reverse proxy on this machine perfectly mimicking loopback. A LAN client is already rejected on clientAddress, a cross-site fetch on Origin, and a tunnel forwarding its original Host on isLoopbackHost(url.hostname). Reaching the remaining case means running a process on the user's box, where ~/.claude is readable anyway. Loopback goes back to being trusted on its own. The boundary worth keeping is narrower than the whole surface, so requiresLocalAccessCredential pins it to /api/h5-access: another program on this machine must not be able to publish the user's sessions to the network. /api/h5-access/verify stays open -- checking a token the phone already holds is not a control-plane change -- and a server with no token configured does not lock itself out. Restoring loopback trust re-exposed an older hole that the blanket rule had been covering: `if (!origin) return true` also accepts cross-site subresource loads, since carries no Origin. Fetch Metadata separates them -- a real navigation sends Sec-Fetch-Mode: navigate, a subresource does not -- and clients that send no Fetch Metadata at all (curl, adapters, the CLI subprocess) are not a browser CSRF vector, so they stay trusted. This is net new protection; it existed neither before 4c3b08c0 nor after. desktopRuntime resolved the token inside a Promise.all with getServerUrl, so an IPC failure took the whole startup down with it. It degrades to null now, which is the same principle: the token raises what the shell may do, it is not a precondition for running. Verified end to end against a server started with the token injected: the OAuth success page returns 200 and its HTML, /api/status, /api/sessions and /api/adapters return 200 tokenless, while the control plane, an subresource, a cross-site fetch and a proxy hop all return 403. check:server is 224 files / 2379 passing, desktop 225 files / 2522. --- desktop/src/lib/desktopRuntime.test.ts | 27 +++++++ desktop/src/lib/desktopRuntime.ts | 7 +- src/server/__tests__/h5-access-auth.test.ts | 68 ++++++++++++++-- src/server/__tests__/h5-access-policy.test.ts | 80 ++++++++++++++++--- src/server/h5AccessPolicy.ts | 64 +++++++++++++-- src/server/index.ts | 17 ++-- 6 files changed, 234 insertions(+), 29 deletions(-) diff --git a/desktop/src/lib/desktopRuntime.test.ts b/desktop/src/lib/desktopRuntime.test.ts index 26b9d28b..5bc48852 100644 --- a/desktop/src/lib/desktopRuntime.test.ts +++ b/desktop/src/lib/desktopRuntime.test.ts @@ -156,6 +156,33 @@ describe('desktopRuntime browser H5 bootstrap', () => { expect(globalThis.fetch).toHaveBeenCalledTimes(1) }) + it('still starts when the desktop host cannot resolve the local access token', async () => { + // The token only raises what the shell may do — loopback is trusted without + // it — so losing it must degrade rather than block startup. + const serverUrl = 'http://127.0.0.1:59232' + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + runtime: { + getServerUrl: vi.fn().mockResolvedValue(serverUrl), + getLocalAccessToken: vi.fn().mockRejectedValue(new Error('ipc channel missing')), + }, + } + globalThis.fetch = vi.fn().mockResolvedValue( + healthOkResponse(), + ) as typeof fetch + + await expect(initializeDesktopServerUrl()).resolves.toBe(serverUrl) + + expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(serverUrl) + expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) + expect(consoleWarn).toHaveBeenCalled() + + consoleWarn.mockRestore() + }) + it('classifies browser H5 runtime using the desktop host boundary', () => { expect(isBrowserH5Runtime()).toBe(true) expect(isDesktopRuntime()).toBe(false) diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index 84ed337e..0ef0175e 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -165,7 +165,12 @@ export async function initializeDesktopServerUrl() { try { const [serverUrl, localAccessToken] = await Promise.all([ host.runtime.getServerUrl(), - host.runtime.getLocalAccessToken(), + // The process token only *raises* what the shell may do; loopback is + // trusted without it. Losing it must not take the whole app down with it. + host.runtime.getLocalAccessToken().catch((error) => { + console.warn('[desktop] local access token unavailable, continuing on loopback trust', error) + return null + }), ]) setBaseUrl(serverUrl) setAuthToken(localAccessToken) diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts index 6c16b6b8..268ebc62 100644 --- a/src/server/__tests__/h5-access-auth.test.ts +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -332,12 +332,20 @@ describe('remote H5 auth and CORS integration', () => { } }) - test('rejects a tokenless loopback proxy hop when desktop local auth is configured', async () => { + test('keeps tokenless loopback capabilities working when desktop local auth is configured', async () => { + // The desktop shell injects a process token, but the browser windows it + // opens (OAuth success pages, `/preview-fs` links) and local scripts cannot + // carry it. Loopback must stay trusted on its own. process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret' await restartRemoteServer() - const proxyShapedResponse = await fetch(`${baseUrl}/api/status`) - expect(proxyShapedResponse.status).toBe(403) + const tokenlessResponse = await fetch(`${baseUrl}/api/status`) + expect(tokenlessResponse.status).toBe(200) + await expect(tokenlessResponse.json()).resolves.toMatchObject({ status: 'ok' }) + + const oauthSuccessResponse = await fetch(`${baseUrl}/api/haha-grok-oauth/success`) + expect(oauthSuccessResponse.status).toBe(200) + expect(oauthSuccessResponse.headers.get('Content-Type')).toContain('text/html') const desktopResponse = await fetch(`${baseUrl}/api/status`, { headers: { Authorization: 'Bearer desktop-local-secret' }, @@ -346,6 +354,51 @@ describe('remote H5 auth and CORS integration', () => { await expect(desktopResponse.json()).resolves.toMatchObject({ status: 'ok' }) }) + test('still requires the desktop process token for the H5 control plane', async () => { + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret' + await restartRemoteServer() + + // Another browser or script on the same machine must not be able to publish + // the user's sessions to the network. + const tokenlessRead = await fetch(`${baseUrl}/api/h5-access`) + expect(tokenlessRead.status).toBe(403) + + const tokenlessEnable = await fetch(`${baseUrl}/api/h5-access/enable`, { method: 'POST' }) + expect(tokenlessEnable.status).toBe(403) + + const desktopEnable = await fetch(`${baseUrl}/api/h5-access/enable`, { + method: 'POST', + headers: { Authorization: 'Bearer desktop-local-secret' }, + }) + expect(desktopEnable.status).toBe(200) + await expect(desktopEnable.json()).resolves.toHaveProperty('token') + }) + + test('does not extend tokenless loopback trust to cross-site subresource loads', async () => { + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret' + await restartRemoteServer() + + // A malicious page embedding `` + // sends no Origin, so Fetch Metadata is what marks it as not-a-navigation. + const subresourceResponse = await fetch(`${baseUrl}/api/status`, { + headers: { + 'Sec-Fetch-Site': 'cross-site', + 'Sec-Fetch-Mode': 'no-cors', + 'Sec-Fetch-Dest': 'image', + }, + }) + expect(subresourceResponse.status).toBe(403) + + const navigationResponse = await fetch(`${baseUrl}/api/status`, { + headers: { + 'Sec-Fetch-Site': 'cross-site', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Dest': 'document', + }, + }) + expect(navigationResponse.status).toBe(200) + }) + test('enforces the pet bearer capability allowlist before API routing', async () => { process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret' process.env.CC_HAHA_PET_ACCESS_TOKEN = 'pet-capability-secret' @@ -478,8 +531,10 @@ describe('remote H5 auth and CORS integration', () => { body: JSON.stringify({ model: 'test', max_tokens: 8, messages: [] }), }) + // Loopback reaches the proxy with or without the process token; only the + // provider configuration decides the outcome. const disabledTokenlessResponse = await requestProxy(false) - expect(disabledTokenlessResponse.status).toBe(403) + expect(disabledTokenlessResponse.status).toBe(400) const disabledAuthorizedResponse = await requestProxy(true) expect(disabledAuthorizedResponse.status).toBe(400) @@ -489,8 +544,11 @@ describe('remote H5 auth and CORS integration', () => { await new H5AccessService().enable() + // Remote browsers on this same route still need the H5 token — covered by + // 'requires H5 token for remote browser proxy requests when H5 access is + // enabled'. const enabledTokenlessResponse = await requestProxy(false) - expect(enabledTokenlessResponse.status).toBe(401) + expect(enabledTokenlessResponse.status).toBe(400) const enabledAuthorizedResponse = await requestProxy(true) expect(enabledAuthorizedResponse.status).toBe(400) diff --git a/src/server/__tests__/h5-access-policy.test.ts b/src/server/__tests__/h5-access-policy.test.ts index 030655d8..3919a01d 100644 --- a/src/server/__tests__/h5-access-policy.test.ts +++ b/src/server/__tests__/h5-access-policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { classifyH5Request, isLoopbackHost, + requiresLocalAccessCredential, shouldBlockDisabledH5Access, shouldRequireH5Token, } from '../h5AccessPolicy.js' @@ -193,9 +194,7 @@ describe('h5AccessPolicy', () => { } }) - test('requires the configured local credential even when a proxy perfectly mimics loopback', () => { - const request = req('http://127.0.0.1:3456/api/h5-access') - const url = new URL(request.url) + test('requires the configured local credential to reach the H5 control plane', () => { const unauthorizedContext = { clientAddress: '127.0.0.1', localAccessTokenConfigured: true, @@ -206,15 +205,72 @@ describe('h5AccessPolicy', () => { localAccessAuthorized: true, } - expect(classifyH5Request(request, url, unauthorizedContext)).toBe('h5-browser') - expect(shouldBlockDisabledH5Access({ - request, - url, - h5Enabled: false, - explicitAuthRequired: false, - context: unauthorizedContext, - })).toBe(true) - expect(classifyH5Request(request, url, authorizedContext)).toBe('local-trusted') + for (const pathname of ['/api/h5-access', '/api/h5-access/enable']) { + expect(requiresLocalAccessCredential(pathname, unauthorizedContext)).toBe(true) + expect(requiresLocalAccessCredential(pathname, authorizedContext)).toBe(false) + } + + // Verifying a token the phone already holds is not a control-plane change, + // and an unmanaged (tokenless) server must not lock itself out either. + expect(requiresLocalAccessCredential('/api/h5-access/verify', unauthorizedContext)).toBe(false) + expect(requiresLocalAccessCredential('/api/h5-access', { clientAddress: '127.0.0.1' })).toBe(false) + }) + + test('keeps ordinary loopback capabilities usable without the desktop process token', () => { + // The desktop shell injects a process token, but the OAuth success page the + // system browser opens, a `/preview-fs` link and plain `curl` can never + // carry it. Gating loopback behind that token 401'd all of them. + const desktopContext = { + clientAddress: '127.0.0.1', + localAccessTokenConfigured: true, + localAccessAuthorized: false, + } + + for (const pathname of [ + '/api/haha-grok-oauth/success', + '/api/sessions', + '/preview-fs/session-1/index.html', + '/ws/session-1', + ]) { + const request = req(`http://127.0.0.1:3456${pathname}`) + const url = new URL(request.url) + + expect(classifyH5Request(request, url, desktopContext)).toBe('local-trusted') + expect(shouldRequireH5Token({ request, url, h5Enabled: true, context: desktopContext })).toBe(false) + expect(shouldBlockDisabledH5Access({ + request, + url, + h5Enabled: false, + explicitAuthRequired: false, + context: desktopContext, + })).toBe(false) + } + }) + + test('does not extend loopback trust to cross-site subresource loads', () => { + // `` from a malicious page reaches + // us without an Origin header; only Fetch Metadata separates it from a real + // local navigation. + const request = req('http://127.0.0.1:3456/api/sessions', { + headers: { + 'Sec-Fetch-Site': 'cross-site', + 'Sec-Fetch-Mode': 'no-cors', + 'Sec-Fetch-Dest': 'image', + }, + }) + expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser') + + for (const headers of [ + // The OAuth provider redirecting the browser back to us. + { 'Sec-Fetch-Site': 'cross-site', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document' }, + // The user opening the URL from the address bar or `shell.open`. + { 'Sec-Fetch-Site': 'none', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document' }, + // The local H5 shell calling its own API. + { 'Sec-Fetch-Site': 'same-origin', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Dest': 'empty' }, + ]) { + const allowed = req('http://127.0.0.1:3456/api/sessions', { headers }) + expect(classifyH5Request(allowed, new URL(allowed.url), localContext)).toBe('local-trusted') + } }) test('does not grant internal SDK trust to a request carrying proxy traces', () => { diff --git a/src/server/h5AccessPolicy.ts b/src/server/h5AccessPolicy.ts index fd9340f1..20737f9c 100644 --- a/src/server/h5AccessPolicy.ts +++ b/src/server/h5AccessPolicy.ts @@ -59,8 +59,29 @@ function isLoopbackBrowserOrigin(origin: string): boolean { return isLoopbackHost(parsed.hostname) } -function isLocalDesktopOrNavigationOrigin(origin: string | null): boolean { - if (!origin) return true +/** + * A cross-site subresource load (``, `