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