diff --git a/config/scripts/package-mobile-web-rnw.mjs b/config/scripts/package-mobile-web-rnw.mjs index db5d2d41821..1ff2da05455 100644 --- a/config/scripts/package-mobile-web-rnw.mjs +++ b/config/scripts/package-mobile-web-rnw.mjs @@ -7,6 +7,7 @@ import { MobileWebManifestSchema, serializeMobileWebManifestForBuildId } from '../../src/shared/mobile-web/manifest-contract.ts' +import { mobileWebDocumentCsp } from '../../src/shared/mobile-web/document-csp.ts' import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH } from '../../src/shared/mobile-web/markdown-editor-csp.ts' const args = parseArgs(process.argv.slice(2)) @@ -154,22 +155,7 @@ function replaceReferences(source, replacements) { } function mobileWebDocument({ scriptPath, stylePath }) { - const csp = [ - "default-src 'none'", - `script-src 'self' ${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH}`, - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob:", - "font-src 'self'", - "connect-src 'none'", - "media-src 'none'", - "object-src 'none'", - 'frame-src data:', - 'child-src data:', - "worker-src 'none'", - "base-uri 'none'", - "form-action 'none'", - "frame-ancestors 'none'" - ].join('; ') + const csp = mobileWebDocumentCsp(MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH) return `
diff --git a/config/scripts/verify-mobile-web-rnw-build.mjs b/config/scripts/verify-mobile-web-rnw-build.mjs index 6ddb1146b80..370f6a012fd 100644 --- a/config/scripts/verify-mobile-web-rnw-build.mjs +++ b/config/scripts/verify-mobile-web-rnw-build.mjs @@ -6,6 +6,7 @@ import { MobileWebManifestSchema, serializeMobileWebManifestForBuildId } from '../../src/shared/mobile-web/manifest-contract.ts' +import { mobileWebDocumentCspDirectives } from '../../src/shared/mobile-web/document-csp.ts' import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH } from '../../src/shared/mobile-web/markdown-editor-csp.ts' import { MOBILE_WEB_RNW_BUILD_BUDGET, @@ -76,23 +77,9 @@ const html = await readFile(path.join(outputRoot, manifest.entrypoint), 'utf8') if (!/]*\bviewport-fit=cover\b/i.test(html)) { throw new Error('RNW document must expose native safe-area insets') } -const requiredCsp = [ - "default-src 'none'", - `script-src 'self' ${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH}`, - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob:", - "font-src 'self'", - "connect-src 'none'", - "media-src 'none'", - "object-src 'none'", - 'frame-src data:', - 'child-src data:', - "worker-src 'none'", - "base-uri 'none'", - "form-action 'none'", - "frame-ancestors 'none'" -] -for (const directive of requiredCsp) { +for (const directive of mobileWebDocumentCspDirectives( + MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH +)) { if (!html.includes(directive)) { throw new Error(`RNW CSP is missing: ${directive}`) } 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 421af359f1a..c90da67d4b9 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 @@ -1541,8 +1541,10 @@ copy. They also cap each raw manifest document at 256 KiB before JSON parsing. Native activation records also require exact string-typed active/previous hashes; numeric hash-shaped values fail with the same stable error on iOS and - Android. Broader generated mutation, path/MIME/CSP, and persisted-cache - metadata fuzzing remains open. + Android. The packager and verifier now consume one document-CSP contract, and + source tests require the iOS and Android response policies to match its exact + directive sequence. Broader generated mutation, path/MIME/CSP behavior, and + persisted-cache metadata fuzzing remains open. - [ ] Fuzz bridge envelopes, schemas, sizes, IDs, ordering, cancellation, and subscription lifecycle. - [ ] Attempt cross-host, cross-build, cross-workspace, and cross-session races. @@ -2576,4 +2578,5 @@ copy. | 2026-07-28 | Complete | Mirrored 65,537-character native chunk regressions pass the Swift fault executable and the refreshed Android module suite across 76 Gradle tasks. No RNW package content changed. | | 2026-07-28 | Finding | Native manifest parsing enforced asset count and field bounds only after parsing both supplied JSON documents. Swift and Kotlin now reject either raw manifest above 256 KiB before handing it to `JSONSerialization` or `JSONObject`. | | 2026-07-28 | Complete | Mirrored oversized primary/canonical manifest regressions pass the Swift fault executable and the refreshed Android module suite across 76 Gradle tasks. No staging directory is created for the rejected Android inputs. | +| 2026-07-28 | Complete | The RNW packager, build verifier, iOS response policy, and Android response policy now share one exact document-CSP contract. Focused packager/native-source tests pass, and a fresh production RNW build retains build `9ed8c7f7…`, 49 assets, 9,280,463 raw bytes, and 2,684,481 gzip bytes. | | 2026-07-28 | Next | Complete the remaining parity inventory and cutover cleanup, then execute the physical-device, topology, security, performance, packaged-release, and App Store gates. | diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md index f381216af1e..5237486a0ca 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md @@ -489,6 +489,7 @@ IDs, and unrelated provider state never enter the page result. | -------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | Multi-asset manifest v1 | Implemented and focused-test passing | `src/shared/mobile-web/manifest-contract.ts`; TypeScript, Swift, and Kotlin reject quoted/Boolean numeric scalar confusion before staging | | Manifest resource bounds | Implemented, measured, and focused-test passing | 256 KiB/raw manifest before native parse; 48 KiB chunks / 65,536 base64 chars before native decode; 10 MiB/asset, 32 MiB/package, 256 assets | +| Document CSP | Implemented and focused-test passing | One shared directive contract drives packaging/verification and exact-sequence checks for both native response policies | | Bridge envelope and capabilities | Implemented and focused-test passing | `src/shared/mobile-web/bridge-contract.ts`; bounded native-chat schemas and remaining operation payload schemas | | Terminal stream | Implemented and focused-test passing | `src/shared/mobile-web/terminal-stream-contract.ts`; broker adapter and real-stream validation remain | | Shell navigation events | Implemented and focused-test passing | Strict opaque routes with monotonic sequence, shell-session/build context, and one-shot restore | 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 57d156e585c..ddae8d149b1 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 @@ -2722,8 +2722,10 @@ that encoded ceiling before invoking their native decoders and cap each raw manifest document at 256 KiB before JSON parsing. Native activation metadata likewise requires string-typed active and previous hashes on both platforms; hash-shaped JSON numbers fail with -`mobile_web_activation_invalid`. Generated mutation and the remaining -path/MIME/CSP/cache corpus are still required. +`mobile_web_activation_invalid`. The RNW packager and verifier consume one +document-CSP contract, and source tests require both native response policies +to match its exact directive sequence. Generated mutation and the remaining +path/MIME/CSP behavior/cache corpus are still required. ## App Store Gate 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 2b39953fc83..0d6ec85bc39 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 @@ -210,8 +210,10 @@ cross-scope races, privacy/authorization audit, and independent review. rejects numeric active/previous hashes with the same stable error on both platforms. Both native stores cap each raw manifest at 256 KiB before JSON parsing. Android now requires the exact root document URL and rejects - percent-encoded or query-bearing asset requests; a fresh exact-app rerun, - generated mutation, and the other listed boundaries remain. + percent-encoded or query-bearing asset requests. One document-CSP contract + now drives packaging/verification and exact native source parity; a fresh + exact-app rerun, generated mutation, and the other listed boundaries + remain. - [ ] Attempt cross-host, cross-build, cross-workspace, cross-session, replay, reconnect, process-loss, and host-removal races. - [ ] Verify no credential or privileged host identity reaches URLs, DOM state, diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt index f84b8afc90a..7c5f5038b7c 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt @@ -30,8 +30,8 @@ private val MOBILE_WEB_CSP = listOf( "default-src 'none'", "script-src 'self' 'sha256-1U6xDmOrcY3IC5LxY6dRlxDPeS9l4iILlzMspyz5qlY='", "style-src 'self' 'unsafe-inline'", - "font-src 'self'", "img-src 'self' data: blob:", + "font-src 'self'", "connect-src 'none'", "media-src 'none'", "object-src 'none'", diff --git a/mobile/packages/expo-mobile-web-shell/ios/MobileWebShellView.swift b/mobile/packages/expo-mobile-web-shell/ios/MobileWebShellView.swift index 53c8e8f7e0d..fa4b41194b5 100644 --- a/mobile/packages/expo-mobile-web-shell/ios/MobileWebShellView.swift +++ b/mobile/packages/expo-mobile-web-shell/ios/MobileWebShellView.swift @@ -10,8 +10,8 @@ private let mobileWebCsp = [ "script-src 'self' 'sha256-1U6xDmOrcY3IC5LxY6dRlxDPeS9l4iILlzMspyz5qlY='", // Why: React Native Web emits runtime style elements and attributes for the existing mobile UI. "style-src 'self' 'unsafe-inline'", - "font-src 'self'", "img-src 'self' data: blob:", + "font-src 'self'", "connect-src 'none'", "media-src 'none'", "object-src 'none'", diff --git a/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts b/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts index 4332e7e61de..7fd995046f5 100644 --- a/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { MOBILE_WEB_BRIDGE_MAX_MESSAGE_BYTES } from '../../../src/shared/mobile-web/bridge-contract' +import { mobileWebDocumentCspDirectives } from '../../../src/shared/mobile-web/document-csp' import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH } from '../../../src/shared/mobile-web/markdown-editor-csp' const iosSource = readFileSync( @@ -138,6 +139,9 @@ describe('mobile web native bridge transport', () => { }) it('allows only opaque data frames for the shared rich Markdown editor', () => { + const expected = mobileWebDocumentCspDirectives(MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH) + expect(nativeCspDirectives(iosSource, 'mobileWebCsp')).toEqual(expected) + expect(nativeCspDirectives(androidSource, 'MOBILE_WEB_CSP')).toEqual(expected) for (const source of [iosSource, androidSource]) { expect(source).toContain('"frame-src data:"') expect(source).toContain('"child-src data:"') @@ -164,3 +168,16 @@ describe('mobile web native bridge transport', () => { expect(androidSource).toContain('!request.isForMainFrame && url.scheme == "data"') }) }) + +function nativeCspDirectives(source: string, declaration: string): string[] { + const kotlinStart = source.indexOf(`${declaration} = listOf(`) + const swiftStart = source.indexOf(`${declaration} = [`) + const opening = kotlinStart >= 0 ? kotlinStart : swiftStart + const closing = source.indexOf(kotlinStart >= 0 ? ').joinToString' : '].joined', opening) + if (opening < 0 || closing < 0) { + return [] + } + return [...source.slice(opening, closing).matchAll(/^\s*"([^"]+)",?$/gm)].map( + (match) => match[1]! + ) +} diff --git a/src/shared/mobile-web/document-csp.ts b/src/shared/mobile-web/document-csp.ts new file mode 100644 index 00000000000..d1a257e4ea9 --- /dev/null +++ b/src/shared/mobile-web/document-csp.ts @@ -0,0 +1,22 @@ +export function mobileWebDocumentCspDirectives(markdownEditorScriptHash: string) { + return [ + "default-src 'none'", + `script-src 'self' ${markdownEditorScriptHash}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self'", + "connect-src 'none'", + "media-src 'none'", + "object-src 'none'", + 'frame-src data:', + 'child-src data:', + "worker-src 'none'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'" + ] as const +} + +export function mobileWebDocumentCsp(markdownEditorScriptHash: string): string { + return mobileWebDocumentCspDirectives(markdownEditorScriptHash).join('; ') +}