Merge branch 'fix2-security-merge' into mobile-rearch

This commit is contained in:
Jinwoo-H
2026-09-04 16:32:02 -04:00
10 changed files with 181 additions and 8 deletions
@@ -8,6 +8,8 @@ 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: {
@@ -144,6 +146,37 @@ describe('browser.tabCreate file: URLs from a paired client', () => {
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,
@@ -30,6 +30,7 @@ export function registerLegacyBinaryControlFrames(
registerBinaryStreamHandler,
ptyId,
clientId,
connectionClientId,
isMobile,
supportsDesktopViewportClaims,
supportsQueryReply,
@@ -61,7 +62,13 @@ export function registerLegacyBinaryControlFrames(
// Why: opcode 18 skips the mobile input floor, so it needs every guard terminal.send applies; drop otherwise.
if (
isQueryReply &&
!isAcceptableTerminalQueryReplyFrame({ runtime, ptyId, text, client: params.client })
!isAcceptableTerminalQueryReplyFrame({
runtime,
ptyId,
text,
client: params.client,
connectionClientId
})
) {
return
}
@@ -19,7 +19,10 @@ export type TerminalSubscriptionArgs = {
signal: RpcContext['signal']
emit: TerminalSubscriptionEmit
ptyId: string
/** Client-declared id from `params.client`; never an authenticated identity. */
clientId: string | undefined
/** Authenticated device token carried by the transport; undefined for in-process/desktop callers. */
connectionClientId: string | undefined
isMobile: boolean
supportsDesktopViewportClaims: boolean
supportsQueryReply: boolean
@@ -20,6 +20,8 @@ export type MultiplexEmit = (result: unknown) => void
export type TerminalMultiplexConnectionBase = {
runtime: RpcContext['runtime']
connectionId: string
/** Authenticated device token for this socket; undefined for in-process/desktop callers. */
connectionClientId: string | undefined
sendBinary: NonNullable<RpcContext['sendBinary']>
registerBinaryStreamHandler: NonNullable<RpcContext['registerBinaryStreamHandler']>
signal: RpcContext['signal']
@@ -45,7 +45,8 @@ export function handleMultiplexInputFrame(
runtime,
ptyId: stream.ptyId,
text,
client: stream.client
client: stream.client,
connectionClientId: state.connectionClientId
})
) {
return
@@ -17,7 +17,7 @@ export const TERMINAL_MULTIPLEX_METHODS: RpcAnyMethod[] = [
params: TerminalMultiplex,
handler: async (
_params,
{ runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal },
{ runtime, connectionId, clientId, sendBinary, registerBinaryStreamHandler, signal },
emit
) => {
if (!sendBinary || !registerBinaryStreamHandler || !connectionId) {
@@ -31,6 +31,7 @@ export const TERMINAL_MULTIPLEX_METHODS: RpcAnyMethod[] = [
const state: TerminalMultiplexConnectionBase = {
runtime,
connectionId,
connectionClientId: clientId,
sendBinary,
registerBinaryStreamHandler,
signal,
@@ -5,8 +5,11 @@ import type { TerminalViewportClient } from './terminal-stream-types'
type TerminalQueryReplyIdentity = {
text: string | undefined
client: TerminalViewportClient | undefined
/** Authenticated device token when the transport carries one; the declared client id must match it. */
connectionClientId?: string | undefined
/**
* Authenticated device token when the transport carries one; the declared client id must match it.
* Required (not optional) so a new call site cannot silently fall back to the declared id.
*/
connectionClientId: string | undefined
}
/**
@@ -15,7 +15,14 @@ export const TERMINAL_SUBSCRIBE_METHODS: RpcAnyMethod[] = [
params: TerminalSubscribe,
handler: async (
params,
{ runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal },
{
runtime,
connectionId,
clientId: connectionClientId,
sendBinary,
registerBinaryStreamHandler,
signal
},
emit
) => {
let leaf = runtime.resolveLeafForHandle(params.terminal)
@@ -70,6 +77,7 @@ export const TERMINAL_SUBSCRIBE_METHODS: RpcAnyMethod[] = [
emit,
ptyId,
clientId,
connectionClientId,
isMobile,
supportsDesktopViewportClaims: params.capabilities?.desktopViewportClaims === 1,
supportsQueryReply: params.capabilities?.queryReply === 1,
@@ -51,7 +51,8 @@ export function makeRequest(method: string, params?: unknown): RpcRequest {
export function startDesktopMultiplexSubscribe(
overrides: Partial<OrcaRuntimeService> = {},
trace?: string[],
sendBinaryOverride?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
sendBinaryOverride?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void,
connectionClientId?: string
) {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
@@ -93,6 +94,7 @@ export function startDesktopMultiplexSubscribe(
},
{
connectionId: 'conn-desktop-first-paint',
clientId: connectionClientId,
sendBinary: (bytes) => {
const sent = sendBinaryOverride?.(bytes)
if (sent === false) {
@@ -239,6 +239,118 @@ describe('terminal query-reply opcode guards', () => {
})
})
describe('terminal query-reply opcode 18 author identity', () => {
// Why: the declared `client.id` is free-form page input; only the transport's device token is authenticated.
const IDENTITY_CASES = [
{
name: 'a declared id impersonating another paired device',
declaredClientId: 'mobile-authority',
connectionClientId: 'mobile-impostor',
delivered: false
},
{
name: 'a declared id matching the connection device token',
declaredClientId: 'mobile-authority',
connectionClientId: 'mobile-authority',
delivered: true
},
{
name: 'a connection carrying no device token',
declaredClientId: 'mobile-authority',
connectionClientId: undefined,
delivered: true
}
]
it.each(IDENTITY_CASES)('gates multiplex opcode 18 on $name', async (testCase) => {
const sendTerminal = vi.fn().mockResolvedValue({ accepted: true })
const harness = startDesktopMultiplexSubscribe(
{
sendTerminal,
handleMobileSubscribe: vi.fn().mockResolvedValue(undefined),
handleMobileUnsubscribe: vi.fn(),
isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true)
},
undefined,
undefined,
testCase.connectionClientId
)
await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true))
harness.handlers.get(0)?.(
subscribeFrame({
streamId: 21,
clientType: 'mobile',
negotiated: true,
clientId: testCase.declaredClientId
})
)
await vi.waitFor(() => expect(harness.handlers.has(21)).toBe(true))
harness.handlers.get(21)?.(queryReplyFrame(21))
// Ordinary input on the same stream proves the stream is live and the drop is guard-specific.
harness.handlers.get(21)?.(inputFrame(21))
await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(testCase.delivered ? 2 : 1))
expect(sendTerminal.mock.calls.some((call) => call[1]?.text === QUERY_REPLY)).toBe(
testCase.delivered
)
harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint')
await harness.dispatchPromise
})
it.each(IDENTITY_CASES)('gates direct opcode 18 on $name', async (testCase) => {
const registry = createSubscriptionRegistryDouble()
const messages: string[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const sendTerminal = vi.fn().mockResolvedValue({ accepted: true })
const runtime = stubRuntime({
...directSubscribeRuntime(registry),
isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true),
sendTerminal
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: testCase.declaredClientId, type: 'mobile' },
capabilities: { terminalBinaryStream: 1, queryReply: 1 }
}),
(message) => messages.push(message),
{
connectionId: `conn-identity-${testCase.name}`,
clientId: testCase.connectionClientId,
sendBinary: vi.fn(),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe(
true
)
)
const streamId = messages
.map((message) => JSON.parse(message).result)
.find((event) => event?.type === 'subscribed').streamId
handlers.get(streamId)?.(queryReplyFrame(streamId))
handlers.get(streamId)?.(inputFrame(streamId))
await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(testCase.delivered ? 2 : 1))
expect(sendTerminal.mock.calls.some((call) => call[1]?.text === QUERY_REPLY)).toBe(
testCase.delivered
)
runtime.cleanupSubscription(`terminal-1:${testCase.declaredClientId}`)
await dispatchPromise
})
})
function directSubscribeRuntime(registry: ReturnType<typeof createSubscriptionRegistryDouble>) {
return {
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
@@ -263,6 +375,7 @@ function subscribeFrame(options: {
streamId: number
clientType: 'mobile' | 'desktop'
negotiated: boolean
clientId?: string
}) {
return decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
@@ -272,7 +385,7 @@ function subscribeFrame(options: {
payload: encodeTerminalStreamJson({
streamId: options.streamId,
terminal: 'terminal-1',
client: { id: 'client-1', type: options.clientType },
client: { id: options.clientId ?? 'client-1', type: options.clientType },
capabilities: {
ackOutput: 1,
...(options.negotiated ? { queryReply: 1 } : {})