mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user