From 8ee4fcdac19ef2f57eb085d0acf89f6bad93042d Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:43:01 -0700 Subject: [PATCH] fix(mobile): redact hosted privacy surfaces --- .../mobile-web-rnw-executable-policy.mjs | 5 + .../mobile-web-rnw-executable-policy.test.mjs | 8 ++ ...hybrid-webview-implementation-checklist.md | 24 +++- ...bile-hybrid-webview-single-pr-migration.md | 18 ++- ...27-mobile-hybrid-webview-remaining-work.md | 29 +++-- .../app/h/[hostId]/session/[worktreeId].tsx | 5 +- mobile/app/h/[hostId]/tasks.tsx | 23 ++-- mobile/app/pair-confirm.tsx | 3 +- mobile/app/pair-scan.tsx | 3 +- .../mobile-web-route-restorer.tsx | 4 +- .../diagnostics/mobile-log-error-kind.test.ts | 12 ++ .../src/diagnostics/mobile-log-error-kind.ts | 3 + .../hooks/mobile-dictation-desktop-start.ts | 5 +- .../src/hooks/use-native-mobile-dictation.ts | 5 +- .../mobile-web-bridge-roundtrip.test.ts | 2 +- ...mobile-web-browser-event-sanitizer.test.ts | 27 ++++ .../mobile-web-browser-event-sanitizer.ts | 3 +- .../mobile-web-browser-operations.test.ts | 20 +++ .../mobile-web-browser-operations.ts | 3 +- .../mobile-web-route-restoration.test.ts | 26 ++-- .../mobile-web-route-restoration.ts | 20 ++- .../mobile-web-session-snapshot.test.ts | 41 ++++++ .../mobile-web/mobile-web-session-snapshot.ts | 3 +- ...st-session-terminal-stream-presentation.ts | 4 - .../src/session/use-mobile-terminal-paste.ts | 8 +- .../terminal-webview-engine-error-state.tsx | 2 +- .../mobile-direct-rpc-sender.test.ts | 31 +++++ .../src/transport/mobile-direct-rpc-sender.ts | 5 +- .../transport/redacted-websocket-endpoint.ts | 10 +- .../rpc-client-log-redaction.test.ts | 84 ++++++++++-- mobile/src/transport/rpc-client.ts | 32 +++-- .../src/transport/socket-event-debug.test.ts | 35 +++++ mobile/src/transport/socket-event-debug.ts | 43 ++---- .../browser-operation-contract.test.ts | 14 ++ .../mobile-web/browser-operation-contract.ts | 20 +-- .../mobile-web/browser-url-privacy.test.ts | 60 +++++++++ src/shared/mobile-web/browser-url-privacy.ts | 122 ++++++++++++++++++ 37 files changed, 618 insertions(+), 144 deletions(-) create mode 100644 mobile/src/diagnostics/mobile-log-error-kind.test.ts create mode 100644 mobile/src/diagnostics/mobile-log-error-kind.ts create mode 100644 mobile/src/mobile-web/mobile-web-browser-event-sanitizer.test.ts create mode 100644 mobile/src/transport/mobile-direct-rpc-sender.test.ts create mode 100644 mobile/src/transport/socket-event-debug.test.ts create mode 100644 src/shared/mobile-web/browser-url-privacy.test.ts create mode 100644 src/shared/mobile-web/browser-url-privacy.ts diff --git a/config/scripts/mobile-web-rnw-executable-policy.mjs b/config/scripts/mobile-web-rnw-executable-policy.mjs index bebcecec137..24fcb7c5400 100644 --- a/config/scripts/mobile-web-rnw-executable-policy.mjs +++ b/config/scripts/mobile-web-rnw-executable-policy.mjs @@ -1,4 +1,6 @@ const runtimeCodeGenerationPattern = /\beval\s*\(|\bnew\s+Function\s*\(|sourceMappingURL/ +const buildEnvironmentPathPattern = + /(?:\/Users\/[^/"'\s]+\/|\/home\/[^/"'\s]+\/|[A-Za-z]:\\\\Users\\\\[^\\/"'\s]+\\\\)/ const pagePersistencePatterns = [ /\b(?:window\.)?(?:localStorage|sessionStorage)\s*\.\s*(?:getItem|setItem|removeItem|clear|key)\b/, @@ -13,6 +15,9 @@ export function mobileWebRnwExecutablePolicyFailure(source) { if (runtimeCodeGenerationPattern.test(source)) { return 'runtime code generation' } + if (buildEnvironmentPathPattern.test(source)) { + return 'build environment path disclosure' + } if (pagePersistencePatterns.some((pattern) => pattern.test(source))) { return 'page-owned persistence' } diff --git a/config/scripts/mobile-web-rnw-executable-policy.test.mjs b/config/scripts/mobile-web-rnw-executable-policy.test.mjs index 3acb1e5c8a1..3aaeced9252 100644 --- a/config/scripts/mobile-web-rnw-executable-policy.test.mjs +++ b/config/scripts/mobile-web-rnw-executable-policy.test.mjs @@ -18,6 +18,14 @@ describe('mobile web RNW executable policy', () => { expect(() => assertMobileWebRnwExecutablePolicy(source)).toThrow('page-owned persistence') }) + it.each([ + 'const sourcePath="/Users/developer/orca/mobile/app.tsx"', + 'const sourcePath="/home/runner/work/orca/mobile/app.tsx"', + String.raw`const sourcePath="C:\\Users\\builder\\orca\\mobile\\app.tsx"` + ])('rejects build environment paths: %s', (source) => { + expect(mobileWebRnwExecutablePolicyFailure(source)).toBe('build environment path disclosure') + }) + it('allows inert syntax-highlighter keyword strings', () => { expect( mobileWebRnwExecutablePolicyFailure('["document","localStorage","sessionStorage","module"]') diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md index 4ca7a5abd2d..0135b53ef44 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md @@ -549,7 +549,7 @@ recovery, or physical-device gates. RNW artifact. The retired Vite entry, package plugin/verifier, duplicate workspace/session/files/source-control UI, and UI-only tests are removed. Production bridge clients and transport tests remain. The rebuilt package - now verifies as build `bcd9c95e…`. + now verifies as build `e0ad7c7d…`. ## 3. Production Package Delivery RPC @@ -1475,8 +1475,15 @@ copy. packaging and independent verification rejects executable access to Web Storage, IndexedDB, CacheStorage, cookies, WebSQL, and origin-private filesystem/storage persistence. It permits inert storage keywords used by - the production syntax highlighter. The remaining URL, DOM/message, cache - metadata, log, diagnostic, analytics, crash-report, and fixture audit is + the production syntax highlighter and rejects macOS, Linux, and Windows + build-machine user paths. Hosted URL construction owns a fixed page-host + label and cannot accept a paired-host identifier. Browser state removes URL + userinfo, signed/OAuth query or fragment credentials, and host-local file + paths before entering the page; page-originated navigation rejects those + values and unsupported schemes. Native transport and shared-route logs now + drop endpoint, WebSocket/auth event values, repository identity, and raw + errors in favor of protocol/state/category fields. The remaining DOM/message, + cache metadata, diagnostic, analytics, crash-report, and fixture audit is still open. - [~] Verify the WebView cannot make network requests. Static iOS/Android CSP and native-origin controls are covered. Android also sets @@ -1927,9 +1934,10 @@ passes 576 files / 3,440 tests with 2 expected skips. A full root run passes 30-second dynamic-import timeout; its isolated 2-test rerun passes in 4.69 seconds. Mobile/root/RNW typechecks, mobile/shared lint, all 55 reliability gates, max-lines, localization, formatting, diff hygiene, and production -package verification pass. The rebuilt package is -`bcd9c95e3a1e416d82a240ff10ef311375d03cbc3210d44876c7bca896e093b7`: -50 assets, 9,299,540 raw bytes, and 2,691,263 gzip bytes. +package verification pass. The latest privacy-hardening package is +`e0ad7c7dbb2991f10945bf66b4786dc8efc82599ddea0652e4a95c9d0d554eba`: +50 assets, 9,301,460 raw bytes, and 2,692,026 gzip bytes. The full mobile suite +passes 580 files / 3,449 tests with 2 expected skips. | Date | Workstream | Evidence | Result | | ---------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -2306,6 +2314,7 @@ package verification pass. The rebuilt package is | 2026-07-27 | Android corrupt cache | Forged and activated corrupt `ffff…`, stopped Desktop, cold-opened the exact APK, and inspected cache plus the private document with the durable harness | Passed; verified `48531616…` restored, forged generation removed, and bridge-ready workspace-list UI retained | | 2026-07-27 | Hosted storage boundary | Inert hosted AsyncStorage adapter, verifier rejection of executable `localStorage`, and terminal preference/accessory/custom-shortcut typed bridge operations | Passed; page-to-native shortcut read/write round trip and unchanged session adapter included in 7 focused files / 34 tests | | 2026-07-28 | Page persistence | Packaging and verification share rejection of runtime code generation plus Web Storage, IndexedDB, CacheStorage, cookie, WebSQL, and origin-private filesystem/storage access | Focused policy/package/page-source tests pass; mobile and RNW typechecks, focused lint, max-lines, formatting, diff hygiene, and exact build `bcd9c95e…` verification pass | +| 2026-07-28 | Privacy boundaries | Fixed page-host label; credential/file-path URL sanitization and navigation rejection; build-path rejection; value-free transport, pairing, Tasks, clipboard, dictation, and terminal logs | 23 focused root tests, 25 focused mobile tests, full 580-file mobile suite / 3,449 tests with 2 skips, typechecks, lint, max-lines, formatting, diff hygiene, and exact build `e0ad7c7d…` pass | | 2026-07-27 | Android private origin | Exact Debug APK at reserved HTTPS origin, main-frame fragment validation, hosted History API fragment retention, deliberate-red network/navigation corpus, and real routed terminal snapshot | Passed; `/h/.../session/...#` retained, zero sentinel observations, bridge loaded one real tab, and fresh logcat had no prior exceptions | | 2026-07-27 | RNW package | Exact-source build and independent verifier after hosted History API session binding | Passed; build `8b5c9b64b4caa778603ff4e3845a7f3df9c6ed0f027560cd5447ed259ebbb0e8`, 49 assets, 9,178,634 raw bytes, 2,662,870 gzip bytes | | 2026-07-27 | Validation | Focused bridge/origin/history tests, mobile and mobile-web typechecks/lints, max-lines ratchet, Android JVM/process tests, exact Debug assembly, and diff hygiene | Passed; 34 focused tests, 17 Android JVM tests, 577 Gradle tasks, and no new max-lines bypass | @@ -2826,4 +2835,5 @@ package verification pass. The rebuilt package is | 2026-07-28 | Finding | The full root run passes 3,829 files / 40,205 tests with 70 expected skips but the unrelated `ProjectViewWrapper` dynamic-import boundary again reaches its 30-second timeout under full-suite load. Its isolated rerun passes 2/2 in 4.69 seconds. | | 2026-07-28 | Complete | Mirrored Swift and Kotlin package-store suites now pass 120 concurrent cache flows per platform. The added same-host matrix covers 16 competing distinct-generation commits, 16 live-session activation/cleanup flows with post-activation reads, and 16 interleaved commit/abort mutations; final activation retains at most active plus previous, and host removal leaves no cache subtree. | | 2026-07-28 | Complete | Packaging and independent verification now share one executable policy that rejects runtime code generation and page-owned Web Storage, IndexedDB, CacheStorage, cookie, WebSQL, and origin-private filesystem/storage access while permitting inert syntax-highlighter keywords. Focused tests, typechecks, lint, max-lines, formatting, diff hygiene, and exact `bcd9c95e…` package verification pass. | -| 2026-07-28 | Next | Continue the privacy audit across URLs, DOM/messages, cache metadata, logs, diagnostics, analytics, crash reports, and fixtures; then run exact release-app corpus injection, broader live two-host/topology races, and independent security review. | +| 2026-07-28 | Complete | Hosted page URL construction no longer accepts a paired-host identifier. Browser results, events, and snapshots remove credentials and local file paths; hosted navigation rejects credential-bearing URLs. Packaging rejects build-machine paths. Mobile logs retain only reviewed protocol, state, count, and category fields. The full mobile suite passes 580 files / 3,449 tests with 2 skips, and build `e0ad7c7d…` verifies at 50 assets / 9,301,460 raw / 2,692,026 gzip bytes. | +| 2026-07-28 | Next | Continue the privacy audit across DOM/messages, cache metadata, diagnostics, analytics, crash reports, and fixtures; then run exact release-app corpus injection, broader live two-host/topology races, and independent security review. | diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md index a13292c7874..120db412358 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md @@ -339,8 +339,16 @@ operations. Packaging and independent verification share one executable policy that rejects runtime code generation and access to Web Storage, IndexedDB, CacheStorage, cookies, WebSQL, or origin-private filesystem/storage persistence. Bare storage keywords remain allowed because the production syntax highlighter -contains inert keyword tables. Focused policy tests, page-to-native round trips, -and the exact 9,299,540-byte production package pass. +contains inert keyword tables. The same policy rejects macOS, Linux, and Windows +build-machine user paths. Hosted URL construction owns its fixed page-host label +and cannot accept a paired-host identifier. Browser results, events, and +snapshots remove URL userinfo, signed/OAuth query or fragment credentials, and +host-local file paths before entering the page; hosted navigation rejects those +values and unsupported schemes. Native transport, pairing, Tasks, clipboard, +dictation, and terminal error logs retain only reviewed protocol, state, count, +and error-category fields rather than endpoint, event, repository, or error +values. Focused policy tests, page-to-native round trips, and the exact +9,301,460-byte production package pass. This proves the core Android Debug emulator and deliberate isolation slices. The separate locally signed Release gate now passes on a production `user` @@ -2184,9 +2192,9 @@ The authoritative route now has a separately reviewed ceiling of 10 MiB total, from 8 MiB / 2 MiB / 7.5 MiB only after the legacy Mermaid CDN executable was replaced by the locally bundled, network-denied engine needed by both native and RNW without forking the existing UI. Boundary tests reject every -measurement above its ceiling. The current 50-asset package is 9,299,540 bytes -/ 2,691,263 bytes gzip and build -`bcd9c95e3a1e416d82a240ff10ef311375d03cbc3210d44876c7bca896e093b7`. +measurement above its ceiling. The current 50-asset package is 9,301,460 bytes +/ 2,692,026 bytes gzip and build +`e0ad7c7dbb2991f10945bf66b4786dc8efc82599ddea0652e4a95c9d0d554eba`. `build:mobile-web` and release resource mapping therefore select the RNW package; the original 2 MiB Vite-fixture ceiling is retired with that fixture. Workspace snapshots now page through diff --git a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md index 04f8e0b0ce7..b5323331edb 100644 --- a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md +++ b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md @@ -113,8 +113,8 @@ changed-file and full-mobile formatting, localization, the max-lines ratchet, and diff hygiene pass. React Doctor reports zero blocking errors across the migration without suppressions. The independently verified React Native Web package is -`bcd9c95e3a1e416d82a240ff10ef311375d03cbc3210d44876c7bca896e093b7`: -50 assets, 9,299,540 raw bytes, and 2,691,263 gzip bytes. +`e0ad7c7dbb2991f10945bf66b4786dc8efc82599ddea0652e4a95c9d0d554eba`: +50 assets, 9,301,460 raw bytes, and 2,692,026 gzip bytes. The immediately preceding `7c7c673d…` package passed the unpacked macOS arm64 → Docker SSH → actual iOS WKWebView journey from a clean app reinstall in 1.9 @@ -314,8 +314,8 @@ collection ceiling. Session subscription events are also bound to the requested workspace. Existing Source Control, provider-review, file, and Markdown correlation remains in force. -The production package now verifies as `bcd9c95e…`: 50 assets, 9,299,540 raw -bytes, and 2,691,263 gzip bytes. The full mobile suite passes 576 files / 3,440 +The production package now verifies as `e0ad7c7d…`: 50 assets, 9,301,460 raw +bytes, and 2,692,026 gzip bytes. The full mobile suite passes 580 files / 3,449 tests with 2 expected skips. Mobile, mobile-web, and root typechecks; mobile and mobile-web lint; all 55 reliability gates; localization; max-lines; and package verification pass. The full root run passes 3,829 files / 40,205 tests with 70 @@ -330,10 +330,15 @@ commit/abort mutations, bounded final retention, and host removal. Packaging and independent package verification now share one executable policy that rejects runtime code generation and page-owned Web Storage, IndexedDB, CacheStorage, cookie, WebSQL, and origin-private filesystem/storage access. It -permits inert storage keywords required by the production syntax highlighter. -Focused tests and exact build `bcd9c95e…` verification pass. The remaining -security work below is exact release-app corpus testing, broader live -cross-scope races, the rest of the privacy audit, and independent review. +permits inert storage keywords required by the production syntax highlighter +and rejects build-machine user paths. Hosted URL construction cannot accept a +paired-host identity. Browser state removes credentials and host-local file +paths before entering the page, and hosted navigation rejects credential-bearing +URLs. Native transport and shared-route logging removes endpoint, WebSocket/auth +event, repository, and raw error values. Focused tests and exact build +`e0ad7c7d…` verification pass. The remaining security work below is exact +release-app corpus testing, broader live cross-scope races, the rest of the +privacy audit, and independent review. ## 1. Production Cutover and Cleanup @@ -410,8 +415,12 @@ cross-scope races, the rest of the privacy audit, and independent review. - [ ] Verify no credential or privileged host identity reaches URLs, DOM state, page storage, cache assets, logs, diagnostics, analytics, or fixtures. Executable access to browser-owned persistence now fails during both - packaging and verification; URLs, DOM/messages, cache metadata, logs, - diagnostics, analytics, crash reports, and fixtures remain under audit. + packaging and verification. Hosted routes use a fixed page-host label, the + package rejects build-machine paths, browser state removes credential and + host-local file URLs, hosted navigation rejects credential-bearing URLs, + and reviewed mobile logs retain only protocol/state/category fields. + DOM/messages, cache metadata, remaining diagnostics, analytics, crash + reports, and fixtures remain under audit. - [ ] Verify all resource limits apply before allocation and during assembly. Persisted native manifests, activation metadata, and assets have pre-allocation read ceilings; every bridge operation has generated diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 80c830afa03..944f0c13e7c 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -46,6 +46,7 @@ import { } from 'lucide-react-native' import type { RpcClient } from '../../../../src/transport/rpc-client' import { loadSessionNativeHostProfile } from '../../../../src/session/session-native-host-profile' +import { mobileLogErrorKind } from '../../../../src/diagnostics/mobile-log-error-kind' import { persistSessionLastVisitedWorktree } from '../../../../src/session/session-last-visited-worktree' import { startRuntimeCapabilityRead } from '../../../../src/transport/runtime-capability-probe' import { @@ -3529,11 +3530,9 @@ export function SessionScreen({ terminalRefs.current.get(handle)?.cancelSelect() } catch (e) { triggerError() - const err = e as { name?: string; message?: string } // eslint-disable-next-line no-console console.warn('[mobile-clip] setString failed', { - name: err.name, - message: err.message + kind: mobileLogErrorKind(e) }) showToast("Couldn't copy", 1500) } diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 27d9cc2b9cc..c1fe148895b 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -37,6 +37,7 @@ import { } from '../../../src/transport/client-context-connection-metrics' import { classifyConnection } from '../../../src/transport/connection-health' import type { ConnectionState } from '../../../src/transport/types' +import { mobileLogErrorKind } from '../../../src/diagnostics/mobile-log-error-kind' import { StatusDot } from '../../../src/components/StatusDot' import { ActionSheetModal } from '../../../src/components/ActionSheetModal' import { BottomDrawer } from '../../../src/components/BottomDrawer' @@ -3271,11 +3272,10 @@ export default function MobileTasksScreen({ } catch (err) { const isExpectedSshSkip = isGitHubWorkItemsSshRemoteRequiredError(err) const logWorkItemFetchFailure = isExpectedSshSkip ? console.log : console.warn - logWorkItemFetchFailure( - '[mobile tasks] failed to fetch github work items', - repo.id, - isExpectedSshSkip && err instanceof Error ? err.message : err - ) + logWorkItemFetchFailure('[mobile tasks] failed to fetch github work items', { + expected: isExpectedSshSkip, + kind: mobileLogErrorKind(err) + }) return { items: [] as Array>, repoId: repo.id, @@ -3331,11 +3331,10 @@ export default function MobileTasksScreen({ } catch (err) { const isExpectedSshSkip = isGitHubWorkItemsSshRemoteRequiredError(err) const logWorkItemCountFailure = isExpectedSshSkip ? console.log : console.warn - logWorkItemCountFailure( - '[mobile tasks] failed to count github work items', - repo.id, - isExpectedSshSkip && err instanceof Error ? err.message : err - ) + logWorkItemCountFailure('[mobile tasks] failed to count github work items', { + expected: isExpectedSshSkip, + kind: mobileLogErrorKind(err) + }) return 0 } } @@ -3468,7 +3467,9 @@ export default function MobileTasksScreen({ ) } } catch (err) { - console.warn(`[mobile tasks] failed to fetch ${provider} work items`, repo.id, err) + console.warn(`[mobile tasks] failed to fetch ${provider} work items`, { + kind: mobileLogErrorKind(err) + }) return { items: [] as TaskItem[], error: err instanceof Error ? err.message : 'Failed to load GitLab tasks' diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index 0621caa4f01..8e63e884ff3 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -16,6 +16,7 @@ import { loadMobileOnboardingSteps, mobileOnboardingDestination } from '../src/onboarding/mobile-onboarding-plan' +import { mobileLogErrorKind } from '../src/diagnostics/mobile-log-error-kind' type Status = 'awaiting-confirm' | 'connecting' | 'error' @@ -131,7 +132,7 @@ export default function PairConfirmScreen() { if (!mountedRef.current || !attemptIsCurrent) { return } - console.warn('[pair-confirm] connect failed', err) + console.warn('[pair-confirm] connect failed', { kind: mobileLogErrorKind(err) }) setStatus('error') setErrorMessage( timedOut diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 892c1b359a1..ee41500367d 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -26,6 +26,7 @@ import { loadMobileOnboardingSteps, mobileOnboardingDestination } from '../src/onboarding/mobile-onboarding-plan' +import { mobileLogErrorKind } from '../src/diagnostics/mobile-log-error-kind' // Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken // route surfaces as a real error with the log visible instead of a @@ -175,7 +176,7 @@ export default function PairScanScreen() { if (!mountedRef.current || !attemptIsCurrent) { return } - console.warn('[pair] connect failed', err) + console.warn('[pair] connect failed', { kind: mobileLogErrorKind(err) }) setStatus('error') setErrorMessage( timedOut diff --git a/mobile/host-web-app/mobile-web-route-restorer.tsx b/mobile/host-web-app/mobile-web-route-restorer.tsx index 89feda2bd50..5b79c38239f 100644 --- a/mobile/host-web-app/mobile-web-route-restorer.tsx +++ b/mobile/host-web-app/mobile-web-route-restorer.tsx @@ -4,8 +4,6 @@ import { useRouter } from 'expo-router' import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel' import { mobileWebNavigationRouteTarget } from '../src/mobile-web/mobile-web-route-restoration' -const HOSTED_PAGE_HOST_ID = 'paired-orca-desktop' - export function MobileWebRouteRestorer() { const router = useRouter() const shell = useMobileWebNativeShell() @@ -20,7 +18,7 @@ export function MobileWebRouteRestorer() { return } restoredContextRef.current = restorationKey - router.replace(mobileWebNavigationRouteTarget(shell.navigationRoute, HOSTED_PAGE_HOST_ID)) + router.replace(mobileWebNavigationRouteTarget(shell.navigationRoute)) }, [router, shell.context, shell.navigationRoute, shell.routeRevision]) return null diff --git a/mobile/src/diagnostics/mobile-log-error-kind.test.ts b/mobile/src/diagnostics/mobile-log-error-kind.test.ts new file mode 100644 index 00000000000..0ab59cf0409 --- /dev/null +++ b/mobile/src/diagnostics/mobile-log-error-kind.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { mobileLogErrorKind } from './mobile-log-error-kind' + +describe('mobileLogErrorKind', () => { + it('does not expose custom error names or messages', () => { + const error = new Error('credential-secret /private/repository') + error.name = 'credential-secret' + + expect(mobileLogErrorKind(error)).toBe('error') + expect(mobileLogErrorKind('credential-secret')).toBe('string') + }) +}) diff --git a/mobile/src/diagnostics/mobile-log-error-kind.ts b/mobile/src/diagnostics/mobile-log-error-kind.ts new file mode 100644 index 00000000000..797d2caaf71 --- /dev/null +++ b/mobile/src/diagnostics/mobile-log-error-kind.ts @@ -0,0 +1,3 @@ +export function mobileLogErrorKind(error: unknown): string { + return error instanceof Error ? 'error' : typeof error +} diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.ts b/mobile/src/hooks/mobile-dictation-desktop-start.ts index 581b2b99d9f..ed065553e29 100644 --- a/mobile/src/hooks/mobile-dictation-desktop-start.ts +++ b/mobile/src/hooks/mobile-dictation-desktop-start.ts @@ -4,6 +4,7 @@ import { } from './mobile-dictation-session-state' import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' import type { RpcClient } from '../transport/rpc-client' +import { mobileLogErrorKind } from '../diagnostics/mobile-log-error-kind' type StartMobileDictationDesktopSessionOptions = { client: RpcClient @@ -99,7 +100,9 @@ export async function startMobileDictationDesktopSession( const budgetTimer = setTimeout(resolve, MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) keepAwakeOwner .acquire(dictationId) - .catch((err: unknown) => console.error('Keep-awake activation failed', err)) + .catch((err: unknown) => + console.error('Keep-awake activation failed', { kind: mobileLogErrorKind(err) }) + ) .finally(() => { clearTimeout(budgetTimer) resolve() diff --git a/mobile/src/hooks/use-native-mobile-dictation.ts b/mobile/src/hooks/use-native-mobile-dictation.ts index cc0e2092141..a9d7a336c7f 100644 --- a/mobile/src/hooks/use-native-mobile-dictation.ts +++ b/mobile/src/hooks/use-native-mobile-dictation.ts @@ -21,6 +21,7 @@ import type { UseMobileDictationOptions, UseMobileDictationResult } from './mobile-dictation-session-state' +import { mobileLogErrorKind } from '../diagnostics/mobile-log-error-kind' export type { UseMobileDictationResult } from './mobile-dictation-session-state' @@ -68,7 +69,9 @@ export function useNativeMobileDictation( } catch (err) { // Cleanup must keep going when native recording shutdown throws, or // the wake tag and dictation state would leak. - console.error('Failed to stop microphone recording', err) + console.error('Failed to stop microphone recording', { + kind: mobileLogErrorKind(err) + }) } void keepAwakeOwner.release(dictationId ?? undefined).catch(() => undefined) }, diff --git a/mobile/src/mobile-web/mobile-web-bridge-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-bridge-roundtrip.test.ts index a55323ec9b8..a39837c2315 100644 --- a/mobile/src/mobile-web/mobile-web-bridge-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-bridge-roundtrip.test.ts @@ -284,7 +284,7 @@ describe('mobile web bridge round trip', () => { id: `browser_0_${'01'.repeat(16)}`, browserPageId: `browser_0_${'01'.repeat(16)}`, title: 'Review', - url: 'https://example.invalid/?credential=secret', + url: 'https://example.invalid/', loading: false, canGoBack: true, canGoForward: false, diff --git a/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.test.ts b/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.test.ts new file mode 100644 index 00000000000..fa450d6b05b --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeMobileWebBrowserEvent } from './mobile-web-browser-event-sanitizer' + +describe('sanitizeMobileWebBrowserEvent', () => { + it('removes URL credentials from browser events', () => { + const event = sanitizeMobileWebBrowserEvent({ + type: 'navigation', + tab: { + url: 'https://user:password@example.com/callback?access_token=secret&tab=review', + title: 'Review', + canGoBack: true, + canGoForward: false + } + }) + + expect(event).toEqual({ + type: 'navigation', + tab: { + url: 'https://example.com/callback?tab=review', + title: 'Review', + canGoBack: true, + canGoForward: false + } + }) + expect(JSON.stringify(event)).not.toMatch(/password|access_token|secret/) + }) +}) diff --git a/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.ts b/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.ts index 6aaa2f3bf32..6e7bb7eeff4 100644 --- a/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.ts +++ b/mobile/src/mobile-web/mobile-web-browser-event-sanitizer.ts @@ -2,6 +2,7 @@ import { MobileWebBrowserEventSchema, type MobileWebBrowserEvent } from '../../../src/shared/mobile-web/browser-operation-contract' +import { mobileWebPageBrowserUrl } from '../../../src/shared/mobile-web/browser-url-privacy' export function sanitizeMobileWebBrowserEvent(value: unknown): MobileWebBrowserEvent | null { if (!isRecord(value)) { @@ -12,7 +13,7 @@ export function sanitizeMobileWebBrowserEvent(value: unknown): MobileWebBrowserE return MobileWebBrowserEventSchema.parse({ type: value.type, tab: { - url: boundedText(tab.url, 4096, 'about:blank'), + url: mobileWebPageBrowserUrl(tab.url), title: boundedText(tab.title, 240, ''), canGoBack: tab.canGoBack === true, canGoForward: tab.canGoForward === true diff --git a/mobile/src/mobile-web/mobile-web-browser-operations.test.ts b/mobile/src/mobile-web/mobile-web-browser-operations.test.ts index 42f373443aa..af4b5e4f45a 100644 --- a/mobile/src/mobile-web/mobile-web-browser-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-browser-operations.test.ts @@ -33,6 +33,26 @@ describe('mobile web browser operations', () => { ) }) + it('removes credentials from the authoritative navigation result', async () => { + const { workspaceAuthority, browserAuthority, workspaceId, pageId } = authorities() + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + url: 'https://user:password@example.com/callback?code=secret&tab=review#access_token=secret' + } + }) + + await expect( + executeMobileWebBrowserOperation({ + operation: 'navigate', + payload: { workspaceId, pageId, url: 'https://example.com' }, + client: { sendRequest } as unknown as RpcClient, + workspaceAuthority, + browserAuthority + }) + ).resolves.toEqual({ url: 'https://example.com/callback?tab=review' }) + }) + it('keeps pointer fallback native and rejects cross-workspace page handles', async () => { const { workspaceAuthority, browserAuthority, workspaceId, pageId } = authorities() const sendRequest = vi diff --git a/mobile/src/mobile-web/mobile-web-browser-operations.ts b/mobile/src/mobile-web/mobile-web-browser-operations.ts index 15421d6a55f..f79bf716fab 100644 --- a/mobile/src/mobile-web/mobile-web-browser-operations.ts +++ b/mobile/src/mobile-web/mobile-web-browser-operations.ts @@ -7,6 +7,7 @@ import { MobileWebBrowserPointerPayloadSchema, MobileWebBrowserTargetPayloadSchema } from '../../../src/shared/mobile-web/browser-operation-contract' +import { mobileWebPageBrowserUrl } from '../../../src/shared/mobile-web/browser-url-privacy' import type { RpcClient } from '../transport/rpc-client' import type { MobileWebBrowserAuthority } from './mobile-web-browser-authority' import { MobileWebBrokerError } from './mobile-web-broker-error' @@ -29,7 +30,7 @@ export async function executeMobileWebBrowserOperation(args: { ) const result = requireResult(response) const parsed = MobileWebBrowserNavigateResultSchema.safeParse({ - url: isRecord(result) && typeof result.url === 'string' ? result.url : '' + url: isRecord(result) ? mobileWebPageBrowserUrl(result.url) : 'about:blank' }) if (!parsed.success) { throw new MobileWebBrokerError('host_error') diff --git a/mobile/src/mobile-web/mobile-web-route-restoration.test.ts b/mobile/src/mobile-web/mobile-web-route-restoration.test.ts index 7e9d1c18be8..92866c05b57 100644 --- a/mobile/src/mobile-web/mobile-web-route-restoration.test.ts +++ b/mobile/src/mobile-web/mobile-web-route-restoration.test.ts @@ -6,22 +6,26 @@ import { describe('mobile web route restoration', () => { it('keeps the workspace list as the default recovery route', () => { - expect(mobileWebResumeRouteTarget({ kind: 'workspaceList' }, 'paired-orca-desktop')).toBeNull() + expect(mobileWebResumeRouteTarget({ kind: 'workspaceList' })).toBeNull() }) - it('restores only the opaque workspace handle and bounded display name', () => { + it('restores only the fixed page host label, opaque workspace handle, and display name', () => { expect( - mobileWebResumeRouteTarget( - { - kind: 'session', - workspaceId: 'opaque/workspace?one', - workspaceName: 'Feature & tests' - }, - 'paired-orca-desktop' - ) + mobileWebResumeRouteTarget({ + kind: 'session', + workspaceId: 'opaque/workspace?one', + workspaceName: 'Feature & tests' + }) ).toBe('/h/paired-orca-desktop/session/opaque%2Fworkspace%3Fone?name=Feature+%26+tests') }) + it('cannot accept a paired host identity for a page URL', () => { + expect(mobileWebNavigationRouteTarget).toHaveLength(1) + expect(mobileWebNavigationRouteTarget({ kind: 'accounts' })).not.toContain( + 'paired-host-public-key' + ) + }) + it.each([ [{ kind: 'tasks' } as const, '/h/paired-orca-desktop/tasks'], [ @@ -31,6 +35,6 @@ describe('mobile web route restoration', () => { [{ kind: 'accounts' } as const, '/h/paired-orca-desktop/accounts'], [{ kind: 'newWorkspace' } as const, '/?action=newWorktree'] ])('maps the typed native destination %s', (route, expected) => { - expect(mobileWebNavigationRouteTarget(route, 'paired-orca-desktop')).toBe(expected) + expect(mobileWebNavigationRouteTarget(route)).toBe(expected) }) }) diff --git a/mobile/src/mobile-web/mobile-web-route-restoration.ts b/mobile/src/mobile-web/mobile-web-route-restoration.ts index 191a65c0410..6b80b2f98ba 100644 --- a/mobile/src/mobile-web/mobile-web-route-restoration.ts +++ b/mobile/src/mobile-web/mobile-web-route-restoration.ts @@ -3,18 +3,14 @@ import type { MobileWebResumeRoute } from '../../../src/shared/mobile-web/bridge-contract' -export function mobileWebResumeRouteTarget( - route: MobileWebResumeRoute, - hostedHostId: string -): string | null { - const target = mobileWebNavigationRouteTarget(route, hostedHostId) +const HOSTED_PAGE_HOST_ID = 'paired-orca-desktop' + +export function mobileWebResumeRouteTarget(route: MobileWebResumeRoute): string | null { + const target = mobileWebNavigationRouteTarget(route) return target === '/' ? null : target } -export function mobileWebNavigationRouteTarget( - route: MobileWebNavigationRoute, - hostedHostId: string -): string { +export function mobileWebNavigationRouteTarget(route: MobileWebNavigationRoute): string { if (route.kind === 'workspaceList') { return '/' } @@ -22,14 +18,14 @@ export function mobileWebNavigationRouteTarget( const query = route.taskSource ? `?${new URLSearchParams({ taskSource: route.taskSource }).toString()}` : '' - return `/h/${encodeURIComponent(hostedHostId)}/tasks${query}` + return `/h/${HOSTED_PAGE_HOST_ID}/tasks${query}` } if (route.kind === 'accounts') { - return `/h/${encodeURIComponent(hostedHostId)}/accounts` + return `/h/${HOSTED_PAGE_HOST_ID}/accounts` } if (route.kind === 'newWorkspace') { return '/?action=newWorktree' } const query = new URLSearchParams({ name: route.workspaceName }).toString() - return `/h/${encodeURIComponent(hostedHostId)}/session/${encodeURIComponent(route.workspaceId)}?${query}` + return `/h/${HOSTED_PAGE_HOST_ID}/session/${encodeURIComponent(route.workspaceId)}?${query}` } diff --git a/mobile/src/mobile-web/mobile-web-session-snapshot.test.ts b/mobile/src/mobile-web/mobile-web-session-snapshot.test.ts index 3b180673075..0bb118ad743 100644 --- a/mobile/src/mobile-web/mobile-web-session-snapshot.test.ts +++ b/mobile/src/mobile-web/mobile-web-session-snapshot.test.ts @@ -88,6 +88,47 @@ describe('mobile web session snapshot', () => { ).toThrow('mobile_web_session_snapshot_invalid') }) + it('removes browser URL credentials and local file paths', () => { + const authority = authorities() + const snapshot = mobileWebSessionSnapshot( + { + worktree: 'workspace-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeTabId: 'browser-1', + activeTabType: 'browser', + tabs: [ + { + type: 'browser', + id: 'browser-1', + browserPageId: 'host-browser-1', + title: 'Private callback', + url: 'https://user:password@example.com/callback?token=secret&tab=review', + isActive: true + }, + { + type: 'browser', + id: 'browser-2', + browserPageId: 'host-browser-2', + title: 'Local file', + url: 'file:///private/repository/secret.txt', + isActive: false + } + ] + }, + 'workspace-1', + 'opaque-workspace', + authority.browser, + authority.nativeChat + ) + + expect(snapshot.tabs.map((tab) => ('url' in tab ? tab.url : null))).toEqual([ + 'https://example.com/callback?tab=review', + 'file:///[redacted]' + ]) + expect(JSON.stringify(snapshot)).not.toMatch(/password|token=|private\/repository/) + }) + it('projects only bounded chat state and hides host transcript authority', () => { const authority = authorities() const snapshot = mobileWebSessionSnapshot( diff --git a/mobile/src/mobile-web/mobile-web-session-snapshot.ts b/mobile/src/mobile-web/mobile-web-session-snapshot.ts index 13f70168b33..ce47e7debd3 100644 --- a/mobile/src/mobile-web/mobile-web-session-snapshot.ts +++ b/mobile/src/mobile-web/mobile-web-session-snapshot.ts @@ -12,6 +12,7 @@ import { type MobileWebSessionSnapshotResult, type MobileWebSessionTab } from '../../../src/shared/mobile-web/bridge-operation-contract' +import { mobileWebPageBrowserUrl } from '../../../src/shared/mobile-web/browser-url-privacy' import type { MobileWebBrowserAuthority } from './mobile-web-browser-authority' import type { MobileWebHostNativeChatBinding, @@ -159,7 +160,7 @@ function mobileWebSessionTab( id: browserPageId, type: 'browser', browserPageId, - url: boundedText(value.url, 4096, 'about:blank'), + url: mobileWebPageBrowserUrl(value.url), loading: value.loading === true, canGoBack: value.canGoBack === true, canGoForward: value.canGoForward === true diff --git a/mobile/src/session/host-session-terminal-stream-presentation.ts b/mobile/src/session/host-session-terminal-stream-presentation.ts index c20d0cba3a2..d074149b419 100644 --- a/mobile/src/session/host-session-terminal-stream-presentation.ts +++ b/mobile/src/session/host-session-terminal-stream-presentation.ts @@ -82,7 +82,6 @@ function dropStaleResize( const last = context.layoutSequences.get(context.handle) if (last != null && eventSequence < last && last - eventSequence <= 20) { console.log('[fit][session] DROP-stale-seq', { - handle: context.handle.slice(-8), type: data.type, eventSeq: eventSequence, lastSeq: last, @@ -119,7 +118,6 @@ function presentScrollback( const ref = context.getTerminalRef(context.handle) if (!ref) { console.log('[fit][session] scrollback DROPPED — no terminal ref', { - handle: context.handle.slice(-8), cols, rows }) @@ -187,7 +185,6 @@ function presentOutput( const ref = context.getTerminalRef(context.handle) if (!ref) { console.log('[fit][session] data DROPPED — no terminal ref', { - handle: context.handle.slice(-8), chunkLen: hostSessionTerminalData(data.chunk).length, initialized: context.initializedHandles.has(context.handle) }) @@ -195,7 +192,6 @@ function presentOutput( } if (!context.initializedHandles.has(context.handle)) { console.log('[fit][session] data RECEIVED before scrollback', { - handle: context.handle.slice(-8), chunkLen: hostSessionTerminalData(data.chunk).length }) } diff --git a/mobile/src/session/use-mobile-terminal-paste.ts b/mobile/src/session/use-mobile-terminal-paste.ts index 35c3fb03b15..86c8efba226 100644 --- a/mobile/src/session/use-mobile-terminal-paste.ts +++ b/mobile/src/session/use-mobile-terminal-paste.ts @@ -126,7 +126,13 @@ export function useMobileTerminalPaste({ const err = e as { name?: string; message?: string } const isDisconnected = connState !== 'connected' // eslint-disable-next-line no-console - console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message }) + console.warn('[mobile-clip] paste failed', { + kind: isDisconnected + ? 'disconnected' + : err.message === 'Clipboard image is too large' + ? 'image-too-large' + : 'unknown' + }) if (isDisconnected) { showToast('Paste failed (disconnected)', 1500) } else if (err.message === 'Clipboard image is too large') { diff --git a/mobile/src/terminal/terminal-webview-engine-error-state.tsx b/mobile/src/terminal/terminal-webview-engine-error-state.tsx index 0ba3e79db8d..7e0899ca635 100644 --- a/mobile/src/terminal/terminal-webview-engine-error-state.tsx +++ b/mobile/src/terminal/terminal-webview-engine-error-state.tsx @@ -27,7 +27,7 @@ export function useTerminalWebViewEngineErrorState(onEngineError?: (message: str (message: string, fatal: boolean) => { onEngineError?.(message) // eslint-disable-next-line no-console - console.warn('[terminal-webview] engine error', message) + console.warn('[terminal-webview] engine error', { fatal }) if (fatal) { // Why: the first fatal report is the root cause; later cascades (e.g. the // web-ready watchdog firing after a process-crash report) must not diff --git a/mobile/src/transport/mobile-direct-rpc-sender.test.ts b/mobile/src/transport/mobile-direct-rpc-sender.test.ts new file mode 100644 index 00000000000..8dcb3024550 --- /dev/null +++ b/mobile/src/transport/mobile-direct-rpc-sender.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' +import { createMobileDirectRpcSender } from './mobile-direct-rpc-sender' + +describe('createMobileDirectRpcSender', () => { + it('does not log rejected request content or error details', () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const socket = { readyState: WebSocket.OPEN } as WebSocket + const sender = createMobileDirectRpcSender({ + getOutbound: () => ({ + acknowledge: vi.fn(), + acknowledgeAuthentication: vi.fn(), + dispose: vi.fn(), + enqueue() { + const error = new Error('credential-secret /private/repository') + error.name = 'credential-secret' + throw error + }, + socketClosed: vi.fn() + }), + getSharedKey: () => new Uint8Array(32), + getSocket: () => socket, + getState: () => 'connected', + onSocketDesync: vi.fn() + }) + + expect(sender({ token: 'request-secret' })).toBe(false) + const output = JSON.stringify(consoleWarn.mock.calls) + expect(output).toContain('"kind":"error"') + expect(output).not.toMatch(/credential-secret|private\/repository|request-secret/) + }) +}) diff --git a/mobile/src/transport/mobile-direct-rpc-sender.ts b/mobile/src/transport/mobile-direct-rpc-sender.ts index d2e9c4ca672..0c3a0d357e5 100644 --- a/mobile/src/transport/mobile-direct-rpc-sender.ts +++ b/mobile/src/transport/mobile-direct-rpc-sender.ts @@ -1,6 +1,7 @@ import type { MobileDirectRpcOutbound } from './mobile-direct-rpc-outbound' import { stringifyMobileOutboundJson } from './mobile-outbound-json' import type { ConnectionState } from './types' +import { mobileLogErrorKind } from '../diagnostics/mobile-log-error-kind' export function createMobileDirectRpcSender(args: { getOutbound: () => MobileDirectRpcOutbound | null @@ -21,7 +22,9 @@ export function createMobileDirectRpcSender(args: { requestAcknowledgementKey(request) ) } catch (error) { - console.warn('[net] outbound request rejected', error) + console.warn('[net] outbound request rejected', { + kind: mobileLogErrorKind(error) + }) return false } } diff --git a/mobile/src/transport/redacted-websocket-endpoint.ts b/mobile/src/transport/redacted-websocket-endpoint.ts index b19431099ee..9fb2c39fdcb 100644 --- a/mobile/src/transport/redacted-websocket-endpoint.ts +++ b/mobile/src/transport/redacted-websocket-endpoint.ts @@ -1,8 +1,14 @@ -// Why: keep device tokens and full URLs out of connection logs. +// Why: even a hostname identifies the paired desktop in shared logs. export function redactedWebSocketEndpoint(endpoint: string): string { try { const url = new URL(endpoint) - return (url.protocol === 'ws:' || url.protocol === 'wss:') && url.host ? url.host : 'unknown' + if (!url.host) { + return 'unknown' + } + if (url.protocol === 'wss:') { + return 'encrypted-websocket' + } + return url.protocol === 'ws:' ? 'websocket' : 'unknown' } catch { return 'unknown' } diff --git a/mobile/src/transport/rpc-client-log-redaction.test.ts b/mobile/src/transport/rpc-client-log-redaction.test.ts index ff893cdde1a..eb9d22cf3dc 100644 --- a/mobile/src/transport/rpc-client-log-redaction.test.ts +++ b/mobile/src/transport/rpc-client-log-redaction.test.ts @@ -24,11 +24,15 @@ class NeverOpeningWebSocket { readonly OPEN = 1 readonly CLOSING = 2 readonly CLOSED = 3 + static latest: NeverOpeningWebSocket | null = null readyState = 0 onopen: (() => void) | null = null - onclose: (() => void) | null = null + onclose: ((event: Record) => void) | null = null onmessage: ((event: { data: unknown }) => void) | null = null - onerror: (() => void) | null = null + onerror: ((event: Record) => void) | null = null + constructor(readonly url: string) { + NeverOpeningWebSocket.latest = this + } send(): void {} close(): void { this.readyState = 3 @@ -42,7 +46,7 @@ afterEach(() => { }) describe('mobile rpc-client connection logs', () => { - it('never exposes endpoint query credentials', () => { + it('never exposes paired endpoint identity or query credentials', () => { globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket const logs: ConnectionLogEntry[] = [] const endpoint = 'wss://desktop.example:7443/runtime?token=super-secret&route=private' @@ -52,10 +56,12 @@ describe('mobile rpc-client connection logs', () => { }) expect(logs).toContainEqual( - expect.objectContaining({ message: 'Opening WebSocket', detail: 'desktop.example:7443' }) + expect.objectContaining({ message: 'Opening WebSocket', detail: 'encrypted-websocket' }) ) - expect(JSON.stringify(logs)).not.toContain('super-secret') - expect(JSON.stringify(logs)).not.toContain('route=private') + const serialized = JSON.stringify(logs) + expect(serialized).not.toContain('desktop.example') + expect(serialized).not.toContain('super-secret') + expect(serialized).not.toContain('route=private') client.close() }) @@ -67,8 +73,70 @@ describe('mobile rpc-client connection logs', () => { onLog: (entry) => logs.push(entry) }) - expect(logs[0]?.detail).toBe('desktop.example:7443') - expect(JSON.stringify(logs)).not.toContain('password') + expect(logs[0]?.detail).toBe('encrypted-websocket') + const serialized = JSON.stringify(logs) + expect(serialized).not.toContain('desktop.example') + expect(serialized).not.toContain('password') client.close() }) + + it('does not serialize endpoint, auth, or socket-event values to console', () => { + globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) + const client = connect( + 'wss://private-desktop.example/runtime?token=url-secret', + 'device-secret', + 'server-key' + ) + const socket = NeverOpeningWebSocket.latest + if (!socket) { + throw new Error('WebSocket was not created') + } + socket.onerror?.({ + endpoint: 'wss://private-desktop.example', + message: 'socket-secret /private/repository', + type: 'error' + }) + socket.onclose?.({ + code: 1006, + reason: 'close-secret /private/repository', + wasClean: false + }) + client.close() + + const authClient = connect( + 'wss://private-desktop.example/runtime?token=url-secret', + 'device-secret', + 'server-key' + ) + const authSocket = NeverOpeningWebSocket.latest + if (!authSocket) { + throw new Error('WebSocket was not created') + } + authSocket.onopen?.() + authSocket.onmessage?.({ + data: JSON.stringify({ + type: 'e2ee_error', + error: { + code: 'unauthorized', + message: 'credential-secret /private/repository' + } + }) + }) + authClient.close() + + const consoleOutput = JSON.stringify(consoleLog.mock.calls) + expect(consoleOutput).toContain('encrypted-websocket') + for (const secret of [ + 'private-desktop.example', + 'url-secret', + 'device-secret', + 'credential-secret', + 'socket-secret', + 'close-secret', + '/private/repository' + ]) { + expect(consoleOutput).not.toContain(secret) + } + }) }) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index a23dc3befcf..0c04b390c60 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -279,7 +279,7 @@ export function connect( emitLog( 'info', reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket', - redactSocketEndpoint(endpoint) + redactedWebSocketEndpoint(endpoint) ) if (!processMobileOutboundMemoryBudget.canRegisterBufferedAmount()) { @@ -455,8 +455,13 @@ export function connect( (!msg.ok && (msg.error as { code?: unknown } | undefined)?.code === 'unauthorized') ) { openingOutbound.acknowledgeAuthentication() - console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error }) - clearHandshakeTimer() + console.log('[net] e2ee auth FAILED', { + signal: msg.type === 'e2ee_error' ? 'e2ee_error' : 'unauthorized_response' + }) + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } handleAuthRejection('Unauthorized — pairing may be revoked') } } @@ -586,6 +591,11 @@ export function connect( disposeActiveOutbound() } const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined + const closeCode = + typeof e?.code === 'number' && Number.isInteger(e.code) && e.code >= 0 && e.code <= 65_535 + ? e.code + : undefined + const wasClean = typeof e?.wasClean === 'boolean' ? e.wasClean : undefined const closeAt = Date.now() // Why: time-since-construct classifies the failure — instant close = RST/unreachable, slow = SYN timeout/packet loss. const constructToCloseMs = currentWsOpenedAt != null ? closeAt - currentWsOpenedAt : null @@ -595,9 +605,8 @@ export function connect( // Why: statically imported — a hot-reload bug came from a stale closure capturing a half-loaded module. const closeEvent = describeSocketEvent(event) console.log('[net] ws.onclose', { - code: e?.code, - reason: e?.reason, - wasClean: e?.wasClean, + code: closeCode, + wasClean, state, attempt: reconnectAttempt, intentionallyClosed, @@ -605,9 +614,10 @@ export function connect( constructToCloseMs, aliveMs, inboundIdleMs, - eventKeys: closeEvent.keys, - eventStr: closeEvent.json + eventFields: closeEvent.fields }) + lastWsClosedAt = closeAt + currentWsOpenedAt = null handleSocketClosed(openingWs, { closeCode }) } @@ -615,15 +625,11 @@ export function connect( if (isStaleRpcSocketEvent(ws, openingWs, 'error', state, reconnectAttempt)) { return } - // Why: RN surfaces the original network error here — onclose follows but its close code alone hides the cause. - const e = event as { message?: string } | undefined const errEvent = describeSocketEvent(event) console.log('[net] ws.onerror', { - message: e?.message, state, attempt: reconnectAttempt, - eventKeys: errEvent.keys, - eventStr: errEvent.json + eventFields: errEvent.fields }) } } diff --git a/mobile/src/transport/socket-event-debug.test.ts b/mobile/src/transport/socket-event-debug.test.ts new file mode 100644 index 00000000000..178a0ab2b33 --- /dev/null +++ b/mobile/src/transport/socket-event-debug.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { describeSocketEvent } from './socket-event-debug' + +describe('describeSocketEvent', () => { + it('retains only reviewed field names without reading their values', () => { + const event = { + code: 1006, + endpoint: 'wss://paired-desktop.example', + message: 'credential-secret', + reason: '/private/repository', + type: 'error', + wasClean: false + } + + expect(describeSocketEvent(event)).toEqual({ + fields: ['code', 'type', 'wasClean'] + }) + expect(JSON.stringify(describeSocketEvent(event))).not.toMatch( + /paired-desktop|credential|private/ + ) + }) + + it('fails closed when event field enumeration throws', () => { + const event = new Proxy( + {}, + { + ownKeys() { + throw new Error('credential-secret') + } + } + ) + + expect(describeSocketEvent(event)).toEqual({ fields: [] }) + }) +}) diff --git a/mobile/src/transport/socket-event-debug.ts b/mobile/src/transport/socket-event-debug.ts index dab927ad6b0..f5fba453c9b 100644 --- a/mobile/src/transport/socket-event-debug.ts +++ b/mobile/src/transport/socket-event-debug.ts @@ -1,36 +1,19 @@ -// Why: RN's WebSocket close/error events are loosely typed and vary per -// platform. Serialize them defensively (circular-safe, function-safe, -// truncated) so the [net] diagnostics can never crash mid-handler. -export function describeSocketEvent(event: unknown): { keys: string[]; json: string } { - let keys: string[] = [] +const SAFE_SOCKET_EVENT_FIELDS = new Set(['code', 'isTrusted', 'type', 'wasClean']) + +// Why: event values and extension field names can contain endpoints or host errors. +export function describeSocketEvent(event: unknown): { fields: string[] } { + let fields: string[] = [] try { - keys = event && typeof event === 'object' ? Object.keys(event as object) : [] + fields = + event && typeof event === 'object' + ? Object.keys(event as object) + .filter((key) => SAFE_SOCKET_EVENT_FIELDS.has(key)) + .sort() + : [] } catch { - keys = [] + fields = [] } - let json = '' - try { - const seen = new WeakSet() - json = JSON.stringify( - event, - (_k, v) => { - if (typeof v === 'object' && v !== null) { - if (seen.has(v as object)) { - return '[circular]' - } - seen.add(v as object) - } - if (typeof v === 'function') { - return '[fn]' - } - return v - }, - 0 - ).slice(0, 500) - } catch { - json = '[unstringifiable]' - } - return { keys, json } + return { fields } } export function redactSocketEndpoint(endpoint: string): string { diff --git a/src/shared/mobile-web/browser-operation-contract.test.ts b/src/shared/mobile-web/browser-operation-contract.test.ts index 0cc0a4ea66e..a45f899f798 100644 --- a/src/shared/mobile-web/browser-operation-contract.test.ts +++ b/src/shared/mobile-web/browser-operation-contract.test.ts @@ -22,6 +22,20 @@ describe('mobile web browser operation contract', () => { url: 'javascript:alert(1)' }).success ).toBe(false) + expect( + MobileWebBrowserNavigatePayloadSchema.safeParse({ + workspaceId: 'workspace-1', + pageId: 'browser-1', + url: 'https://example.com/callback?access_token=secret' + }).success + ).toBe(false) + expect( + MobileWebBrowserNavigatePayloadSchema.safeParse({ + workspaceId: 'workspace-1', + pageId: 'browser-1', + url: 'file:///private/repository/secret.txt' + }).success + ).toBe(false) expect( MobileWebBrowserPointerPayloadSchema.safeParse({ workspaceId: 'workspace-1', diff --git a/src/shared/mobile-web/browser-operation-contract.ts b/src/shared/mobile-web/browser-operation-contract.ts index 9da7263ab61..d975a7452c0 100644 --- a/src/shared/mobile-web/browser-operation-contract.ts +++ b/src/shared/mobile-web/browser-operation-contract.ts @@ -1,9 +1,13 @@ import { z } from 'zod' import { isMobileWebBase64 } from './protocol-token-contract' import { MobileWebWorkspaceIdSchema } from './workspace-operation-contract' +import { + isMobileWebPageBrowserNavigationUrl, + MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH +} from './browser-url-privacy' export const MOBILE_WEB_BROWSER_PAGE_ID_MAX_LENGTH = 512 -export const MOBILE_WEB_BROWSER_URL_MAX_LENGTH = 4096 +export const MOBILE_WEB_BROWSER_URL_MAX_LENGTH = MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH export const MOBILE_WEB_BROWSER_FRAME_MAX_IMAGE_BYTES = 8 * 1024 * 1024 export const MOBILE_WEB_BROWSER_FRAME_CHUNK_BYTES = 128 * 1024 export const MOBILE_WEB_BROWSER_FRAME_MAX_CHUNKS = Math.ceil( @@ -47,7 +51,7 @@ export const MobileWebBrowserNavigatePayloadSchema = MobileWebBrowserTargetSchem .string() .min(1) .max(MOBILE_WEB_BROWSER_URL_MAX_LENGTH) - .refine(isAllowedBrowserUrl, 'Unsupported browser URL') + .refine(isMobileWebPageBrowserNavigationUrl, 'Unsupported browser URL') }).strict() export const MobileWebBrowserTargetPayloadSchema = MobileWebBrowserTargetSchema @@ -169,15 +173,3 @@ export type MobileWebBrowserKeyboardPayload = z.infer export type MobileWebBrowserEvent = z.infer export type MobileWebBrowserFrameChunk = Extract - -function isAllowedBrowserUrl(value: string): boolean { - if (value === 'about:blank') { - return true - } - try { - const protocol = new URL(value).protocol - return protocol === 'http:' || protocol === 'https:' || protocol === 'file:' - } catch { - return false - } -} diff --git a/src/shared/mobile-web/browser-url-privacy.test.ts b/src/shared/mobile-web/browser-url-privacy.test.ts new file mode 100644 index 00000000000..0d7c5ccd19d --- /dev/null +++ b/src/shared/mobile-web/browser-url-privacy.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { isMobileWebPageBrowserNavigationUrl, mobileWebPageBrowserUrl } from './browser-url-privacy' + +describe('mobileWebPageBrowserUrl', () => { + it.each([ + [ + 'https://user:password@example.com/path?view=grid&access_token=secret#section', + 'https://example.com/path?view=grid#section' + ], + [ + 'https://example.com/callback?code=secret&state=private&tab=review', + 'https://example.com/callback?tab=review' + ], + [ + 'https://storage.example/object?x-amz-signature=secret&part=1', + 'https://storage.example/object?part=1' + ], + [ + 'https://storage.example/object?X-Goog-Credential=secret&part=1', + 'https://storage.example/object?part=1' + ], + [ + 'https://example.com/callback?id_token=secret&view=mobile', + 'https://example.com/callback?view=mobile' + ], + ['https://example.com/#access_token=secret', 'https://example.com/'], + ['https://example.com/#/callback?refresh_token=secret&view=mobile', 'https://example.com/'], + ['file:///private/repository/secret.txt', 'file:///[redacted]'] + ])('removes credentials from %s', (value, expected) => { + expect(mobileWebPageBrowserUrl(value)).toBe(expected) + }) + + it('preserves ordinary URL state and rejects unsupported or invalid URLs', () => { + expect(mobileWebPageBrowserUrl('https://example.com/search?q=orca#results')).toBe( + 'https://example.com/search?q=orca#results' + ) + expect(mobileWebPageBrowserUrl('https://example.com')).toBe('https://example.com') + expect(mobileWebPageBrowserUrl('javascript:alert(1)')).toBe('about:blank') + expect(mobileWebPageBrowserUrl('not a url')).toBe('about:blank') + expect(mobileWebPageBrowserUrl(`https://example.com/?q=${'a'.repeat(4096)}`)).toBe( + 'about:blank' + ) + }) + + it('admits only credential-free hosted navigation URLs', () => { + expect(isMobileWebPageBrowserNavigationUrl('https://example.com/search?q=orca')).toBe(true) + expect(isMobileWebPageBrowserNavigationUrl('about:blank')).toBe(true) + for (const value of [ + 'https://user:password@example.com/', + 'https://example.com/?token=secret', + 'https://example.com/#access_token=secret', + 'https://example.com/#/callback?refresh_token=secret', + `https://example.com/?q=${'a'.repeat(4096)}`, + 'file:///private/repository/secret.txt', + 'javascript:alert(1)' + ]) { + expect(isMobileWebPageBrowserNavigationUrl(value)).toBe(false) + } + }) +}) diff --git a/src/shared/mobile-web/browser-url-privacy.ts b/src/shared/mobile-web/browser-url-privacy.ts new file mode 100644 index 00000000000..997ac8ce212 --- /dev/null +++ b/src/shared/mobile-web/browser-url-privacy.ts @@ -0,0 +1,122 @@ +const SENSITIVE_QUERY_KEY_PATTERNS = [ + /^auth(?:entication|orization)?$/, + /^bearer$/, + /^code$/, + /^credential(?:s)?$/, + /^csrf$/, + /^id_token$/, + /^oauth_state$/, + /^password$/, + /^passwd$/, + /^refresh_token$/, + /^secret$/, + /^security_token$/, + /^session(?:_?id)?$/, + /^sig(?:nature)?$/, + /^state$/, + /^(?:access_|auth_)?token$/, + /^api_?key$/, + /^client_secret$/, + /^x_amz_/, + /^x_goog_(?:credential|signature)$/ +] + +export const MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH = 4096 + +export function mobileWebPageBrowserUrl(value: unknown): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH + ) { + return 'about:blank' + } + if (value === 'about:blank') { + return value + } + try { + const parsed = new URL(value) + if (parsed.protocol === 'file:') { + return 'file:///[redacted]' + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return 'about:blank' + } + if ( + !parsed.username && + !parsed.password && + !queryContainsCredential(parsed.searchParams) && + !fragmentContainsCredential(parsed.hash) + ) { + return value + } + parsed.username = '' + parsed.password = '' + for (const key of new Set(parsed.searchParams.keys())) { + if (isSensitiveQueryKey(key)) { + parsed.searchParams.delete(key) + } + } + if (fragmentContainsCredential(parsed.hash)) { + parsed.hash = '' + } + const sanitized = parsed.toString() + return sanitized.length <= MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH ? sanitized : 'about:blank' + } catch { + return 'about:blank' + } +} + +export function isMobileWebPageBrowserNavigationUrl(value: string): boolean { + if (value.length > MOBILE_WEB_PAGE_BROWSER_URL_MAX_LENGTH) { + return false + } + if (value === 'about:blank') { + return true + } + try { + const parsed = new URL(value) + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + !parsed.username && + !parsed.password && + !queryContainsCredential(parsed.searchParams) && + !fragmentContainsCredential(parsed.hash) + ) + } catch { + return false + } +} + +function isSensitiveQueryKey(key: string): boolean { + const normalized = key.trim().toLowerCase().replaceAll('-', '_') + return SENSITIVE_QUERY_KEY_PATTERNS.some((pattern) => pattern.test(normalized)) +} + +function queryContainsCredential(query: URLSearchParams): boolean { + for (const key of query.keys()) { + if (isSensitiveQueryKey(key)) { + return true + } + } + return false +} + +function fragmentContainsCredential(fragment: string): boolean { + if (!fragment) { + return false + } + const decoded = safelyDecodeURIComponent(fragment.slice(1)).toLowerCase() + const keys = decoded + .split(/[?&;]/) + .map((part) => part.split('=', 1)[0]!.trim().replaceAll('-', '_')) + return keys.some(isSensitiveQueryKey) +} + +function safelyDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +}