fix(mobile-web): confine file: browser creates to the named workspace

`session.createBrowser` is reachable from unprivileged page script and the page
it creates is streamed back over `browser.screencast`, so a `file:` URL turned
any host file into frames the page could decode. `browser.navigate` already
refused `file:` and result URLs were redacted; only the create path was open.

Both sides of the call now fence it instead of banning the scheme, because the
native HTML-artifact file tap is the same call:

- the shell re-resolves the path through `files.resolveTerminalPath` against the
  workspace the page named and forwards the host's own absolute path, so nothing
  the page wrote reaches `browser.tabCreate`, and an SSH worktree (whose path is
  on another machine) is refused;
- the runtime independently requires a paired caller's `file:` URL to sit inside
  that worktree's root on this host, so the page is not the only fence.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-04 02:21:13 -04:00
parent 8ec4d59331
commit f283f88bd8
8 changed files with 553 additions and 1 deletions
@@ -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<RpcClient['sendRequest']>(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
})
})
})
@@ -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<string> {
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -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) {
@@ -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,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.')
}
}
@@ -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<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()
})
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)
})
})
@@ -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(