diff --git a/mobile/src/mobile-web/mobile-web-browser-file-url-confinement.test.ts b/mobile/src/mobile-web/mobile-web-browser-file-url-confinement.test.ts new file mode 100644 index 00000000000..50a9373a9c9 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-browser-file-url-confinement.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { executeMobileWebSessionOperation } from './mobile-web-session-operations' +import { MobileWebBrowserAuthority } from './mobile-web-browser-authority' +import { MobileWebNativeChatAuthority } from './mobile-web-native-chat-authority' +import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' + +const WORKTREE_PATH = '/tmp/worktree-a' + +/** + * `session.createBrowser` is reachable from unprivileged page script, and the page it creates is + * streamed back over `browser.screencast`. The shell is the trusted side of that call, so it — not + * the page — decides which filesystem path a `file:` create may name. + */ +function resolveTerminalPathReply(pathText: string) { + if (!pathText.startsWith(`${WORKTREE_PATH}/`)) { + return { ok: true as const, result: { worktree: 'workspace-1', exists: false } } + } + return { + ok: true as const, + result: { + worktree: 'workspace-1', + relativePath: pathText.slice(WORKTREE_PATH.length + 1), + absolutePath: pathText, + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: pathText.slice(WORKTREE_PATH.length + 1), + absolutePath: pathText + } + } + } +} + +function createClient(overrides?: { provider?: 'local' | 'ssh' }) { + const sendRequest = vi.fn(async (method, params) => { + if (method === 'files.resolveTerminalPath') { + const reply = resolveTerminalPathReply((params as { pathText: string }).pathText) + if (overrides?.provider && 'openTarget' in reply.result && reply.result.openTarget) { + reply.result.openTarget.provider = overrides.provider + } + return reply + } + if (method === 'browser.tabCreate') { + return { ok: true, result: { browserPageId: 'page-1' } } + } + throw new Error(`Unexpected method: ${method}`) + }) + return sendRequest +} + +function operationArgs(sendRequest: RpcClient['sendRequest']) { + const workspaceAuthority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length)) + workspaceAuthority.synchronize([{ workspaceId: 'workspace-1', repoId: 'repo-1' }]) + return { + operation: 'createBrowser', + requestId: 'R'.repeat(22), + client: { sendRequest } as unknown as RpcClient, + workspaceAuthority, + browserAuthority: new MobileWebBrowserAuthority((length) => new Uint8Array(length)), + nativeChatAuthority: new MobileWebNativeChatAuthority((length) => new Uint8Array(length)), + workspaceId: workspaceAuthority.pageWorkspaceId('workspace-1') + } +} + +describe('mobile web createBrowser file: confinement', () => { + it('refuses a file: URL the host does not resolve inside that workspace', async () => { + const sendRequest = createClient() + const args = operationArgs(sendRequest) + await expect( + executeMobileWebSessionOperation({ + ...args, + payload: { workspaceId: args.workspaceId, url: 'file:///Users/dev/.ssh/id_rsa' } + }) + ).rejects.toMatchObject({ code: 'invalid_request' }) + expect(sendRequest).not.toHaveBeenCalledWith('browser.tabCreate', expect.anything()) + }) + + it('opens a workspace HTML artifact and forwards the host path, not the page string', async () => { + const sendRequest = createClient() + const args = operationArgs(sendRequest) + await expect( + executeMobileWebSessionOperation({ + ...args, + payload: { + workspaceId: args.workspaceId, + url: `file://${WORKTREE_PATH}/build/report.html#frag` + } + }) + ).resolves.toMatchObject({ workspaceId: args.workspaceId }) + expect(sendRequest).toHaveBeenCalledWith('browser.tabCreate', { + worktree: 'id:workspace-1', + url: `file://${WORKTREE_PATH}/build/report.html`, + activate: true + }) + }) + + it('refuses an SSH workspace file, which names a path on another machine', async () => { + const sendRequest = createClient({ provider: 'ssh' }) + const args = operationArgs(sendRequest) + await expect( + executeMobileWebSessionOperation({ + ...args, + payload: { + workspaceId: args.workspaceId, + url: `file://${WORKTREE_PATH}/build/report.html` + } + }) + ).rejects.toMatchObject({ code: 'invalid_request' }) + expect(sendRequest).not.toHaveBeenCalledWith('browser.tabCreate', expect.anything()) + }) + + it('leaves an https create untouched', async () => { + const sendRequest = createClient() + const args = operationArgs(sendRequest) + await executeMobileWebSessionOperation({ + ...args, + payload: { workspaceId: args.workspaceId, url: 'https://example.com/' } + }) + expect(sendRequest).not.toHaveBeenCalledWith( + 'files.resolveTerminalPath', + expect.anything(), + expect.anything() + ) + expect(sendRequest).toHaveBeenCalledWith('browser.tabCreate', { + worktree: 'id:workspace-1', + url: 'https://example.com/', + activate: true + }) + }) +}) diff --git a/mobile/src/mobile-web/mobile-web-browser-file-url-confinement.ts b/mobile/src/mobile-web/mobile-web-browser-file-url-confinement.ts new file mode 100644 index 00000000000..4b2d6adaff1 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-browser-file-url-confinement.ts @@ -0,0 +1,64 @@ +import { fileUriToFilesystemPath, filesystemPathToFileUri } from '../../../src/shared/file-uri-path' +import type { RpcClient } from '../transport/rpc-client' +import { MobileWebBrokerError } from './mobile-web-broker-error' + +const RESOLVE_TIMEOUT_MS = 10_000 + +export function isMobileWebBrowserFileUrl(url: string): boolean { + try { + return new URL(url).protocol === 'file:' + } catch { + return false + } +} + +/** + * The hosted page is untrusted, so a `file:` browser create it asks for is only honoured when the + * host itself resolves the path back to a file inside that workspace's root. The returned URL is + * rebuilt from the host's own absolute path, so nothing the page wrote reaches `browser.tabCreate`. + */ +export async function confineMobileWebBrowserFileUrl(args: { + url: string + hostWorkspaceId: string + client: RpcClient +}): Promise { + let pathText: string + try { + pathText = fileUriToFilesystemPath(new URL(args.url)) ?? '' + } catch { + throw new MobileWebBrokerError('invalid_request') + } + if (!pathText) { + throw new MobileWebBrokerError('invalid_request') + } + const response = await args.client.sendRequest( + 'files.resolveTerminalPath', + { worktree: `id:${args.hostWorkspaceId}`, pathText }, + { timeoutMs: RESOLVE_TIMEOUT_MS } + ) + if (!response.ok) { + throw new MobileWebBrokerError('host_error') + } + const resolved = response.result + if ( + !isRecord(resolved) || + resolved.worktree !== args.hostWorkspaceId || + resolved.exists !== true || + resolved.isDirectory !== false || + !isRecord(resolved.openTarget) || + resolved.openTarget.kind !== 'worktree-file' || + // Why: browser.tabCreate opens the URL on the runtime host, so an SSH worktree's path would + // name an unrelated local file (or none) on the desktop that renders it. + resolved.openTarget.provider !== 'local' || + typeof resolved.openTarget.absolutePath !== 'string' || + resolved.openTarget.absolutePath.length < 1 || + resolved.openTarget.absolutePath.length > 4096 + ) { + throw new MobileWebBrokerError('invalid_request') + } + return filesystemPathToFileUri(resolved.openTarget.absolutePath) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/mobile/src/mobile-web/mobile-web-session-operations.ts b/mobile/src/mobile-web/mobile-web-session-operations.ts index aadb5a51279..704cabcb11d 100644 --- a/mobile/src/mobile-web/mobile-web-session-operations.ts +++ b/mobile/src/mobile-web/mobile-web-session-operations.ts @@ -22,6 +22,10 @@ import { parseRuntimeStatusCapabilities } from '../transport/runtime-capability- import { projectHostSessionRuntimeCapabilities } from '../session/host-session-runtime-capabilities' import { loadMobileNewTabAgentOptions } from '../session/mobile-new-tab-agent-loader' import type { MobileWebBrowserAuthority } from './mobile-web-browser-authority' +import { + confineMobileWebBrowserFileUrl, + isMobileWebBrowserFileUrl +} from './mobile-web-browser-file-url-confinement' import type { MobileWebNativeChatAuthority } from './mobile-web-native-chat-authority' import { MobileWebBrokerError } from './mobile-web-broker-error' import { mobileWebSessionSnapshot } from './mobile-web-session-snapshot' @@ -176,9 +180,16 @@ export async function executeMobileWebSessionOperation(args: { if (args.operation === 'createBrowser') { const payload = MobileWebSessionBrowserCreatePayloadSchema.parse(args.payload) const hostWorkspaceId = args.workspaceAuthority.hostWorkspaceId(payload.workspaceId) + const url = isMobileWebBrowserFileUrl(payload.url) + ? await confineMobileWebBrowserFileUrl({ + url: payload.url, + hostWorkspaceId, + client: args.client + }) + : payload.url const response = await args.client.sendRequest('browser.tabCreate', { worktree: `id:${hostWorkspaceId}`, - url: payload.url, + url, activate: true }) if (!response.ok) { diff --git a/src/main/runtime/browser-tab-create-file-url-confinement.test.ts b/src/main/runtime/browser-tab-create-file-url-confinement.test.ts new file mode 100644 index 00000000000..3c29b3170b3 --- /dev/null +++ b/src/main/runtime/browser-tab-create-file-url-confinement.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { assertPairedBrowserTabCreateFileUrlAllowed } from './browser-tab-create-file-url-confinement' + +const WORKTREE = { id: 'wt-1', path: '/Users/dev/code/orca' } + +describe('paired browser.tabCreate file: confinement', () => { + it('allows a file inside the workspace root, the native HTML-artifact open', () => { + expect(() => + assertPairedBrowserTabCreateFileUrlAllowed({ + url: 'file:///Users/dev/code/orca/build/report.html', + pairedCaller: true, + worktree: WORKTREE + }) + ).not.toThrow() + }) + + it('refuses a path outside the workspace root', () => { + expect(() => + assertPairedBrowserTabCreateFileUrlAllowed({ + url: 'file:///Users/dev/.ssh/id_rsa', + pairedCaller: true, + worktree: WORKTREE + }) + ).toThrow(/outside the requested workspace/) + }) + + it('refuses a sibling directory that shares the root prefix', () => { + expect(() => + assertPairedBrowserTabCreateFileUrlAllowed({ + url: 'file:///Users/dev/code/orca-secrets/env', + pairedCaller: true, + worktree: WORKTREE + }) + ).toThrow(/outside the requested workspace/) + }) + + it('refuses a traversal escape that percent-encodes its separators', () => { + expect(() => + assertPairedBrowserTabCreateFileUrlAllowed({ + url: 'file:///Users/dev/code/orca/%2e%2e/%2e%2e/.ssh/id_rsa', + pairedCaller: true, + worktree: WORKTREE + }) + ).toThrow(/outside the requested workspace/) + }) + + it('refuses a file: create with no workspace to confine it to', () => { + expect(() => + assertPairedBrowserTabCreateFileUrlAllowed({ + url: 'file:///etc/passwd', + pairedCaller: true, + worktree: undefined + }) + ).toThrow(/requires an explicit workspace/) + }) + + it('refuses a remote workspace, whose path names another machine', () => { + expect(() => + assertPairedBrowserTabCreateFileUrlAllowed({ + url: 'file:///Users/dev/code/orca/build/report.html', + pairedCaller: true, + worktree: { ...WORKTREE, hostId: 'ssh:box' } + }) + ).toThrow(/remote workspace/) + }) + + it('allows a folder workspace on the local host', () => { + expect(() => + assertPairedBrowserTabCreateFileUrlAllowed({ + url: 'file:///Users/dev/notes/index.html', + pairedCaller: true, + worktree: { id: 'folder-1', path: '/Users/dev/notes', hostId: 'local' } + }) + ).not.toThrow() + }) + + it('leaves http(s) and local callers alone', () => { + expect(() => + assertPairedBrowserTabCreateFileUrlAllowed({ + url: 'https://example.com', + pairedCaller: true, + worktree: undefined + }) + ).not.toThrow() + expect(() => + assertPairedBrowserTabCreateFileUrlAllowed({ + url: 'file:///etc/passwd', + pairedCaller: false, + worktree: undefined + }) + ).not.toThrow() + }) +}) diff --git a/src/main/runtime/browser-tab-create-file-url-confinement.ts b/src/main/runtime/browser-tab-create-file-url-confinement.ts new file mode 100644 index 00000000000..7735fdd8d60 --- /dev/null +++ b/src/main/runtime/browser-tab-create-file-url-confinement.ts @@ -0,0 +1,57 @@ +import { fileUriToFilesystemPath } from '../../shared/file-uri-path' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { BrowserError } from '../browser/browser-error' + +export type BrowserTabCreateWorktreeTarget = { + id: string + path?: string + hostId?: ExecutionHostId +} + +export function isBrowserTabCreateFileUrl(url: string): boolean { + try { + return new URL(url).protocol === 'file:' + } catch { + return false + } +} + +/** + * A paired client (phone, hosted mobile page, remote UI) is not trusted to name a filesystem path: + * its `file:` create only renders a file inside the workspace it named, on this host. Local callers + * keep their existing reach, and the screencast that streams the render back never leaves that root. + */ +export function assertPairedBrowserTabCreateFileUrlAllowed(input: { + url: string + pairedCaller: boolean + worktree: BrowserTabCreateWorktreeTarget | undefined +}): void { + if (!input.pairedCaller || !isBrowserTabCreateFileUrl(input.url)) { + return + } + const root = input.worktree?.path + if (!root) { + throw new BrowserError( + 'forbidden', + 'A file:// browser page requires an explicit workspace on this host.' + ) + } + if (input.worktree?.hostId !== undefined && input.worktree.hostId !== LOCAL_EXECUTION_HOST_ID) { + // Why: the URL would be opened against this host's filesystem, where a remote workspace's path + // names a different file (or none) than the one the caller asked for. + throw new BrowserError( + 'forbidden', + 'A file:// browser page is not available for a remote workspace.' + ) + } + let candidate: string | null + try { + candidate = fileUriToFilesystemPath(new URL(input.url)) + } catch { + candidate = null + } + if (!candidate || !isPathInsideOrEqual(root, candidate)) { + throw new BrowserError('forbidden', 'That file is outside the requested workspace.') + } +} diff --git a/src/main/runtime/browser-tab-create-paired-file-url.test.ts b/src/main/runtime/browser-tab-create-paired-file-url.test.ts new file mode 100644 index 00000000000..f2e966696ad --- /dev/null +++ b/src/main/runtime/browser-tab-create-paired-file-url.test.ts @@ -0,0 +1,186 @@ +/** + * A paired client reaches `browser.tabCreate` with a caller-supplied URL, and the page it creates is + * streamed back over `browser.screencast`. Without a fence, `file:///…/id_rsa` renders any file on + * the host into the caller's frames. The native HTML-artifact open is the same call, so the fence + * has to be a workspace-root containment check rather than a scheme ban. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { OrcaRuntimeService } from './orca-runtime' +import { setRuntimeBrowserCommandsFactory } from './runtime-browser-commands-factory' + +const { browserSessionRegistryMock } = vi.hoisted(() => ({ + browserSessionRegistryMock: { + getDefaultProfile: () => ({ + id: 'default', + partition: 'persist:orca-browser' + }), + getProfile: () => ({ id: 'default', partition: 'persist:orca-browser' }), + resolveKnownPartition: () => 'persist:orca-browser' + } +})) + +vi.mock('electron', () => ({ + ipcMain: { + on: vi.fn(), + removeListener: vi.fn(), + handle: vi.fn(), + removeHandler: vi.fn() + }, + webContents: { fromId: vi.fn() } +})) +vi.mock('../browser/browser-session-registry', () => ({ + browserSessionRegistry: browserSessionRegistryMock +})) + +const WORKTREE_PATH = '/tmp/worktree-a' +const WT = `repo-1::${WORKTREE_PATH}` + +const storeBase = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [storeBase.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }), + addRetiredWorktreeName: () => {}, + mergeRetiredWorktreeNames: () => false, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +function makeSession(): WorkspaceSessionState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: WT, + activeTabId: null, + tabsByWorktree: { [WT]: [] }, + terminalLayoutsByTabId: {} + } +} + +function createRuntime(worktree: { id: string; path?: string; hostId?: string }) { + let session = makeSession() + const runtime = new OrcaRuntimeService({ + ...storeBase, + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + } + }) + const createTab = vi.fn(async (options: { browserPageId?: string }) => ({ + browserPageId: options.browserPageId ?? 'page-new' + })) + const internals = runtime as unknown as { + offscreenBrowserBackend: unknown + agentBrowserBridge: unknown + resolveWorktreeSelector: (selector: string) => Promise + } + internals.resolveWorktreeSelector = async () => worktree + internals.offscreenBrowserBackend = { closeTab: vi.fn(), createTab } + internals.agentBrowserBridge = { + tabList: vi.fn(() => ({ tabs: [] })), + getRegisteredTabs: vi.fn(() => new Map()), + setActiveTab: vi.fn() + } + return { runtime, createTab } +} + +function create( + runtime: OrcaRuntimeService, + url: string, + caller?: { pairedDeviceId?: string; clientKind?: 'mobile' | 'runtime' } +): Promise<{ browserPageId: string }> { + return runtime.browserTabCreate( + { + worktree: `id:${WT}`, + page: 'page-new', + url, + activate: true, + navigation: 'caller' + }, + caller + ) +} + +describe('browser.tabCreate file: URLs from a paired client', () => { + beforeAll(async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) + return () => setRuntimeBrowserCommandsFactory(null) + }) + + beforeEach(() => { + vi.useFakeTimers() + return () => vi.useRealTimers() + }) + + it('refuses a paired file: create outside the named workspace and creates no page', async () => { + const { runtime, createTab } = createRuntime({ + id: WT, + path: WORKTREE_PATH + }) + await expect( + create(runtime, 'file:///tmp/secrets/id_rsa', { + pairedDeviceId: 'device-1', + clientKind: 'mobile' + }) + ).rejects.toThrow(/outside the requested workspace/) + expect(createTab).not.toHaveBeenCalled() + }) + + it('still opens an HTML artifact inside the workspace, the native file-tap feature', async () => { + const { runtime, createTab } = createRuntime({ + id: WT, + path: WORKTREE_PATH + }) + await expect( + create(runtime, `file://${WORKTREE_PATH}/build/report.html`, { + pairedDeviceId: 'device-1', + clientKind: 'mobile' + }) + ).resolves.toEqual({ browserPageId: 'page-new' }) + expect(createTab).toHaveBeenCalledTimes(1) + }) + + it('refuses a paired file: create for an SSH workspace, whose path is on another machine', async () => { + const { runtime, createTab } = createRuntime({ + id: WT, + path: WORKTREE_PATH, + hostId: 'ssh:box' + }) + await expect( + create(runtime, `file://${WORKTREE_PATH}/build/report.html`, { + pairedDeviceId: 'device-1', + clientKind: 'mobile' + }) + ).rejects.toThrow(/remote workspace/) + expect(createTab).not.toHaveBeenCalled() + }) + + it('leaves an unpaired local create alone', async () => { + const { runtime, createTab } = createRuntime({ + id: WT, + path: WORKTREE_PATH + }) + await expect(create(runtime, 'file:///tmp/secrets/id_rsa')).resolves.toEqual({ + browserPageId: 'page-new' + }) + expect(createTab).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts b/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts index c7766703926..3d689bcaa5d 100644 --- a/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts +++ b/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts @@ -162,11 +162,13 @@ export type RuntimeBrowserCommandHost = { id: string repoId?: string hostId?: ExecutionHostId + path?: string }> resolveBrowserWorkspace(selector: string): Promise<{ id: string repoId?: string hostId?: ExecutionHostId + path?: string }> resolveBrowserNetworkExecutionHost(worktree?: { id: string diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-create.ts b/src/main/runtime/runtime-browser-commands-browser-tab-create.ts index 67c39c7f5b7..c389af7440b 100644 --- a/src/main/runtime/runtime-browser-commands-browser-tab-create.ts +++ b/src/main/runtime/runtime-browser-commands-browser-tab-create.ts @@ -6,6 +6,7 @@ import { publishCreatedBrowserSessionTab, resolveBrowserTabCreateFocus } from './browser-tab-create-publication' +import { assertPairedBrowserTabCreateFileUrlAllowed } from './browser-tab-create-file-url-confinement' import { browserSessionRegistry } from '../browser/browser-session-registry' import { BrowserError } from '../browser/browser-error' import { randomUUID } from 'node:crypto' @@ -45,6 +46,11 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo : await this.host.resolveWorktreeSelector(params.worktree) : undefined const worktreeId = worktree?.id + assertPairedBrowserTabCreateFileUrlAllowed({ + url, + pairedCaller: Boolean(caller?.pairedDeviceId), + worktree + }) const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId) if (!sessionPartition) { throw new BrowserError(