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 (``, `