fix(security): harden desktop request isolation

This commit is contained in:
程序员阿江(Relakkes) 2026-07-14 02:12:03 +08:00
parent d3bf146c46
commit 4c3b08c0a4
26 changed files with 392 additions and 40 deletions

View File

@ -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 },

View File

@ -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[] = []

View File

@ -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<Response> {
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<string> {
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<boolean> {
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<RecentProject[]> {
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<GitInfo> {
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<SessionTask[]> {
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<void> {
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<string, unknown>): Promise<void> {
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),

View File

@ -50,12 +50,18 @@ export class WsBridge {
private handlerChains = new Map<string, Promise<void>>()
private serverUrl: string
private platform: string
private localAccessToken: string | null
private heartbeatTimer: ReturnType<typeof setInterval> | 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

View File

@ -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,

View File

@ -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',

View File

@ -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: '*/*' } })
})
})

View File

@ -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, () => {

View File

@ -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<Session['webRequest'], 'onBeforeSendHeaders'>,
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<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,

View File

@ -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({

View File

@ -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<string>
private readonly localAccessToken = randomBytes(32).toString('base64url')
private sidecarEnvPromise: Promise<NodeJS.ProcessEnv> | 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<void> {
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<void> {
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)

View File

@ -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() {

View File

@ -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(

View File

@ -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),

View File

@ -142,6 +142,7 @@ export type DesktopHost = {
capabilities: DesktopHostCapabilities
runtime: {
getServerUrl(): Promise<string>
getLocalAccessToken(): Promise<string | null>
}
app: {
getVersion(): Promise<string>

View File

@ -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'),
},
}

View File

@ -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

View File

@ -484,6 +484,7 @@ describe('updateStore', () => {
updates: true,
},
runtime: {
...browserHost.runtime,
getServerUrl,
},
updates: {

View File

@ -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: {

View File

@ -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' },

View File

@ -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)
})
})

View File

@ -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'
}

View File

@ -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, {

View File

@ -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
}

View File

@ -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<AuthResult> {
if (isLocalAccessAuthorized(req, tokenOverride)) {
return { valid: true }
}
const anthropicAuth = validateAuth(req)
if (anthropicAuth.valid) {
return anthropicAuth

View File

@ -59,6 +59,15 @@ import { getDisconnectGraceMs } from './disconnectGraceConfig.js'
const settingsService = new SettingsService()
const providerService = new ProviderService()
function buildSdkWebSocketUrl(
ws: ServerWebSocket<WebSocketData>,
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)