refactor(mobile): centralize hosted document CSP

This commit is contained in:
OrcaWin
2026-08-09 18:34:58 -04:00
committed by Jinwoo-H
parent 36a9bece9f
commit 71dbadc1f6
10 changed files with 61 additions and 41 deletions
+2 -16
View File
@@ -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 `<!doctype html>
<html lang="en">
<head>
+4 -17
View File
@@ -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 (!/<meta\s+name=["']viewport["'][^>]*\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}`)
}
@@ -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. |
@@ -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 |
@@ -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
@@ -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,
@@ -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'",
@@ -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'",
@@ -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]!
)
}
+22
View File
@@ -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('; ')
}