From 696b83186f50e9ca66147d4d8d0b323a5aaa0c23 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 1 Sep 2026 23:01:06 -0400 Subject: [PATCH] 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 --- .../hosted-ios-app-bound-navigation-probe.mjs | 104 ++++++++ .../hosted-ios-user-gesture-window-probe.mjs | 226 ++++++++++++++++++ ...s-webview-gesture-and-app-bound-probes.mjs | 137 +++++++++++ 3 files changed, 467 insertions(+) create mode 100644 mobile/scripts/hosted-ios-app-bound-navigation-probe.mjs create mode 100644 mobile/scripts/hosted-ios-user-gesture-window-probe.mjs create mode 100644 mobile/scripts/run-hosted-ios-webview-gesture-and-app-bound-probes.mjs diff --git a/mobile/scripts/hosted-ios-app-bound-navigation-probe.mjs b/mobile/scripts/hosted-ios-app-bound-navigation-probe.mjs new file mode 100644 index 00000000000..1cac07227f3 --- /dev/null +++ b/mobile/scripts/hosted-ios-app-bound-navigation-probe.mjs @@ -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())}` +} diff --git a/mobile/scripts/hosted-ios-user-gesture-window-probe.mjs b/mobile/scripts/hosted-ios-user-gesture-window-probe.mjs new file mode 100644 index 00000000000..11378b2d3bb --- /dev/null +++ b/mobile/scripts/hosted-ios-user-gesture-window-probe.mjs @@ -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)) +} diff --git a/mobile/scripts/run-hosted-ios-webview-gesture-and-app-bound-probes.mjs b/mobile/scripts/run-hosted-ios-webview-gesture-and-app-bound-probes.mjs new file mode 100644 index 00000000000..11cad04abfc --- /dev/null +++ b/mobile/scripts/run-hosted-ios-webview-gesture-and-app-bound-probes.mjs @@ -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 +})