Files
claude-code-haha/desktop/electron/main.security.test.ts
程序员阿江-Relakkes 7e50c31986 fix(desktop): composite the workspace browser with the app UI (#1437)
The workspace browser was a native WebContentsView, which always paints
above the DOM. Switching to the skills or settings page left the page
floating over it, and menus over the page needed a screenshot swap that
showed up clipped and flashed white.

Pages are now <webview> guests kept in a layer inside the session panel
that is never unmounted, so other pages, menus and dialogs draw over
them like any element. The main process adopts each guest and keeps all
in-page behaviour: navigation, history, find, zoom, capture, PDF,
downloads, shortcuts and annotation.

- Guard every attach in the main window: browser partition only, start
  at about:blank, preload and sandbox pinned, reported ids validated.
- Match the native page: drop the blank history entry, keep page zoom
  independent of app zoom, allow popups so they still become tabs.
- Keep app drags working over a page and close menus on a click into it.
- Tell the side dock it is off screen when the session page is hidden.
- Remove the overlay snapshot machinery and native bounds syncing.
2026-10-04 03:24:50 +08:00

317 lines
13 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
configureLocalServerRequestAuth,
configurePreviewSessionPermissions,
createPreviewSessionPartition,
isAllowlistedMainRendererMediaRequest,
} from './services/previewSession'
import { applyWorkspaceBrowserAttachPolicy } from './services/workspaceBrowserGuest'
import {
WORKSPACE_BROWSER_INITIAL_SRC,
WORKSPACE_BROWSER_PARTITION,
} from '../src/lib/workspace/browserGuestContract'
const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts'))
? process.cwd()
: path.resolve(process.cwd(), 'desktop')
const mainSource = readFileSync(path.join(desktopRoot, 'electron', 'main.ts'), 'utf8')
const previewServiceSource = mainSource.slice(
mainSource.indexOf('function getPreviewService()'),
mainSource.indexOf('function getPetWindowController()'),
)
const mainWindowSource = mainSource.slice(
mainSource.indexOf('async function createMainWindow()'),
mainSource.indexOf('if (!acquireSingleInstanceLock'),
)
// The workspace browser is the app's *second* host for arbitrary remote pages,
// and it sits after the pet controller, so neither slice above covers it. It
// needs its own, or the boundary it relies on could be undone silently.
const workspaceBrowserServiceSource = mainSource.slice(
mainSource.indexOf('function getWorkspaceBrowserService()'),
mainSource.indexOf('function registerIpcHandlers('),
)
describe('Electron preview security boundary', () => {
it('restricts public access management to the main desktop frame', () => {
expect(mainSource).toContain("channel.startsWith('desktop:public-access:')")
expect(mainSource).toContain('senderWindow !== mainWindow || event.senderFrame !== event.sender.mainFrame')
})
it('does not give the pet preload the desktop master access token', () => {
const petPreloadSource = readFileSync(path.join(desktopRoot, 'electron', 'pet-preload.ts'), 'utf8')
expect(petPreloadSource).toContain('runtimeGetPetAccessToken')
expect(petPreloadSource).not.toContain('runtimeGetLocalAccessToken')
expect(mainSource).toContain('resolvePetServerAccess')
})
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('does not authenticate preview resources and only enables the main media allowlist', () => {
expect(previewServiceSource).not.toContain('configureLocalServerRequestAuth')
expect(previewServiceSource).not.toContain('resolveLocalServerAccess')
expect(mainWindowSource).toContain('configureLocalServerRequestAuth')
expect(mainWindowSource).toContain('isAllowlistedMainRendererMediaRequest')
})
it('keeps the local access token away from the workspace browser', () => {
// These pages load whatever the user types into an address bar. Attaching
// the desktop's local token to their loopback requests would hand the local
// API to any site they visit.
// Matched as a *call*, not as a word: the source deliberately names the
// helper in a comment explaining why it is absent, and a bare substring
// check would read that explanation as the thing it forbids.
expect(workspaceBrowserServiceSource).not.toMatch(/configureLocalServerRequestAuth\s*\(/)
expect(workspaceBrowserServiceSource).not.toMatch(/resolveLocalServerAccess\s*\(/)
})
it('isolates the workspace browser from the renderer session', () => {
// A shared *persistent* partition is deliberate — one browsing profile for
// the user — but it must never be the renderer's own session, which is
// where the local token is injected.
expect(workspaceBrowserServiceSource).toContain('WORKSPACE_BROWSER_PARTITION')
expect(workspaceBrowserServiceSource).not.toContain('defaultSession')
expect(workspaceBrowserServiceSource).toContain('configurePreviewSessionPermissions')
})
it('locks workspace browser sandboxing on', () => {
// Pages are `<webview>` guests now; their preferences are pinned by the
// attach policy, which the main window must install.
const preferences: Record<string, unknown> = { sandbox: false, contextIsolation: false, nodeIntegration: true }
expect(applyWorkspaceBrowserAttachPolicy(preferences, {
partition: WORKSPACE_BROWSER_PARTITION,
src: WORKSPACE_BROWSER_INITIAL_SRC,
}, { preload: '/preview-preload.cjs' })).toBe(true)
expect(preferences).toMatchObject({ sandbox: true, contextIsolation: true, nodeIntegration: false })
expect(mainWindowSource).toContain('installWorkspaceBrowserGuestPolicy(mainWindow.webContents')
})
it('lets only the main window host workspace browser pages', () => {
// The service keeps one parent window. Trace and pet windows load the same
// preload, so without this a secondary window could adopt every page and
// strand it as an unremovable child of the main window.
const createHandler = mainSource.slice(
mainSource.indexOf('ELECTRON_IPC_CHANNELS.workspaceBrowserCreate'),
mainSource.indexOf('ELECTRON_IPC_CHANNELS.workspaceBrowserNavigate'),
)
expect(createHandler).toContain('currentWindow(event) !== mainWindow')
})
it.each([
['open-target icon', 'GET', 'image', 'http://127.0.0.1:49321/api/open-targets/icons/cursor'],
['profile avatar', 'GET', 'image', 'http://127.0.0.1:49321/api/desktop-ui/preferences/profile/avatar?v=1'],
['path attachment', 'GET', 'image', 'http://127.0.0.1:49321/api/filesystem/file?path=%2Ftmp%2Fimage.png'],
['workspace image', 'GET', 'image', 'http://127.0.0.1:49321/preview-fs/session/image.png'],
['workspace video range', 'GET', 'media', 'http://127.0.0.1:49321/preview-fs/session/video.mp4'],
])('allows the main renderer %s request', (_name, method, resourceType, url) => {
expect(isAllowlistedMainRendererMediaRequest({
method,
resourceType,
url,
webContentsId: 42,
}, 42)).toBe(true)
})
it('keeps the local access token away from the workspace browser', () => {
// These pages load whatever the user types into an address bar. Attaching
// the desktop's local token to their loopback requests would hand the local
// API to any site they visit.
// Matched as a *call*, not as a word: the source deliberately names the
// helper in a comment explaining why it is absent, and a bare substring
// check would read that explanation as the thing it forbids.
expect(workspaceBrowserServiceSource).not.toMatch(/configureLocalServerRequestAuth\s*\(/)
expect(workspaceBrowserServiceSource).not.toMatch(/resolveLocalServerAccess\s*\(/)
})
it('isolates the workspace browser from the renderer session', () => {
// A shared *persistent* partition is deliberate — one browsing profile for
// the user — but it must never be the renderer's own session, which is
// where the local token is injected.
expect(workspaceBrowserServiceSource).toContain('WORKSPACE_BROWSER_PARTITION')
expect(workspaceBrowserServiceSource).not.toContain('defaultSession')
expect(workspaceBrowserServiceSource).toContain('configurePreviewSessionPermissions')
})
it('locks workspace browser sandboxing on', () => {
// Pages are `<webview>` guests now; their preferences are pinned by the
// attach policy, which the main window must install.
const preferences: Record<string, unknown> = { sandbox: false, contextIsolation: false, nodeIntegration: true }
expect(applyWorkspaceBrowserAttachPolicy(preferences, {
partition: WORKSPACE_BROWSER_PARTITION,
src: WORKSPACE_BROWSER_INITIAL_SRC,
}, { preload: '/preview-preload.cjs' })).toBe(true)
expect(preferences).toMatchObject({ sandbox: true, contextIsolation: true, nodeIntegration: false })
expect(mainWindowSource).toContain('installWorkspaceBrowserGuestPolicy(mainWindow.webContents')
})
it('lets only the main window host workspace browser pages', () => {
// The service keeps one parent window. Trace and pet windows load the same
// preload, so without this a secondary window could adopt every page and
// strand it as an unremovable child of the main window.
const createHandler = mainSource.slice(
mainSource.indexOf('ELECTRON_IPC_CHANNELS.workspaceBrowserCreate'),
mainSource.indexOf('ELECTRON_IPC_CHANNELS.workspaceBrowserNavigate'),
)
expect(createHandler).toContain('currentWindow(event) !== mainWindow')
})
it.each([
['privileged API image probe', 'GET', 'image', 'http://127.0.0.1:49321/api/sessions'],
['allowlisted path through fetch', 'GET', 'xhr', 'http://127.0.0.1:49321/api/open-targets/icons/cursor'],
['allowlisted path with mutation', 'POST', 'image', 'http://127.0.0.1:49321/api/filesystem/file?path=%2Ftmp%2Fimage.png'],
])('rejects the main renderer %s request', (_name, method, resourceType, url) => {
expect(isAllowlistedMainRendererMediaRequest({
method,
resourceType,
url,
webContentsId: 42,
}, 42)).toBe(false)
})
it('rejects allowlisted media from another web contents in the default session', () => {
expect(isAllowlistedMainRendererMediaRequest({
method: 'GET',
resourceType: 'image',
url: 'http://127.0.0.1:49321/api/open-targets/icons/cursor',
webContentsId: 99,
}, 42)).toBe(false)
})
it('injects the desktop token only for allowlisted media and preserves range headers', () => {
let beforeSendHeaders: ((details: {
method: string
resourceType: string
url: string
webContentsId: number
requestHeaders: Record<string, string>
}, callback: (response: { requestHeaders: Record<string, string> }) => void) => void) | undefined
const webRequest = {
onBeforeSendHeaders(handler: typeof beforeSendHeaders) {
beforeSendHeaders = handler
},
}
configureLocalServerRequestAuth(
webRequest as never,
() => ({
serverUrl: 'http://127.0.0.1:49321',
token: 'desktop-local-token',
}),
details => isAllowlistedMainRendererMediaRequest(details, 42),
)
const videoCallback = vi.fn()
beforeSendHeaders?.({
method: 'GET',
resourceType: 'media',
url: 'http://127.0.0.1:49321/preview-fs/session/video.mp4',
webContentsId: 42,
requestHeaders: { Accept: 'video/*', Range: 'bytes=0-1023' },
}, videoCallback)
expect(videoCallback).toHaveBeenCalledWith({
requestHeaders: {
Accept: 'video/*',
Range: 'bytes=0-1023',
Authorization: 'Bearer desktop-local-token',
},
})
for (const details of [
{
method: 'GET',
resourceType: 'image',
url: 'http://127.0.0.1:49321/api/sessions',
webContentsId: 42,
},
{
method: 'GET',
resourceType: 'xhr',
url: 'http://127.0.0.1:49321/api/open-targets/icons/cursor',
webContentsId: 42,
},
{
method: 'GET',
resourceType: 'image',
url: 'https://example.com/api/open-targets/icons/cursor',
webContentsId: 42,
},
{
method: 'GET',
resourceType: 'image',
url: 'http://127.0.0.1:49321/api/open-targets/icons/cursor',
webContentsId: 99,
},
]) {
const callback = vi.fn()
beforeSendHeaders?.({
...details,
requestHeaders: { Accept: '*/*' },
}, callback)
expect(callback).toHaveBeenCalledWith({
requestHeaders: { Accept: '*/*' },
})
}
})
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) {
handlers.check = handler
},
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)')
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: '*/*' } })
})
})