fix(mobile): redact hosted privacy surfaces

This commit is contained in:
OrcaWin
2026-08-09 18:35:01 -04:00
committed by Jinwoo-H
parent f2a6a5d8b6
commit 8ee4fcdac1
37 changed files with 618 additions and 144 deletions
@@ -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'
}
@@ -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"]')
@@ -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/...#<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. |
@@ -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
@@ -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
@@ -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)
}
+12 -11
View File
@@ -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<Extract<TaskItem, { provider: 'github' }>>,
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'
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
@@ -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
@@ -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')
})
})
@@ -0,0 +1,3 @@
export function mobileLogErrorKind(error: unknown): string {
return error instanceof Error ? 'error' : typeof error
}
@@ -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()
@@ -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)
},
@@ -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,
@@ -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/)
})
})
@@ -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
@@ -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<RpcClient['sendRequest']>().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
@@ -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')
@@ -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)
})
})
@@ -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}`
}
@@ -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(
@@ -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
@@ -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
})
}
@@ -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') {
@@ -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
@@ -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/)
})
})
@@ -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
}
}
@@ -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'
}
@@ -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<string, unknown>) => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onerror: ((event: Record<string, unknown>) => 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)
}
})
})
+19 -13
View File
@@ -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
})
}
}
@@ -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: [] })
})
})
+13 -30
View File
@@ -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<object>()
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 {
@@ -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',
@@ -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<typeof MobileWebBrowserKey
export type MobileWebBrowserDialogPayload = z.infer<typeof MobileWebBrowserDialogPayloadSchema>
export type MobileWebBrowserEvent = z.infer<typeof MobileWebBrowserEventSchema>
export type MobileWebBrowserFrameChunk = Extract<MobileWebBrowserEvent, { type: 'frameChunk' }>
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
}
}
@@ -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)
}
})
})
@@ -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
}
}