mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix(security): harden desktop request isolation
This commit is contained in:
parent
d3bf146c46
commit
4c3b08c0a4
@ -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 },
|
||||
|
||||
@ -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[] = []
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '*/*' } })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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, () => {
|
||||
|
||||
@ -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'>,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -142,6 +142,7 @@ export type DesktopHost = {
|
||||
capabilities: DesktopHostCapabilities
|
||||
runtime: {
|
||||
getServerUrl(): Promise<string>
|
||||
getLocalAccessToken(): Promise<string | null>
|
||||
}
|
||||
app: {
|
||||
getVersion(): Promise<string>
|
||||
|
||||
@ -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'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -484,6 +484,7 @@ describe('updateStore', () => {
|
||||
updates: true,
|
||||
},
|
||||
runtime: {
|
||||
...browserHost.runtime,
|
||||
getServerUrl,
|
||||
},
|
||||
updates: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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' },
|
||||
|
||||
36
src/server/__tests__/local-access-auth.test.ts
Normal file
36
src/server/__tests__/local-access-auth.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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'
|
||||
}
|
||||
|
||||
|
||||
@ -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, {
|
||||
|
||||
37
src/server/localAccessAuth.ts
Normal file
37
src/server/localAccessAuth.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user