diff --git a/adapters/common/__tests__/http-client.test.ts b/adapters/common/__tests__/http-client.test.ts index 65549f12..1eae1bc7 100644 --- a/adapters/common/__tests__/http-client.test.ts +++ b/adapters/common/__tests__/http-client.test.ts @@ -41,6 +41,18 @@ describe('AdapterHttpClient', () => { expect(body.workDir).toBe('/path/to/project') }) + it('authenticates requests with the desktop local access token', async () => { + client = new AdapterHttpClient('ws://127.0.0.1:3456', { + localAccessToken: 'adapter-secret', + }) + globalThis.fetch = mock(() => Promise.resolve(Response.json({ projects: [] }))) as any + + await client.listRecentProjects() + + const init = (globalThis.fetch as any).mock.calls[0][1] as RequestInit + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer adapter-secret') + }) + it('listRecentProjects calls GET /api/sessions/recent-projects', async () => { const mockProjects = [ { projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 }, diff --git a/adapters/common/__tests__/ws-bridge.test.ts b/adapters/common/__tests__/ws-bridge.test.ts index 5cbb8886..80411600 100644 --- a/adapters/common/__tests__/ws-bridge.test.ts +++ b/adapters/common/__tests__/ws-bridge.test.ts @@ -115,6 +115,22 @@ describe('WsBridge: handler serialization', () => { return connections[0]! } + it('authenticates websocket connections with the desktop local access token', async () => { + let requestUrl = '' + server.once('connection', (_ws, request) => { + requestUrl = request.url || '' + }) + const bridge = new WsBridge(serverUrl, 'test', 'adapter secret/with spaces') + + bridge.connectSession('chat-auth', 'sess-auth') + expect(await bridge.waitForOpen('chat-auth')).toBe(true) + await waitForServerConnection() + + expect(new URL(requestUrl, serverUrl).searchParams.get('token')) + .toBe('adapter secret/with spaces') + bridge.destroy() + }) + it('processes handler calls in strict FIFO order per chatId', async () => { const bridge = new WsBridge(serverUrl, 'test') const events: string[] = [] diff --git a/adapters/common/http-client.ts b/adapters/common/http-client.ts index 18c9da18..a0dbbd48 100644 --- a/adapters/common/http-client.ts +++ b/adapters/common/http-client.ts @@ -73,10 +73,14 @@ export type SkillSummary = { export class AdapterHttpClient { readonly httpBaseUrl: string private readonly allowedProjectRoots: string[] + private readonly localAccessToken: string | null /** Default timeout for HTTP requests (30 seconds) */ private static readonly DEFAULT_TIMEOUT_MS = 30_000 - constructor(wsUrl: string, options?: { allowedProjectRoots?: string[] }) { + constructor( + wsUrl: string, + options?: { allowedProjectRoots?: string[], localAccessToken?: string }, + ) { this.httpBaseUrl = wsUrl .replace(/^ws:/, 'http:') .replace(/^wss:/, 'https:') @@ -84,6 +88,17 @@ export class AdapterHttpClient { this.allowedProjectRoots = (options?.allowedProjectRoots ?? []) .map(resolveExistingProjectPath) .filter((value): value is string => Boolean(value)) + this.localAccessToken = options?.localAccessToken?.trim() || + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN?.trim() || + null + } + + private request(pathname: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers) + if (this.localAccessToken) { + headers.set('Authorization', `Bearer ${this.localAccessToken}`) + } + return fetch(`${this.httpBaseUrl}${pathname}`, { ...init, headers }) } /** Create an AbortController with timeout */ @@ -99,7 +114,7 @@ export class AdapterHttpClient { async createSession(workDir: string): Promise { const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}/api/sessions`, { + const res = await this.request('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workDir }), @@ -119,7 +134,7 @@ export class AdapterHttpClient { async sessionExists(sessionId: string): Promise { const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, { + const res = await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, { signal: controller.signal, }) if (res.status === 404) return false @@ -136,7 +151,7 @@ export class AdapterHttpClient { async listRecentProjects(): Promise { const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, { + const res = await this.request('/api/sessions/recent-projects', { signal: controller.signal, }) if (!res.ok) { @@ -202,7 +217,7 @@ export class AdapterHttpClient { async getGitInfo(sessionId: string): Promise { const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, { + const res = await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/git-info`, { signal: controller.signal, }) if (!res.ok) { @@ -218,7 +233,7 @@ export class AdapterHttpClient { async getTasksForSession(sessionId: string): Promise { const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, { + const res = await this.request(`/api/tasks/lists/${encodeURIComponent(sessionId)}`, { signal: controller.signal, }) if (!res.ok) { @@ -246,7 +261,7 @@ export class AdapterHttpClient { const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}/api/sessions${suffix}`, { + const res = await this.request(`/api/sessions${suffix}`, { signal: controller.signal, }) if (!res.ok) { @@ -262,7 +277,7 @@ export class AdapterHttpClient { async listProviders(): Promise<{ providers: ProviderSummary[]; activeId: string | null }> { const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}/api/providers`, { + const res = await this.request('/api/providers', { signal: controller.signal, }) if (!res.ok) { @@ -286,7 +301,7 @@ export class AdapterHttpClient { async listModels(): Promise<{ models: ModelSummary[]; provider: { id: string; name: string } | null }> { const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}/api/models`, { + const res = await this.request('/api/models', { signal: controller.signal, }) if (!res.ok) { @@ -302,7 +317,7 @@ export class AdapterHttpClient { async getCurrentModel(): Promise<{ model: ModelSummary }> { const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}/api/models/current`, { + const res = await this.request('/api/models/current', { signal: controller.signal, }) if (!res.ok) { @@ -323,7 +338,7 @@ export class AdapterHttpClient { const params = new URLSearchParams({ cwd }) const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}/api/skills?${params.toString()}`, { + const res = await this.request(`/api/skills?${params.toString()}`, { signal: controller.signal, }) if (!res.ok) { @@ -339,7 +354,7 @@ export class AdapterHttpClient { private async postJson(pathname: string): Promise { const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}${pathname}`, { + const res = await this.request(pathname, { method: 'POST', signal: controller.signal, }) @@ -355,7 +370,7 @@ export class AdapterHttpClient { private async putJson(pathname: string, body: Record): Promise { const { controller, timer } = this.createTimeoutController() try { - const res = await fetch(`${this.httpBaseUrl}${pathname}`, { + const res = await this.request(pathname, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/adapters/common/ws-bridge.ts b/adapters/common/ws-bridge.ts index 413e2d2c..f6e47251 100644 --- a/adapters/common/ws-bridge.ts +++ b/adapters/common/ws-bridge.ts @@ -50,12 +50,18 @@ export class WsBridge { private handlerChains = new Map>() private serverUrl: string private platform: string + private localAccessToken: string | null private heartbeatTimer: ReturnType | null = null private destroyed = false - constructor(serverUrl: string, platform: string) { + constructor( + serverUrl: string, + platform: string, + localAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN, + ) { this.serverUrl = serverUrl.replace(/\/$/, '') this.platform = platform + this.localAccessToken = localAccessToken?.trim() || null this.startHeartbeat() } @@ -162,7 +168,10 @@ export class WsBridge { // ------- internal ------- private connect(chatId: string, sessionId: string): void { - const url = `${this.serverUrl}/ws/${sessionId}` + const url = new URL(`${this.serverUrl}/ws/${sessionId}`) + if (this.localAccessToken) { + url.searchParams.set('token', this.localAccessToken) + } const ws = new WebSocket(url) // Cancel any pending reconnect timer for this chatId diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index b1bf8842..3be04884 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -72,6 +72,7 @@ const updateCheckOptions: Validator = value => { export const ELECTRON_IPC_VALIDATORS = { [ELECTRON_IPC_CHANNELS.appGetVersion]: noPayload, [ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload, + [ELECTRON_IPC_CHANNELS.runtimeGetLocalAccessToken]: noPayload, [ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke, [ELECTRON_IPC_CHANNELS.clipboardReadText]: noPayload, [ELECTRON_IPC_CHANNELS.clipboardWriteText]: stringPayload, diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index 1ea23b8d..6656fdf5 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -1,6 +1,7 @@ export const ELECTRON_IPC_CHANNELS = { appGetVersion: 'desktop:app:get-version', runtimeGetServerUrl: 'desktop:runtime:get-server-url', + runtimeGetLocalAccessToken: 'desktop:runtime:get-local-access-token', commandInvoke: 'desktop:command:invoke', clipboardReadText: 'desktop:clipboard:read-text', clipboardWriteText: 'desktop:clipboard:write-text', diff --git a/desktop/electron/main.security.test.ts b/desktop/electron/main.security.test.ts index 38367610..df00c501 100644 --- a/desktop/electron/main.security.test.ts +++ b/desktop/electron/main.security.test.ts @@ -1,9 +1,10 @@ import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { + configureLocalServerRequestAuth, configurePreviewSessionPermissions, - PREVIEW_SESSION_PARTITION, + createPreviewSessionPartition, } from './services/previewSession' const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts')) @@ -12,16 +13,21 @@ const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts' const mainSource = readFileSync(path.join(desktopRoot, 'electron', 'main.ts'), 'utf8') describe('Electron preview security boundary', () => { - it('uses a dedicated in-memory session partition for remote previews', () => { - expect(PREVIEW_SESSION_PARTITION).toBe('cc-haha-preview') - expect(PREVIEW_SESSION_PARTITION.startsWith('persist:')).toBe(false) - expect(mainSource).toContain('partition: PREVIEW_SESSION_PARTITION') + it('uses a fresh in-memory session partition for every remote preview', () => { + const firstPartition = createPreviewSessionPartition() + const secondPartition = createPreviewSessionPartition() + + expect(firstPartition.startsWith('cc-haha-preview-')).toBe(true) + expect(firstPartition.startsWith('persist:')).toBe(false) + expect(secondPartition).not.toBe(firstPartition) + expect(mainSource).toContain('partition: createPreviewSessionPartition()') }) it('denies preview permission checks and requests by default', () => { const handlers: { check?: (...args: unknown[]) => boolean request?: (...args: unknown[]) => void + beforeSendHeaders?: (...args: unknown[]) => void } = {} const session = { setPermissionCheckHandler(handler: (...args: unknown[]) => boolean) { @@ -30,13 +36,42 @@ describe('Electron preview security boundary', () => { setPermissionRequestHandler(handler: (...args: unknown[]) => void) { handlers.request = handler }, + webRequest: { + onBeforeSendHeaders(handler: (...args: unknown[]) => void) { + handlers.beforeSendHeaders = handler + }, + }, } configurePreviewSessionPermissions(session as never) + configureLocalServerRequestAuth(session.webRequest as never, () => ({ + serverUrl: 'http://127.0.0.1:49321', + token: 'preview-local-token', + })) expect(handlers.check?.()).toBe(false) const callback = (allowed: boolean) => expect(allowed).toBe(false) handlers.request?.(null, 'media', callback) expect(mainSource).toContain('configurePreviewSessionPermissions(view.webContents.session)') + expect(mainSource).toContain('mainWindow.webContents.session.webRequest') + + const localCallback = vi.fn() + handlers.beforeSendHeaders?.({ + url: 'http://127.0.0.1:49321/preview-fs/session/index.css', + requestHeaders: { Accept: 'text/css' }, + }, localCallback) + expect(localCallback).toHaveBeenCalledWith({ + requestHeaders: { + Accept: 'text/css', + Authorization: 'Bearer preview-local-token', + }, + }) + + const remoteCallback = vi.fn() + handlers.beforeSendHeaders?.({ + url: 'https://example.com/app.js', + requestHeaders: { Accept: '*/*' }, + }, remoteCallback) + expect(remoteCallback).toHaveBeenCalledWith({ requestHeaders: { Accept: '*/*' } }) }) }) diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 92e67814..b7406d7e 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -20,8 +20,10 @@ import { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke' import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal' import { ElectronPreviewService, type PreviewBounds } from './services/preview' import { + configureLocalServerRequestAuth, configurePreviewSessionPermissions, - PREVIEW_SESSION_PARTITION, + createPreviewSessionPartition, + type PreviewLocalAccess, } from './services/previewSession' import { applyStartupPortableMode, @@ -150,6 +152,14 @@ function getServerRuntime() { return serverRuntime } +function resolveLocalServerAccess(): PreviewLocalAccess | null { + const runtime = getServerRuntime() + const serverUrl = runtime.getActiveServerUrl() + return serverUrl + ? { serverUrl, token: runtime.getLocalAccessToken() } + : null +} + function getUpdaterService() { const smokeUpdater = createUpdateSmokeUpdaterFromEnv(process.env) updaterService ??= new ElectronUpdaterService(smokeUpdater ?? autoUpdater, { @@ -189,13 +199,17 @@ function getPreviewService() { const view = new WebContentsView({ webPreferences: { preload: previewPreloadPath(), - partition: PREVIEW_SESSION_PARTITION, + partition: createPreviewSessionPartition(), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }) configurePreviewSessionPermissions(view.webContents.session) + configureLocalServerRequestAuth( + view.webContents.session.webRequest, + resolveLocalServerAccess, + ) installPreviewNavigationGuards(view.webContents, { openExternal: openExternalUrl }) return view }, @@ -262,6 +276,10 @@ function registerIpcHandlers() { }) registerHandler(ELECTRON_IPC_CHANNELS.appGetVersion, () => app.getVersion()) registerHandler(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl, () => getServerRuntime().getServerUrl()) + registerHandler( + ELECTRON_IPC_CHANNELS.runtimeGetLocalAccessToken, + () => getServerRuntime().getLocalAccessToken(), + ) registerHandler(ELECTRON_IPC_CHANNELS.commandInvoke, (_event, payload) => handleCommandInvoke(payload)) registerHandler(ELECTRON_IPC_CHANNELS.clipboardReadText, () => clipboard.readText()) registerHandler(ELECTRON_IPC_CHANNELS.clipboardWriteText, (_event, payload) => clipboard.writeText(String(payload))) @@ -363,6 +381,10 @@ async function createMainWindow() { sandbox: true, }, }) + configureLocalServerRequestAuth( + mainWindow.webContents.session.webRequest, + resolveLocalServerAccess, + ) installMainWindowNavigationGuards(mainWindow.webContents, { openExternal: openExternalUrl }) installPreviewCleanupOnRendererNavigation(mainWindow.webContents, () => { diff --git a/desktop/electron/services/previewSession.ts b/desktop/electron/services/previewSession.ts index 2803f813..231ad453 100644 --- a/desktop/electron/services/previewSession.ts +++ b/desktop/electron/services/previewSession.ts @@ -1,6 +1,44 @@ +import { randomUUID } from 'node:crypto' import type { Session } from 'electron' -export const PREVIEW_SESSION_PARTITION = 'cc-haha-preview' +const PREVIEW_SESSION_PARTITION_PREFIX = 'cc-haha-preview-' + +export function createPreviewSessionPartition(): string { + return `${PREVIEW_SESSION_PARTITION_PREFIX}${randomUUID()}` +} + +export type PreviewLocalAccess = { + serverUrl: string + token: string +} + +function sameOrigin(left: string, right: string): boolean { + try { + return new URL(left).origin === new URL(right).origin + } catch { + return false + } +} + +export function configureLocalServerRequestAuth( + webRequest: Pick, + resolveLocalAccess: () => PreviewLocalAccess | null, +): void { + webRequest.onBeforeSendHeaders((details, callback) => { + const localAccess = resolveLocalAccess() + if (!localAccess || !sameOrigin(details.url, localAccess.serverUrl)) { + callback({ requestHeaders: details.requestHeaders }) + return + } + + callback({ + requestHeaders: { + ...details.requestHeaders, + Authorization: `Bearer ${localAccess.token}`, + }, + }) + }) +} export function configurePreviewSessionPermissions( session: Pick, diff --git a/desktop/electron/services/serverRuntime.test.ts b/desktop/electron/services/serverRuntime.test.ts index 9ebf72af..665715bf 100644 --- a/desktop/electron/services/serverRuntime.test.ts +++ b/desktop/electron/services/serverRuntime.test.ts @@ -125,6 +125,21 @@ describe('ElectronServerRuntime', () => { .not.toBe(path.join(homedir(), '.claude')) }) + it('shares one unguessable local access token with server, adapters, and renderer', async () => { + const runtime = createRuntime() + + await runtime.startServer() + + const token = runtime.getLocalAccessToken() + expect(token.length).toBeGreaterThanOrEqual(32) + expect(sidecarMocks.serverPlans[0]!.env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe(token) + for (const adapter of sidecarMocks.spawnSidecar.mock.calls + .map(([plan]) => plan) + .filter(plan => plan.args[0] === 'adapters')) { + expect(adapter.env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe(token) + } + }) + it('persists a server startup failure through the sanitized host-log boundary', async () => { sidecarMocks.spawnError = new Error('spawn failed') const runtime = createRuntime({ diff --git a/desktop/electron/services/serverRuntime.ts b/desktop/electron/services/serverRuntime.ts index db2dc793..1a2a8132 100644 --- a/desktop/electron/services/serverRuntime.ts +++ b/desktop/electron/services/serverRuntime.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import { randomBytes } from 'node:crypto' import { appendHostDiagnostic, createAdapterPlan, @@ -97,6 +98,7 @@ export class ElectronServerRuntime { private readonly baseEnv: NodeJS.ProcessEnv private readonly deps: ServerRuntimeDeps private readonly resolveSystemProxy?: (url: string) => Promise + private readonly localAccessToken = randomBytes(32).toString('base64url') private sidecarEnvPromise: Promise | null = null private server: ActiveServer | null = null private adapters: SidecarChild[] = [] @@ -136,6 +138,14 @@ export class ElectronServerRuntime { return await this.startServer() } + getLocalAccessToken(): string { + return this.localAccessToken + } + + getActiveServerUrl(): string | null { + return this.server?.url ?? null + } + restartAdaptersSidecars(): Promise { if (this.adapterRestartPromise) return this.adapterRestartPromise const operation = this.restartAdaptersSidecarsOnce() @@ -183,7 +193,7 @@ export class ElectronServerRuntime { const url = `http://${SERVER_CONTROL_HOST}:${port}` const logs: string[] = [] let startState: ServerStartState | null = null - const env = await this.resolveSidecarBaseEnv() + const env = this.withLocalAccessToken(await this.resolveSidecarBaseEnv()) const plan = createServerPlan({ desktopRoot: this.desktopRoot, appRoot: this.appRoot, @@ -245,7 +255,7 @@ export class ElectronServerRuntime { startState?: ServerStartState, activeServer?: ActiveServer, ): Promise { - const env = await this.resolveSidecarBaseEnv() + const env = this.withLocalAccessToken(await this.resolveSidecarBaseEnv()) const isCurrentGeneration = () => { if (startState?.failure) return false if (activeServer && this.server !== activeServer) return false @@ -293,6 +303,13 @@ export class ElectronServerRuntime { } } + private withLocalAccessToken(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { + ...env, + CC_HAHA_LOCAL_ACCESS_TOKEN: this.localAccessToken, + } + } + private removeOwnedAdapters(owned: SidecarChild[] | undefined, removed: SidecarChild[]) { if (!owned?.length || !removed.length) return const removedSet = new Set(removed) diff --git a/desktop/src/lib/desktopHost/browserHost.ts b/desktop/src/lib/desktopHost/browserHost.ts index f0722c95..7db3fda1 100644 --- a/desktop/src/lib/desktopHost/browserHost.ts +++ b/desktop/src/lib/desktopHost/browserHost.ts @@ -43,6 +43,9 @@ export const browserHost: DesktopHost = { async getServerUrl() { unsupported('Resolving the bundled server URL') }, + async getLocalAccessToken() { + unsupported('Resolving the bundled server access token') + }, }, app: { async getVersion() { diff --git a/desktop/src/lib/desktopHost/contract.test.ts b/desktop/src/lib/desktopHost/contract.test.ts index cb24b341..4a7cc8e6 100644 --- a/desktop/src/lib/desktopHost/contract.test.ts +++ b/desktop/src/lib/desktopHost/contract.test.ts @@ -23,6 +23,7 @@ describe('desktop host contract', () => { it('rejects desktop-only browser calls with actionable errors', async () => { await expect(browserHost.runtime.getServerUrl()).rejects.toThrow('desktop app runtime') + await expect(browserHost.runtime.getLocalAccessToken()).rejects.toThrow('desktop app runtime') await expect(browserHost.dialogs.open({ directory: true })).rejects.toThrow('desktop app runtime') await expect(browserHost.shell.openPath('/tmp/report.md')).rejects.toThrow('desktop app runtime') await expect(browserHost.terminal.spawn({ cwd: '/tmp', cols: 80, rows: 24 })).rejects.toThrow( diff --git a/desktop/src/lib/desktopHost/electronHost.ts b/desktop/src/lib/desktopHost/electronHost.ts index 07184b4e..199c366b 100644 --- a/desktop/src/lib/desktopHost/electronHost.ts +++ b/desktop/src/lib/desktopHost/electronHost.ts @@ -75,6 +75,7 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost { }, runtime: { getServerUrl: () => invoke(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl), + getLocalAccessToken: () => invoke(ELECTRON_IPC_CHANNELS.runtimeGetLocalAccessToken), }, app: { getVersion: () => invoke(ELECTRON_IPC_CHANNELS.appGetVersion), diff --git a/desktop/src/lib/desktopHost/types.ts b/desktop/src/lib/desktopHost/types.ts index b8ac7d6e..2ecd592f 100644 --- a/desktop/src/lib/desktopHost/types.ts +++ b/desktop/src/lib/desktopHost/types.ts @@ -142,6 +142,7 @@ export type DesktopHost = { capabilities: DesktopHostCapabilities runtime: { getServerUrl(): Promise + getLocalAccessToken(): Promise } app: { getVersion(): Promise diff --git a/desktop/src/lib/desktopRuntime.test.ts b/desktop/src/lib/desktopRuntime.test.ts index 58a9460c..26b9d28b 100644 --- a/desktop/src/lib/desktopRuntime.test.ts +++ b/desktop/src/lib/desktopRuntime.test.ts @@ -138,6 +138,7 @@ describe('desktopRuntime browser H5 bootstrap', () => { isDesktop: true, runtime: { getServerUrl: vi.fn().mockResolvedValue(serverUrl), + getLocalAccessToken: vi.fn().mockResolvedValue('desktop-local-token'), }, } globalThis.fetch = vi.fn().mockResolvedValue( @@ -148,7 +149,7 @@ describe('desktopRuntime browser H5 bootstrap', () => { expect(window.desktopHost.runtime.getServerUrl).toHaveBeenCalledTimes(1) expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(serverUrl) - expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) + expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith('desktop-local-token') expect(globalThis.fetch).toHaveBeenCalledWith(`${serverUrl}/health`, { cache: 'no-store', }) @@ -178,6 +179,7 @@ describe('desktopRuntime browser H5 bootstrap', () => { isDesktop: true, runtime: { getServerUrl: vi.fn().mockRejectedValue(error), + getLocalAccessToken: vi.fn().mockResolvedValue('desktop-local-token'), }, } diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index c1435602..84ed337e 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -163,9 +163,12 @@ export async function initializeDesktopServerUrl() { } try { - const serverUrl = await host.runtime.getServerUrl() + const [serverUrl, localAccessToken] = await Promise.all([ + host.runtime.getServerUrl(), + host.runtime.getLocalAccessToken(), + ]) setBaseUrl(serverUrl) - setAuthToken(null) + setAuthToken(localAccessToken) await waitForHealth(serverUrl) markDesktopServerReady() return serverUrl diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts index 0c6e2b83..891c46cb 100644 --- a/desktop/src/stores/updateStore.test.ts +++ b/desktop/src/stores/updateStore.test.ts @@ -484,6 +484,7 @@ describe('updateStore', () => { updates: true, }, runtime: { + ...browserHost.runtime, getServerUrl, }, updates: { diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts index 7c2b093a..23b0bc85 100644 --- a/src/server/__tests__/h5-access-auth.test.ts +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -21,6 +21,7 @@ let originalAnthropicApiKey: string | undefined let originalH5DistDir: string | undefined let originalClaudeAppRoot: string | undefined let originalServerAuthRequired: string | undefined +let originalLocalAccessToken: string | undefined let originalServerPort = 3456 const PHONE_ORIGIN = 'https://phone.example' @@ -192,11 +193,13 @@ beforeEach(async () => { originalH5DistDir = process.env.CLAUDE_H5_DIST_DIR originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT originalServerAuthRequired = process.env.SERVER_AUTH_REQUIRED + originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN originalServerPort = ProviderService.getServerPort() process.env.CLAUDE_CONFIG_DIR = tmpDir const h5DistDir = path.join(tmpDir, 'dist') process.env.CLAUDE_H5_DIST_DIR = h5DistDir delete process.env.ANTHROPIC_API_KEY + delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN await fs.mkdir(path.join(h5DistDir, 'assets'), { recursive: true }) await fs.writeFile( path.join(h5DistDir, 'index.html'), @@ -224,6 +227,8 @@ afterEach(async () => { else process.env.CLAUDE_APP_ROOT = originalClaudeAppRoot if (originalServerAuthRequired === undefined) delete process.env.SERVER_AUTH_REQUIRED else process.env.SERVER_AUTH_REQUIRED = originalServerAuthRequired + if (originalLocalAccessToken === undefined) delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN + else process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = originalLocalAccessToken await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -292,6 +297,20 @@ describe('remote H5 auth and CORS integration', () => { } }) + test('rejects a tokenless loopback proxy hop when desktop local auth is configured', async () => { + 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 desktopResponse = await fetch(`${baseUrl}/api/status`, { + headers: { Authorization: 'Bearer desktop-local-secret' }, + }) + expect(desktopResponse.status).toBe(200) + await expect(desktopResponse.json()).resolves.toMatchObject({ status: 'ok' }) + }) + test('does not keep retired Tauri origins trusted after Electron replacement', async () => { const response = await fetch(`${baseUrl}/api/status`, { headers: { diff --git a/src/server/__tests__/h5-access-policy.test.ts b/src/server/__tests__/h5-access-policy.test.ts index 8c8719ac..030655d8 100644 --- a/src/server/__tests__/h5-access-policy.test.ts +++ b/src/server/__tests__/h5-access-policy.test.ts @@ -57,6 +57,18 @@ describe('h5AccessPolicy', () => { expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: remoteContext })).toBe(false) }) + test('accepts an SDK route only after its session token is authorized', () => { + const request = req('http://127.0.0.1:3456/sdk/session-1?token=sdk-secret') + const configuredContext = { + clientAddress: '127.0.0.1', + localAccessTokenConfigured: true, + localAccessAuthorized: false, + internalSdkAuthorized: true, + } + + expect(classifyH5Request(request, new URL(request.url), configuredContext)).toBe('internal-sdk') + }) + test('keeps adapter API routes tokenless for local integrations', () => { const request = req('http://127.0.0.1:3456/api/adapters') expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted') @@ -181,6 +193,30 @@ 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) + const unauthorizedContext = { + clientAddress: '127.0.0.1', + localAccessTokenConfigured: true, + localAccessAuthorized: false, + } + const authorizedContext = { + ...unauthorizedContext, + 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') + }) + test('does not grant internal SDK trust to a request carrying proxy traces', () => { const request = req('http://127.0.0.1:3456/sdk/session-1', { headers: { 'X-Forwarded-For': '203.0.113.9' }, diff --git a/src/server/__tests__/local-access-auth.test.ts b/src/server/__tests__/local-access-auth.test.ts new file mode 100644 index 00000000..6d4423b5 --- /dev/null +++ b/src/server/__tests__/local-access-auth.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + hasConfiguredLocalAccessToken, + isLocalAccessAuthorized, + LOCAL_ACCESS_TOKEN_ENV, +} from '../localAccessAuth.js' + +const originalToken = process.env[LOCAL_ACCESS_TOKEN_ENV] + +afterEach(() => { + if (originalToken === undefined) { + delete process.env[LOCAL_ACCESS_TOKEN_ENV] + } else { + process.env[LOCAL_ACCESS_TOKEN_ENV] = originalToken + } +}) + +describe('localAccessAuth', () => { + test('accepts only the configured process token', () => { + process.env[LOCAL_ACCESS_TOKEN_ENV] = 'desktop-secret' + + expect(hasConfiguredLocalAccessToken()).toBe(true) + expect(isLocalAccessAuthorized(new Request('http://127.0.0.1:3456/api/status', { + headers: { Authorization: 'Bearer desktop-secret' }, + }))).toBe(true) + expect(isLocalAccessAuthorized(new Request('http://127.0.0.1:3456/api/status'), 'desktop-secret')).toBe(true) + expect(isLocalAccessAuthorized(new Request('http://127.0.0.1:3456/api/status'), 'wrong')).toBe(false) + }) + + test('stays disabled when the process token is absent', () => { + delete process.env[LOCAL_ACCESS_TOKEN_ENV] + + expect(hasConfiguredLocalAccessToken()).toBe(false) + expect(isLocalAccessAuthorized(new Request('http://127.0.0.1:3456/api/status'), 'anything')).toBe(false) + }) +}) diff --git a/src/server/h5AccessPolicy.ts b/src/server/h5AccessPolicy.ts index ccef659b..fd9340f1 100644 --- a/src/server/h5AccessPolicy.ts +++ b/src/server/h5AccessPolicy.ts @@ -1,6 +1,9 @@ export type H5RequestKind = 'local-trusted' | 'internal-sdk' | 'h5-browser' export type H5RequestContext = { clientAddress: string | null + localAccessTokenConfigured?: boolean + localAccessAuthorized?: boolean + internalSdkAuthorized?: boolean } const LOCAL_DESKTOP_ORIGINS = new Set(['file://']) @@ -71,6 +74,10 @@ function isLocalTrustedRequest( context: H5RequestContext, origin: string | null, ): boolean { + if (context.localAccessTokenConfigured) { + return context.localAccessAuthorized === true + } + const clientAddress = context.clientAddress if (!clientAddress) return false if (hasProxyTraceHeaders(request.headers)) return false @@ -96,7 +103,7 @@ export function classifyH5Request( return localTrusted ? 'local-trusted' : 'h5-browser' } - if (url.pathname.startsWith('/sdk/') && localTrusted) { + if (url.pathname.startsWith('/sdk/') && (localTrusted || context.internalSdkAuthorized)) { return 'internal-sdk' } diff --git a/src/server/index.ts b/src/server/index.ts index 9d6926ae..f761361d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -28,6 +28,10 @@ import { handleStaticH5Request } from './staticH5.js' import { classifyH5Request, shouldBlockDisabledH5Access, shouldRequireH5Token } from './h5AccessPolicy.js' import { H5AccessService } from './services/h5AccessService.js' import { refreshDisconnectGraceMs } from './ws/disconnectGraceConfig.js' +import { + hasConfiguredLocalAccessToken, + isLocalAccessAuthorized, +} from './localAccessAuth.js' function readArgValue(flag: string): string | undefined { const args = process.argv.slice(2) @@ -164,7 +168,19 @@ export function startServer(port = PORT, host = HOST) { const url = new URL(req.url) const origin = req.headers.get('Origin') const clientAddress = server.requestIP(req)?.address ?? null - const h5RequestContext = { clientAddress } + const localTokenOverride = url.searchParams.get('localToken') ?? url.searchParams.get('token') + const sdkSessionId = url.pathname.startsWith('/sdk/') + ? url.pathname.split('/').pop() || '' + : '' + const sdkToken = url.searchParams.get('token') + const h5RequestContext = { + clientAddress, + localAccessTokenConfigured: hasConfiguredLocalAccessToken(), + localAccessAuthorized: isLocalAccessAuthorized(req, localTokenOverride), + internalSdkAuthorized: Boolean( + sdkSessionId && sdkToken && conversationService.authorizeSdkConnection(sdkSessionId, sdkToken), + ), + } const h5Settings = await h5AccessService.getSettings() const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl) const cors = await resolveCors(origin, url.origin, { diff --git a/src/server/localAccessAuth.ts b/src/server/localAccessAuth.ts new file mode 100644 index 00000000..fb1b2260 --- /dev/null +++ b/src/server/localAccessAuth.ts @@ -0,0 +1,37 @@ +import { timingSafeEqual } from 'node:crypto' + +export const LOCAL_ACCESS_TOKEN_ENV = 'CC_HAHA_LOCAL_ACCESS_TOKEN' + +function configuredLocalAccessToken(): string | null { + const token = process.env[LOCAL_ACCESS_TOKEN_ENV]?.trim() + return token || null +} + +function tokensEqual(actual: string, expected: string): boolean { + const actualBuffer = Buffer.from(actual) + const expectedBuffer = Buffer.from(expected) + return actualBuffer.length === expectedBuffer.length && + timingSafeEqual(actualBuffer, expectedBuffer) +} + +function bearerToken(request: Request): string | null { + const authorization = request.headers.get('Authorization') + if (!authorization) return null + const [scheme, token] = authorization.split(' ') + return scheme === 'Bearer' && token ? token : null +} + +export function hasConfiguredLocalAccessToken(): boolean { + return configuredLocalAccessToken() !== null +} + +export function isLocalAccessAuthorized( + request: Request, + tokenOverride?: string | null, +): boolean { + const expected = configuredLocalAccessToken() + if (!expected) return false + + const candidate = tokenOverride ?? bearerToken(request) + return candidate ? tokensEqual(candidate, expected) : false +} diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index 4025f90d..e0784ac0 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -6,6 +6,7 @@ */ import { H5AccessService } from '../services/h5AccessService.js' +import { isLocalAccessAuthorized } from '../localAccessAuth.js' type AuthResult = { valid: boolean; error?: string } @@ -48,6 +49,10 @@ export async function validateRequestAuth( req: Request, tokenOverride?: string | null, ): Promise { + if (isLocalAccessAuthorized(req, tokenOverride)) { + return { valid: true } + } + const anthropicAuth = validateAuth(req) if (anthropicAuth.valid) { return anthropicAuth diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index e6d48eff..fc87d980 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -59,6 +59,15 @@ import { getDisconnectGraceMs } from './disconnectGraceConfig.js' const settingsService = new SettingsService() const providerService = new ProviderService() +function buildSdkWebSocketUrl( + ws: ServerWebSocket, + sessionId: string, +): string { + const url = new URL(`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}`) + url.searchParams.set('token', crypto.randomUUID()) + return url.toString() +} + /** * Cache slash commands from CLI init messages, keyed by sessionId. */ @@ -931,9 +940,7 @@ async function restartSessionWithPermissionMode( ...await getRuntimeSettings(sessionId), permissionMode: mode, } - const sdkUrl = - `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + - `?token=${encodeURIComponent(crypto.randomUUID())}` + const sdkUrl = buildSdkWebSocketUrl(ws, sessionId) await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings) await commitConfirmedPermissionMode(sessionId, mode, workDir) @@ -1020,9 +1027,7 @@ async function restartSessionWithRuntimeConfig( conversationService.stopSession(sessionId) const runtimeSettings = await getRuntimeSettings(sessionId) - const sdkUrl = - `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + - `?token=${encodeURIComponent(crypto.randomUUID())}` + const sdkUrl = buildSdkWebSocketUrl(ws, sessionId) await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings) sendMessage(ws, { type: 'status', state: 'idle' }) @@ -1576,9 +1581,7 @@ async function ensureCliSessionStarted( const startupSettings = reason === 'prewarm_session' ? { ...runtimeSettings, resumeInterruptedTurn: false } : runtimeSettings - const sdkUrl = - `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + - `?token=${encodeURIComponent(crypto.randomUUID())}` + const sdkUrl = buildSdkWebSocketUrl(ws, sessionId) await sendRepositoryStartupStatus(ws, sessionId, reason) console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`) await conversationService.startSession(sessionId, workDir, sdkUrl, startupSettings)