fix(browser): confine file:// tab creation to the workspace

`browser.tabCreate` accepted any caller-supplied URL, and the page it creates is
streamed back over `browser.screencast`. A paired client could therefore name
`file:///Users/dev/.ssh/id_rsa` and decode any host file out of its own frames.
`browser.navigate` already refused `file:` and result URLs were redacted; only
the create path was open.

Fence it rather than ban the scheme, because opening an HTML artifact from a
workspace is the same call: a paired caller's `file:` URL must name an explicit
workspace, that workspace must be on this host, and the resolved path must sit
inside its root. Percent-encoded traversal and a sibling directory that shares
the root prefix both resolve outside and are refused. An unpaired local caller
keeps its existing reach.

`pairedDeviceId` is the gate, which runtime-rpc-pairing mints for `scope:
'runtime'` as well as `'mobile'`, so the fence covers the web client, a desktop
paired to a remote runtime, and remote `orca browser tab create` too.

Wire-compatible: no params or response shape changes. The refusal is an existing
`BrowserError('forbidden', …)` on a path that previously succeeded, which is the
point of the fix.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-08 23:48:25 -04:00
parent b00c49b08b
commit 55f9915034
5 changed files with 397 additions and 0 deletions
@@ -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()
})
})
@@ -0,0 +1,58 @@
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, web client, remote desktop, remote CLI) 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.')
}
}
@@ -0,0 +1,238 @@
/**
* 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. "Paired" is every
* authenticated paired socket — phone, web client, remote desktop, remote CLI — not just mobile.
*/
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'
import { RpcDispatcher } from './rpc/dispatcher'
import { BROWSER_CORE_METHODS } from './rpc/methods/browser-core'
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<typeof worktree>
}
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()
})
// Why: the shell-side confinement is page code; a paired client that is not this shell must still be fenced.
it('refuses file:///etc/passwd dispatched over RPC by a paired mobile device', async () => {
const { runtime, createTab } = createRuntime({
id: WT,
path: WORKTREE_PATH
})
const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS })
const replies: string[] = []
await dispatcher.dispatchStreaming(
{
id: 'req-1',
authToken: 'tok',
method: 'browser.tabCreate',
params: {
worktree: `id:${WT}`,
page: 'page-new',
url: 'file:///etc/passwd',
activate: true,
navigation: 'caller'
}
},
(reply) => replies.push(reply),
{ pairedDeviceId: 'device-1', clientKind: 'mobile' }
)
expect(JSON.parse(replies[0]!)).toMatchObject({
ok: false,
error: { message: expect.stringMatching(/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()
})
// The gate is `caller.pairedDeviceId`, which `runtime-rpc-pairing.ts` mints for `scope: 'runtime'`
// as well as `'mobile'`. So it also governs the web client, a desktop paired to a remote runtime,
// and remote `orca browser tab create` — breadth as a decision, not a side effect.
it('applies the same fence to a runtime-scope paired client, not only a phone', async () => {
const { runtime, createTab } = createRuntime({ id: WT, path: WORKTREE_PATH })
const caller = { pairedDeviceId: 'device-2', clientKind: 'runtime' as const }
await expect(create(runtime, 'file:///tmp/secrets/id_rsa', caller)).rejects.toThrow(
/outside the requested workspace/
)
expect(createTab).not.toHaveBeenCalled()
await expect(
create(runtime, `file://${WORKTREE_PATH}/build/report.html`, caller)
).resolves.toEqual({ browserPageId: 'page-new' })
expect(createTab).toHaveBeenCalledTimes(1)
})
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)
})
})
@@ -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
@@ -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(