test(mobile): probe the hybrid WebView gesture window and app-bound flag

Two security-review questions about the hybrid iOS shell needed measurement
rather than reading: which native touches arm the 5s user-gesture window that
privileged page requests spend, and whether limitsNavigationsToAppBoundDomains
does anything while app.json declares no WKAppBoundDomains key.

The gesture probe drives clipboardWrite, the cheapest gesture-gated mutation,
through the page bridge after each candidate arming action and records whether
the shell granted or denied it. The app-bound probe asks the page to navigate
to an external https origin and records who refuses it: the shell's navigation
delegate raises a native warning banner, while an app-bound refusal would fail
the provisional navigation inside WebKit with no delegate decision.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-01 23:01:06 -04:00
parent 65a766beb2
commit 696b83186f
3 changed files with 467 additions and 0 deletions
@@ -0,0 +1,104 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { waitForHostedIosAccessibilityLabel } from './hosted-ios-emulator-accessibility.mjs'
import { evaluateHostedDocumentWithRetry } from './hosted-webview-cdp-session.mjs'
const execFileAsync = promisify(execFile)
const BLOCKED_WARNING_LABEL = 'Navigation outside Orca was blocked.'
const EXTERNAL_ORIGIN = 'https://example.com/'
const LOG_PREDICATE =
'senderImagePath CONTAINS "WebKit" OR process == "Orca" OR process CONTAINS "com.apple.WebKit"'
// The Swift shell sets limitsNavigationsToAppBoundDomains while app.json declares no
// WKAppBoundDomains key. The shell's own navigation delegate cancels the same navigations, so the
// only way to tell which mechanism fires is to watch who reports the refusal: the delegate raises
// onNavigationBlocked (a native warning banner), while an app-bound refusal fails the provisional
// navigation inside WebKit with no delegate decision.
export async function probeHostedIosAppBoundNavigation(
{ deviceUdid, emulator, sessionDocument, timeoutMs },
operations = {}
) {
const evaluate = operations.evaluate ?? evaluateHostedDocumentWithRetry
const waitForLabel = operations.waitForLabel ?? waitForHostedIosAccessibilityLabel
const collectLog = operations.collectLog ?? collectSimulatorLog
const before = await readDocumentIdentity(sessionDocument, evaluate)
const logStart = new Date()
await requestExternalNavigation(sessionDocument, evaluate)
const warning = await waitForBlockedWarning(emulator, waitForLabel, timeoutMs)
const after = await readDocumentIdentity(sessionDocument, evaluate)
const log = await collectLog(deviceUdid, logStart)
return {
appBoundLogLines: log.filter((line) => /app-?bound/i.test(line)).slice(0, 20),
blockedWarningObserved: warning,
documentRetained: after.href === before.href && after.origin === before.origin,
externalOrigin: EXTERNAL_ORIGIN,
hrefAfter: after.href,
hrefBefore: before.href,
webKitLogLines: log.slice(0, 40)
}
}
async function requestExternalNavigation(document, evaluate) {
const expression = `(() => {
try {
location.href = ${JSON.stringify(EXTERNAL_ORIGIN)};
return JSON.stringify({ requested: true, error: null });
} catch (error) {
return JSON.stringify({ requested: false, error: String(error).slice(0, 240) });
}
})()`
const result = JSON.parse(await evaluate(document, expression))
if (result?.requested !== true) {
throw new Error(`App-bound navigation probe could not request a navigation: ${result?.error}`)
}
}
async function readDocumentIdentity(document, evaluate) {
const expression = `JSON.stringify({
href: String(location.href).slice(0, 2048),
origin: String(location.origin).slice(0, 512)
})`
return JSON.parse(await evaluate(document, expression))
}
async function waitForBlockedWarning(emulator, waitForLabel, timeoutMs) {
try {
await waitForLabel(emulator, BLOCKED_WARNING_LABEL, Math.min(timeoutMs, 30_000))
return true
} catch {
return false
}
}
async function collectSimulatorLog(deviceUdid, since) {
const { stdout } = await execFileAsync(
'xcrun',
[
'simctl',
'spawn',
deviceUdid,
'log',
'show',
'--style',
'compact',
'--start',
formatLogTimestamp(since),
'--predicate',
LOG_PREDICATE
],
{ maxBuffer: 32 * 1024 * 1024 }
).catch(() => ({ stdout: '' }))
return stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
}
function formatLogTimestamp(value) {
const pad = (part) => String(part).padStart(2, '0')
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(
value.getHours()
)}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`
}
@@ -0,0 +1,226 @@
import { randomBytes } from 'node:crypto'
import {
runHostedIosEmulatorCommand,
tapHostedIosPoint
} from './hosted-ios-emulator-accessibility.mjs'
import {
activateHostedWebViewControl,
evaluateHostedDocumentWithRetry,
waitForVisibleHostedWebView
} from './hosted-webview-cdp-session.mjs'
import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs'
const GESTURE_PROBE_PROPERTY = '__orcaE2eGestureWindowProbe'
// MOBILE_WEB_USER_GESTURE_MAX_AGE_MS is 5000; wait past it so no case inherits the previous one.
const GESTURE_QUIESCENCE_MS = 6_500
const PAGE_TAP_POINT = { x: 0.5, y: 0.6 }
const SCROLL_FRAME_COUNT = 24
const SCROLL_FROM_Y = 0.72
const SCROLL_TO_Y = 0.42
export async function probeHostedIosUserGestureWindow(
{ discoveryUrl, emulator, expectedWorkspace, timeoutMs, workspaceDocument },
operations = {}
) {
const evaluate = operations.evaluate ?? evaluateHostedDocumentWithRetry
const waitForDocument = operations.waitForDocument ?? waitForVisibleHostedWebView
const activateWorkspace = operations.activateWorkspace ?? activateHostedWorkspaceRow
const activateControl = operations.activateControl ?? activateHostedWebViewControl
const tapPoint = operations.tapPoint ?? tapHostedIosPoint
const runCommand = operations.runCommand ?? runHostedIosEmulatorCommand
await installGestureProbe(workspaceDocument, evaluate)
await activateWorkspace(workspaceDocument, expectedWorkspace, activateControl, timeoutMs, () =>
waitForDocument({ discoveryUrl, expectedText: expectedWorkspace, timeoutMs })
)
const sessionDocument = await waitForDocument({
discoveryUrl,
expectedText: 'Mobile Emulator',
expectedHrefIncludes: '/session/',
timeoutMs
})
const geometry = await readViewportGeometry(sessionDocument, evaluate)
const insetPoint = { x: 0.5, y: geometry.viewportTopRatio / 2 }
const cases = []
const record = async (name, action) => {
cases.push({ name, ...(await runGestureCase(sessionDocument, evaluate, timeoutMs, action)) })
}
// Control: no native touch at all, so the window must be closed.
await record('no-gesture', quiesce)
// Control: a page-originated DOM touch/click cannot reach the React Native touch responder.
await record('page-dispatched-touch', async () => {
await quiesce()
await dispatchPageTouch(sessionDocument, evaluate)
})
// G3a: a native tap that lands on the WKWebView child.
await record('native-tap-on-webview', async () => {
await quiesce()
await tapPoint(emulator, PAGE_TAP_POINT)
})
// The same window must not survive the operation that spent it.
await record('replay-without-new-gesture', () => Promise.resolve())
// G3b: a pan with no tap, i.e. a scroll.
await record('native-scroll-on-webview', async () => {
await quiesce()
await scrollHostedIosPoint(emulator, runCommand)
})
// G3c: a native tap on the shell chrome above the WebView. A zero-height strip means the hosted
// state leaves no native pixels to tap, which is itself the answer.
if (geometry.viewportTop >= 2) {
await record('native-tap-outside-webview', async () => {
await quiesce()
await tapPoint(emulator, insetPoint)
})
} else {
cases.push({ name: 'native-tap-outside-webview', skipped: 'no native strip above the WebView' })
}
// The window must expire on its own.
await record('native-tap-then-expiry', async () => {
await quiesce()
await tapPoint(emulator, PAGE_TAP_POINT)
await quiesce()
})
return { cases, geometry, insetPoint, sessionDocument }
}
async function runGestureCase(document, evaluate, timeoutMs, action) {
await action()
const requestId = randomBytes(16).toString('base64url')
await postClipboardWriteProbe(document, requestId, evaluate)
const response = await waitForProbeResponse(document, requestId, timeoutMs, evaluate)
return {
error: response?.error?.code ?? null,
granted: response?.status === 'success',
status: response?.status ?? 'missing'
}
}
async function installGestureProbe(document, evaluate) {
const expression = `(() => {
const key = ${JSON.stringify(GESTURE_PROBE_PROPERTY)};
if (globalThis[key]) return JSON.stringify({ started: true });
const native = globalThis.OrcaNative;
if (!native || typeof native.postMessage !== 'function') {
return JSON.stringify({ started: false });
}
const state = globalThis[key] = { context: null, responses: Object.create(null) };
addEventListener('message', (event) => {
try {
const message = typeof event.data === 'string' ? JSON.parse(event.data) : null;
if (message?.type === 'response' && typeof message.requestId === 'string') {
state.responses[message.requestId] = message;
}
} catch {}
});
globalThis.OrcaNative = Object.freeze({
postMessage(value) {
try {
const message = JSON.parse(value);
if (message?.shellSessionId && message?.buildId && Number.isInteger(message.version)) {
state.context = {
version: message.version,
shellSessionId: message.shellSessionId,
buildId: message.buildId
};
}
} catch {}
native.postMessage(value);
}
});
return JSON.stringify({ started: true });
})()`
const result = JSON.parse(await evaluate(document, expression))
if (result?.started !== true) {
throw new Error('Gesture window probe could not observe the hosted bridge')
}
}
// clipboardWrite is the cheapest gesture-gated mutation: it consumes the window and answers with a
// success or a permission_required error without presenting any UI.
async function postClipboardWriteProbe(document, requestId, evaluate) {
const expression = `(() => {
const state = globalThis[${JSON.stringify(GESTURE_PROBE_PROPERTY)}];
if (!state?.context) return JSON.stringify({ posted: false });
globalThis.OrcaNative.postMessage(JSON.stringify({
...state.context,
type: 'request',
mode: 'once',
requestId: ${JSON.stringify(requestId)},
capability: 'native',
operation: 'clipboardWrite',
payload: { text: 'orca-gesture-window-probe' }
}));
return JSON.stringify({ posted: true });
})()`
const result = JSON.parse(await evaluate(document, expression))
if (result?.posted !== true) {
throw new Error('Gesture window probe did not capture an active bridge context')
}
}
async function waitForProbeResponse(document, requestId, timeoutMs, evaluate) {
const deadline = Date.now() + timeoutMs
const expression = `JSON.stringify(globalThis[${JSON.stringify(
GESTURE_PROBE_PROPERTY
)}]?.responses?.[${JSON.stringify(requestId)}] ?? null)`
while (Date.now() < deadline) {
const result = JSON.parse(await evaluate(document, expression))
if (result) {
return result
}
await delay(100)
}
throw new Error('Gesture window probe response did not return to the hosted page')
}
// The WebView is bottom-anchored, so screen.height - innerHeight is the native strip above it.
async function readViewportGeometry(document, evaluate) {
const expression = `JSON.stringify({
innerHeight: Number(innerHeight),
screenHeight: Number(screen.height),
screenWidth: Number(screen.width)
})`
const geometry = JSON.parse(await evaluate(document, expression))
const viewportTop = Math.max(0, geometry.screenHeight - geometry.innerHeight)
return { ...geometry, viewportTop, viewportTopRatio: viewportTop / geometry.screenHeight }
}
async function dispatchPageTouch(document, evaluate) {
const expression = `(() => {
const target = document.elementFromPoint(innerWidth / 2, innerHeight * 0.6) ?? document.body;
if (!target) return JSON.stringify({ dispatched: false });
for (const type of ['pointerdown', 'mousedown', 'touchstart', 'click']) {
target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
}
return JSON.stringify({ dispatched: true });
})()`
const result = JSON.parse(await evaluate(document, expression))
if (result?.dispatched !== true) {
throw new Error('Gesture window probe could not dispatch a page touch')
}
}
async function scrollHostedIosPoint(emulator, runCommand) {
const frames = [{ type: 'begin', x: PAGE_TAP_POINT.x, y: SCROLL_FROM_Y }]
for (let index = 1; index <= SCROLL_FRAME_COUNT; index++) {
const ratio = index / SCROLL_FRAME_COUNT
frames.push({
type: 'move',
x: PAGE_TAP_POINT.x,
y: SCROLL_FROM_Y + (SCROLL_TO_Y - SCROLL_FROM_Y) * ratio
})
}
frames.push({ type: 'end', x: PAGE_TAP_POINT.x, y: SCROLL_TO_Y })
await runCommand(emulator, ['gesture', JSON.stringify(frames)])
}
function quiesce() {
return delay(GESTURE_QUIESCENCE_MS)
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -0,0 +1,137 @@
#!/usr/bin/env node
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { startCdpServer } from 'inspect-webkit'
import { resolveEmulatorOrcaCli } from './emulator-orca-cli-selection.mjs'
import { stopHostedChildProcess } from './hosted-child-process-shutdown.mjs'
import { findAvailableHostedLoopbackPort } from './hosted-loopback-port.mjs'
import { probeHostedIosAppBoundNavigation } from './hosted-ios-app-bound-navigation-probe.mjs'
import { startHostedIosEmulatorController } from './hosted-ios-emulator-controller.mjs'
import { openHostedIosHybridRoute } from './hosted-ios-hybrid-route-handoff.mjs'
import {
startHostedIosMobileLauncher,
waitForHostedIosMobileLauncher
} from './hosted-ios-mobile-launcher.mjs'
import { completeHostedIosNativeOnboarding } from './hosted-ios-native-onboarding.mjs'
import { hostedIosSimulatorAppPreparation } from './hosted-ios-simulator-app-preparation.mjs'
import {
bootHostedIosSimulator,
resolveHostedIosSimulatorUdid
} from './hosted-ios-simulator-device.mjs'
import { probeHostedIosUserGestureWindow } from './hosted-ios-user-gesture-window-probe.mjs'
import { waitForVisibleHostedWebView } from './hosted-webview-cdp-session.mjs'
import { resolveHostedWebViewRuntimeDirectory } from './hosted-webview-runtime-directory.mjs'
const worktree = path.resolve(import.meta.dirname, '../..')
const options = parseOptions(process.argv.slice(2))
const runtimeDirectory = resolveHostedWebViewRuntimeDirectory({
worktree,
override: process.env.ORCA_E2E_MOBILE_WEBVIEW_RUN_DIRECTORY
})
const orcaSelection = resolveEmulatorOrcaCli({
explicitCommand: process.env.ORCA_CLI,
managedCommand: process.env.ORCA_CLI_COMMAND,
devRepoRoot: process.env.ORCA_DEV_REPO_ROOT,
worktree,
cwd: worktree
})
function parseOptions(args) {
const parsed = {
device: 'iPhone 17 Pro',
gestureOnly: false,
reuseNativeInstall: false,
skipNativeBuild: false,
timeoutMs: 180_000
}
for (let index = 0; index < args.length; index++) {
if (args[index] === '--device' && args[index + 1]) {
parsed.device = args[++index]
} else if (args[index] === '--timeout-ms' && args[index + 1]) {
parsed.timeoutMs = Number(args[++index])
} else if (args[index] === '--skip-native-build') {
parsed.skipNativeBuild = true
} else if (args[index] === '--reuse-native-install') {
parsed.reuseNativeInstall = true
} else if (args[index] === '--gesture-only') {
parsed.gestureOnly = true
} else {
throw new Error(`Unknown argument: ${args[index]}`)
}
}
return parsed
}
async function main() {
if (process.platform !== 'darwin') {
throw new Error('Hosted iOS WebView probes require macOS and Xcode.')
}
mkdirSync(runtimeDirectory, { recursive: true, mode: 0o700 })
const deviceUdid = await resolveHostedIosSimulatorUdid(options.device)
let launcher = null
let inspector = null
let emulatorController = null
try {
await bootHostedIosSimulator(deviceUdid)
emulatorController = await startHostedIosEmulatorController({
orcaCli: orcaSelection.command,
runtimeDirectory,
worktree
})
const appPreparation = hostedIosSimulatorAppPreparation({ deviceUdid, worktree, ...options })
const nativeAppPath = await appPreparation.run()
launcher = startHostedIosMobileLauncher({
deviceUdid,
emulatorControlUserDataPath: emulatorController.userData,
orcaCli: orcaSelection.command,
runtimeDirectory,
worktree
})
await waitForHostedIosMobileLauncher(launcher, options.timeoutMs)
const emulator = {
deviceUdid,
orcaCli: orcaSelection.command,
userDataDir: emulatorController.userData,
worktree
}
const inspectorPort = await findAvailableHostedLoopbackPort()
const discoveryUrl = `http://127.0.0.1:${inspectorPort}`
inspector = await startCdpServer({ port: inspectorPort })
const expectedWorkspace = path.basename(worktree)
await completeHostedIosNativeOnboarding(emulator, expectedWorkspace, options.timeoutMs)
await openHostedIosHybridRoute(emulator, options.timeoutMs)
const workspaceDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: expectedWorkspace,
timeoutMs: options.timeoutMs
})
const gesture = await probeHostedIosUserGestureWindow({
discoveryUrl,
emulator,
expectedWorkspace,
timeoutMs: options.timeoutMs,
workspaceDocument
})
const appBound = options.gestureOnly
? null
: await probeHostedIosAppBoundNavigation({
deviceUdid,
emulator,
sessionDocument: gesture.sessionDocument,
timeoutMs: options.timeoutMs
})
const { sessionDocument: _session, ...gestureEvidence } = gesture
console.log(JSON.stringify({ appBound, gesture: gestureEvidence, nativeAppPath }, null, 2))
} finally {
inspector?.stop()
await stopHostedChildProcess(launcher)
await emulatorController?.stop()
}
}
main().catch((error) => {
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error))
process.exitCode = 1
})