feat(mobile): serve the frame scripts from the package, not a native hash

The shell CSP pinned the markdown editor's inline script and the mermaid
frame's inline script, so editing either one needed a new native binary.
Both scripts now ship as content-addressed package assets that their
documents load by src, and the editor moves from a `data:` frame that
inherited the shell policy to a package document of its own.

`'self'` cannot carry these: the frames are sandboxed, and WebKit resolves
`'self'` against the frame's opaque origin, so it matches nothing there
(Chromium resolves it against the response URL and does match). The native
frame policy names the per-session package origin instead.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-07 16:18:29 -04:00
parent 0339cd6874
commit 1033bbf62a
24 changed files with 309 additions and 169 deletions
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { MOBILE_WEB_MARKDOWN_EDITOR_PATH } from '../../mobile/src/components/markdown-editor-document'
import {
MOBILE_WEB_MERMAID_FRAME_PATH,
buildMobileWebMermaidFrameDocument
@@ -28,27 +29,35 @@ export async function createPackagedCliResourceFixture(resourcesDir) {
export async function createMobileWebResourceFixture(resourcesDir) {
const root = join(resourcesDir, 'mobile-web')
const script = Buffer.from('globalThis.__orcaPackagedMobileWeb=true', 'utf8')
const scriptHash = sha256(script)
const scriptPath = `assets/${scriptHash}.js`
const scripts = ['globalThis.__orcaPackagedMobileWeb=true', 'void 0', 'void 1'].map((source) => {
const bytes = Buffer.from(source, 'utf8')
const hash = sha256(bytes)
return { bytes, hash, path: `assets/${hash}.js` }
})
const [entryScript, mermaidScript, editorScript] = scripts
const document = Buffer.from(
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover"><script src="./${scriptPath}" defer></script>`,
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover"><script src="./${entryScript.path}" defer></script>`,
'utf8'
)
const mermaidFrame = Buffer.from(
buildMobileWebMermaidFrameDocument({
theme: { background: 'black', primary: 'gray', text: 'white', line: 'silver' }
theme: { background: 'black', primary: 'gray', text: 'white', line: 'silver' },
script: { src: `./${mermaidScript.path}` }
}),
'utf8'
)
const markdownEditor = Buffer.from(
`<!doctype html><html><body><script src="./${editorScript.path}"></script></body></html>`,
'utf8'
)
const assets = [
{
path: scriptPath,
sha256: scriptHash,
byteLength: script.byteLength,
...scripts.map((entry) => ({
path: entry.path,
sha256: entry.hash,
byteLength: entry.bytes.byteLength,
contentType: 'text/javascript; charset=utf-8',
role: 'script'
},
})),
{
path: 'index.html',
sha256: sha256(document),
@@ -56,6 +65,13 @@ export async function createMobileWebResourceFixture(resourcesDir) {
contentType: 'text/html; charset=utf-8',
role: 'document'
},
{
path: MOBILE_WEB_MARKDOWN_EDITOR_PATH,
sha256: sha256(markdownEditor),
byteLength: markdownEditor.byteLength,
contentType: 'text/html; charset=utf-8',
role: 'document'
},
{
path: MOBILE_WEB_MERMAID_FRAME_PATH,
sha256: sha256(mermaidFrame),
@@ -64,6 +80,7 @@ export async function createMobileWebResourceFixture(resourcesDir) {
role: 'document'
}
]
assets.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
const seed = {
schemaVersion: MOBILE_WEB_MANIFEST_SCHEMA_VERSION,
buildId: '0'.repeat(64),
@@ -74,8 +91,11 @@ export async function createMobileWebResourceFixture(resourcesDir) {
}
const manifest = { ...seed, buildId: sha256(serializeMobileWebManifestForBuildId(seed)) }
await mkdir(join(root, 'assets'), { recursive: true })
await writeFile(join(root, scriptPath), script)
for (const entry of scripts) {
await writeFile(join(root, entry.path), entry.bytes)
}
await writeFile(join(root, 'index.html'), document)
await writeFile(join(root, MOBILE_WEB_MARKDOWN_EDITOR_PATH), markdownEditor)
await writeFile(join(root, MOBILE_WEB_MERMAID_FRAME_PATH), mermaidFrame)
await writeFile(join(root, 'manifest.json'), JSON.stringify(manifest))
}
+23 -1
View File
@@ -9,12 +9,21 @@ import {
} from '../../src/shared/mobile-web/manifest-contract.ts'
import {
MOBILE_WEB_MERMAID_FRAME_PATH,
MOBILE_WEB_MERMAID_FRAME_SCRIPT,
buildMobileWebMermaidFrameDocument
} from '../../mobile/src/components/pr-sidebar/mermaid-frame-document.ts'
import { colors } from '../../mobile/src/theme/mobile-theme.ts'
import { registerTypeScriptExtensionlessResolver } from './typescript-extensionless-resolver.mjs'
import { splitMobileWebRnwScript } from './mobile-web-rnw-script-chunks.mjs'
import { assertMobileWebRnwExecutablePolicy } from './mobile-web-rnw-executable-policy.mjs'
registerTypeScriptExtensionlessResolver()
const {
MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT,
MOBILE_WEB_MARKDOWN_EDITOR_PATH,
buildMobileRichMarkdownEditorHtml
} = await import('../../mobile/src/components/mobile-rich-markdown-editor-html.ts')
const args = parseArgs(process.argv.slice(2))
const inputRoot = path.resolve(args.input ?? 'out/mobile-web-rnw-export')
const outputRoot = path.resolve(args.output ?? 'out/mobile-web-rnw')
@@ -67,15 +76,21 @@ const scriptPaths = splitMobileWebRnwScript(script).map((chunk) => {
const document = mobileWebDocument({ scriptPaths, stylePath })
const documentBytes = Buffer.from(document)
packaged.set('index.html', documentBytes)
// Frame scripts ship as ordinary content-addressed assets so the native CSP never pins a hash.
const mermaidFrame = buildMobileWebMermaidFrameDocument({
theme: {
background: colors.bgRaised,
primary: colors.bgPanel,
text: colors.textPrimary,
line: colors.textSecondary
}
},
script: { src: `./${packageScript(packaged, MOBILE_WEB_MERMAID_FRAME_SCRIPT)}` }
})
packaged.set(MOBILE_WEB_MERMAID_FRAME_PATH, Buffer.from(mermaidFrame))
const markdownEditor = buildMobileRichMarkdownEditorHtml({
src: `./${packageScript(packaged, MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT)}`
})
packaged.set(MOBILE_WEB_MARKDOWN_EDITOR_PATH, Buffer.from(markdownEditor))
for (const [assetPath, bytes] of packaged) {
await mkdir(path.dirname(path.join(outputRoot, assetPath)), { recursive: true })
@@ -166,6 +181,13 @@ function replaceReferences(source, replacements) {
return output
}
function packageScript(packaged, source) {
const bytes = Buffer.from(`${source}\n`)
const assetPath = contentAddressedPath(bytes, '.js')
packaged.set(assetPath, bytes)
return assetPath
}
function mobileWebDocument({ scriptPaths, stylePath }) {
return `<!doctype html>
<html lang="en">
+15 -8
View File
@@ -5,11 +5,9 @@ import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import { MobileWebPackageAssets } from '../../src/main/runtime/rpc/mobile-web-package-assets'
import { MOBILE_WEB_PACKAGE_BRIDGE_RANGE } from '../../src/shared/mobile-web/bridge-limits'
import { MOBILE_WEB_MARKDOWN_EDITOR_PATH } from '../../mobile/src/components/markdown-editor-document'
import { MobileWebManifestSchema } from '../../src/shared/mobile-web/manifest-contract'
import {
MOBILE_WEB_MERMAID_FRAME_PATH,
MOBILE_WEB_MERMAID_FRAME_SCRIPT_CSP_HASH
} from '../../mobile/src/components/pr-sidebar/mermaid-frame-document'
import { MOBILE_WEB_MERMAID_FRAME_PATH } from '../../mobile/src/components/pr-sidebar/mermaid-frame-document'
const execFileAsync = promisify(execFile)
const temporaryRoots: string[] = []
@@ -73,6 +71,10 @@ describe('RNW mobile web packager', () => {
)
const document = await readFile(path.join(output, 'index.html'), 'utf8')
const mermaidFrame = await readFile(path.join(output, MOBILE_WEB_MERMAID_FRAME_PATH), 'utf8')
const markdownEditor = await readFile(
path.join(output, MOBILE_WEB_MARKDOWN_EDITOR_PATH),
'utf8'
)
expect(script).not.toMatch(/\beval\s*\(|\bnew\s+Function\s*\(/)
expect(script).not.toContain('/assets/icon.hash.png')
expect(script).toMatch(/\.\/assets\/[a-f0-9]{64}\.png/)
@@ -83,11 +85,16 @@ describe('RNW mobile web packager', () => {
expect(document).not.toContain('Content-Security-Policy')
expect(document).toContain('maximum-scale=1,user-scalable=no')
expect(document).toContain('viewport-fit=cover')
expect(mermaidFrame).toContain(`script-src ${MOBILE_WEB_MERMAID_FRAME_SCRIPT_CSP_HASH} blob:`)
expect(mermaidFrame).toContain("frame-ancestors 'self'")
// No served document may carry an inline script, or a native CSP would have to pin its hash.
for (const served of [document, mermaidFrame, markdownEditor]) {
expect(served).not.toMatch(/<script(?![^>]*\bsrc=)/)
expect(served).not.toContain('sha256-')
expect(served).not.toContain('Content-Security-Policy')
expect(served).toMatch(/<script src="\.\/assets\/[a-f0-9]{64}\.js"/)
}
expect(mermaidFrame).not.toContain(MOBILE_WEB_MERMAID_FRAME_PATH)
expect(manifest.assets).toHaveLength(5)
expect(manifest.assets.filter((asset) => asset.role === 'document')).toHaveLength(2)
expect(manifest.assets).toHaveLength(8)
expect(manifest.assets.filter((asset) => asset.role === 'document')).toHaveLength(3)
expect(manifest.bridge).toEqual(MOBILE_WEB_PACKAGE_BRIDGE_RANGE)
const packageAssets = new MobileWebPackageAssets({ resolveRoot: () => output })
@@ -0,0 +1,21 @@
import { existsSync } from 'node:fs'
import nodeModule from 'node:module'
import { fileURLToPath } from 'node:url'
/**
* Lets a build script import project TypeScript that uses bundler-style extensionless specifiers.
* Node resolves ESM by exact path, so register this before the dynamic import that needs it.
*/
export function registerTypeScriptExtensionlessResolver() {
nodeModule.registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
const candidate = new URL(`${specifier}.ts`, context.parentURL)
if (existsSync(fileURLToPath(candidate))) {
return { url: candidate.href, shortCircuit: true }
}
}
return nextResolve(specifier, context)
}
})
}
+44 -45
View File
@@ -3,14 +3,10 @@ import { readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
import { gzipSync } from 'node:zlib'
import {
MOBILE_WEB_EMBEDDED_DOCUMENT_PATHS,
MobileWebManifestSchema,
serializeMobileWebManifestForBuildId
} from '../../src/shared/mobile-web/manifest-contract.ts'
import {
MOBILE_WEB_MERMAID_FRAME_PATH,
MOBILE_WEB_MERMAID_FRAME_SCRIPT,
mobileWebMermaidFrameCspDirectives
} from '../../mobile/src/components/pr-sidebar/mermaid-frame-document.ts'
import {
MOBILE_WEB_RNW_BUILD_BUDGET,
mobileWebRnwBuildBudgetFailures
@@ -41,8 +37,11 @@ for (const asset of manifest.assets) {
}
const roles = countRoles(manifest.assets)
if (roles.document !== 2 || !(roles.script >= 1) || roles.style > 1) {
throw new Error('RNW package must contain two documents, scripts, and at most one style')
const expectedDocuments = 1 + MOBILE_WEB_EMBEDDED_DOCUMENT_PATHS.length
if (roles.document !== expectedDocuments || !(roles.script >= 1) || roles.style > 1) {
throw new Error(
`RNW package must contain ${expectedDocuments} documents, scripts, and at most one style`
)
}
const scriptBytes = bytesForRole(manifest.assets, 'script')
@@ -81,55 +80,55 @@ 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')
}
if (/<style(?:\s|>)/i.test(html) || /<script(?!\s+src=)/i.test(html)) {
throw new Error('RNW document contains inline executable or stylesheet content')
if (/<style(?:\s|>)/i.test(html)) {
throw new Error('RNW document contains inline stylesheet content')
}
for (const match of html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)) {
const reference = match[1]
if (!reference?.startsWith('./assets/')) {
throw new Error(`RNW document contains a non-relative asset reference: ${reference}`)
const referencedScripts = []
for (const documentPath of [manifest.entrypoint, ...MOBILE_WEB_EMBEDDED_DOCUMENT_PATHS]) {
const source =
documentPath === manifest.entrypoint
? html
: await readFile(path.join(outputRoot, documentPath), 'utf8')
// Every executable in the package is a declared asset, so no native policy has to pin a hash.
for (const match of source.matchAll(
/<script(?<attributes>[^>]*)>(?<body>[\s\S]*?)<\/script>/gi
)) {
if (match.groups.body !== '') {
throw new Error(`RNW document contains an inline script: ${documentPath}`)
}
const src = /\bsrc="(?<value>[^"]+)"/.exec(match.groups.attributes)?.groups?.value
if (!src) {
throw new Error(`RNW document contains a script without a source: ${documentPath}`)
}
referencedScripts.push(src.slice(2))
}
if (!declaredPaths.includes(reference.slice(2))) {
throw new Error(`RNW document references an undeclared asset: ${reference}`)
if (/\bContent-Security-Policy\b/i.test(source)) {
throw new Error(`RNW document must take its policy from the native header: ${documentPath}`)
}
for (const match of source.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)) {
const reference = match[1]
if (!reference?.startsWith('./assets/')) {
throw new Error(`RNW document contains a non-relative asset reference: ${reference}`)
}
if (!declaredPaths.includes(reference.slice(2))) {
throw new Error(`RNW document references an undeclared asset: ${reference}`)
}
}
}
const documentScripts = [...html.matchAll(/<script src="\.\/([^"]+)" defer><\/script>/g)].map(
(match) => match[1]
)
const packagedScripts = manifest.assets
.filter((asset) => asset.role === 'script')
.map((asset) => asset.path)
if (
new Set(documentScripts).size !== documentScripts.length ||
documentScripts.length !== packagedScripts.length ||
packagedScripts.some((assetPath) => !documentScripts.includes(assetPath))
new Set(referencedScripts).size !== referencedScripts.length ||
referencedScripts.length !== packagedScripts.length ||
packagedScripts.some((assetPath) => !referencedScripts.includes(assetPath))
) {
throw new Error('RNW document must reference every packaged script exactly once with defer')
throw new Error('RNW documents must reference every packaged script exactly once')
}
const mermaidFrame = await readFile(path.join(outputRoot, MOBILE_WEB_MERMAID_FRAME_PATH), 'utf8')
for (const directive of mobileWebMermaidFrameCspDirectives()) {
if (!mermaidFrame.includes(directive)) {
throw new Error(`RNW Mermaid frame CSP is missing: ${directive}`)
}
}
const mermaidScripts = [
...mermaidFrame.matchAll(/<script(?<attributes>[^>]*)>(?<source>[\s\S]*?)<\/script>/gi)
]
if (
mermaidScripts.length !== 1 ||
mermaidScripts[0]?.groups?.attributes?.trim() !== '' ||
mermaidScripts[0]?.groups?.source !== MOBILE_WEB_MERMAID_FRAME_SCRIPT
) {
throw new Error('RNW Mermaid frame must contain only the fixed inline renderer')
}
if (/\b(?:src|href)=["']/i.test(mermaidFrame)) {
throw new Error('RNW Mermaid frame must not reference external resources')
}
const mermaidPolicyFailure = mobileWebRnwExecutablePolicyFailure(MOBILE_WEB_MERMAID_FRAME_SCRIPT)
if (mermaidPolicyFailure) {
throw new Error(`RNW Mermaid frame contains ${mermaidPolicyFailure}`)
if (!/<script src="\.\/[^"]+" defer><\/script>/.test(html)) {
throw new Error('RNW document must load its chunks with defer')
}
for (const asset of manifest.assets.filter((candidate) => candidate.role === 'script')) {
@@ -746,6 +746,9 @@ internal fun isSafeMobileWebAssetPath(path: String): Boolean =
internal fun isMobileWebSha256(value: String): Boolean = SHA256_PATTERN.matches(value)
private val MOBILE_WEB_DOCUMENT_PATHS =
setOf("index.html", "markdown-editor.html", "mermaid-frame.html")
internal fun isValidMobileWebAssetMetadata(
path: String,
hash: String,
@@ -754,8 +757,7 @@ internal fun isValidMobileWebAssetMetadata(
): Boolean {
if (!isSafeMobileWebAssetPath(path) || !isMobileWebSha256(hash)) return false
if (role == "document") {
return (path == "index.html" || path == "mermaid-frame.html") &&
contentType == "text/html; charset=utf-8"
return path in MOBILE_WEB_DOCUMENT_PATHS && contentType == "text/html; charset=utf-8"
}
val components = path.split('/')
if (components.size != 2 || components[0] != "assets") return false
@@ -27,11 +27,14 @@ internal const val MOBILE_WEB_ORIGIN_HOST = "orca-mobile-web.invalid"
internal const val MOBILE_WEB_ORIGIN = "$MOBILE_WEB_ORIGIN_SCHEME://$MOBILE_WEB_ORIGIN_HOST"
private const val MOBILE_WEB_BRIDGE_NAME = "OrcaNative"
private const val MOBILE_WEB_MERMAID_FRAME_PATH = "mermaid-frame.html"
private const val MOBILE_WEB_MARKDOWN_EDITOR_PATH = "markdown-editor.html"
private val MOBILE_WEB_EMBEDDED_DOCUMENT_PATHS =
setOf(MOBILE_WEB_MERMAID_FRAME_PATH, MOBILE_WEB_MARKDOWN_EDITOR_PATH)
private const val MOBILE_WEB_MESSAGE_BYTE_LIMIT = 640 * 1024
private const val MOBILE_WEB_PENDING_MESSAGE_LIMIT = 32
private val MOBILE_WEB_CSP = listOf(
"default-src 'none'",
"script-src 'self' 'sha256-9WQo6QEeDR1Qf5aOmvWdM6FJv6hDF22Gbk7IKakIW4A='",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
@@ -45,9 +48,11 @@ private val MOBILE_WEB_CSP = listOf(
"form-action 'none'",
"frame-ancestors 'none'"
).joinToString("; ")
private val MOBILE_WEB_MERMAID_FRAME_CSP = listOf(
// The embedded frames are sandboxed, so their origin is opaque and WebKit resolves `'self'`
// against it. The package origin is named outright so each frame can load its own script.
private fun mobileWebEmbeddedFrameCsp(origin: String): String = listOf(
"default-src 'none'",
"script-src 'sha256-JHwlo5V7HtwqexHUhXguW04dF71kAVlQOX1QdtyCkjg=' blob:",
"script-src $origin blob:",
"style-src 'unsafe-inline'",
"img-src data:",
"font-src 'none'",
@@ -409,7 +414,11 @@ internal class MobileWebShellView(
)
if (asset.isDocument) {
headers["Content-Security-Policy"] =
if (path == MOBILE_WEB_MERMAID_FRAME_PATH) MOBILE_WEB_MERMAID_FRAME_CSP else MOBILE_WEB_CSP
if (path in MOBILE_WEB_EMBEDDED_DOCUMENT_PATHS) {
mobileWebEmbeddedFrameCsp(mobileWebOriginForSession(sessionId))
} else {
MOBILE_WEB_CSP
}
}
return WebResourceResponse(
contentTypeParts[0],
@@ -443,9 +452,10 @@ internal class MobileWebShellView(
private fun isAllowedEmbeddedDocumentUrl(url: Uri): Boolean {
val sessionId = activeSessionId ?: return false
val path = url.path.orEmpty().removePrefix("/")
return isMobileWebOriginForSession(url, sessionId) &&
url.path == "/$MOBILE_WEB_MERMAID_FRAME_PATH" &&
url.encodedPath == "/$MOBILE_WEB_MERMAID_FRAME_PATH" &&
path in MOBILE_WEB_EMBEDDED_DOCUMENT_PATHS &&
url.encodedPath == "/$path" &&
url.query == null &&
url.fragment == null &&
url.toString().length <= 8 * 1024
@@ -146,6 +146,7 @@ class MobileWebPackageStoreTest {
val valid = listOf(
arrayOf("index.html", hash, "text/html; charset=utf-8", "document"),
arrayOf("mermaid-frame.html", hash, "text/html; charset=utf-8", "document"),
arrayOf("markdown-editor.html", hash, "text/html; charset=utf-8", "document"),
arrayOf("assets/$hash.css", hash, "text/css; charset=utf-8", "style"),
arrayOf("assets/$hash.js", hash, "text/javascript; charset=utf-8", "script"),
arrayOf("assets/$hash.png", hash, "image/png", "image"),
@@ -193,6 +193,7 @@ enum MobileWebPackageStoreTests {
let valid = [
("index.html", hash, "text/html; charset=utf-8", "document"),
("mermaid-frame.html", hash, "text/html; charset=utf-8", "document"),
("markdown-editor.html", hash, "text/html; charset=utf-8", "document"),
("assets/\(hash).css", hash, "text/css; charset=utf-8", "style"),
("assets/\(hash).js", hash, "text/javascript; charset=utf-8", "script"),
("assets/\(hash).png", hash, "image/png", "image"),
@@ -1043,6 +1043,12 @@ func isSafeMobileWebAssetPath(_ path: String) -> Bool {
}
}
private let mobileWebDocumentPaths: Set<String> = [
"index.html",
"markdown-editor.html",
"mermaid-frame.html"
]
func isValidMobileWebAssetMetadata(
path: String,
hash: String,
@@ -1053,8 +1059,7 @@ func isValidMobileWebAssetMetadata(
return false
}
if role == "document" {
return (path == "index.html" || path == "mermaid-frame.html")
&& contentType == "text/html; charset=utf-8"
return mobileWebDocumentPaths.contains(path) && contentType == "text/html; charset=utf-8"
}
let components = path.split(separator: "/")
guard
@@ -5,11 +5,16 @@ private let mobileWebScheme = "orca-mobile-web"
private let mobileWebDocumentUrlLimit = 8 * 1024
private let mobileWebBridgeHandler = "orcaBridge"
private let mobileWebMermaidFramePath = "mermaid-frame.html"
private let mobileWebMarkdownEditorPath = "markdown-editor.html"
private let mobileWebEmbeddedDocumentPaths: Set<String> = [
mobileWebMermaidFramePath,
mobileWebMarkdownEditorPath
]
private let mobileWebMessageByteLimit = 640 * 1024
private let mobileWebPendingMessageLimit = 32
private let mobileWebCsp = [
"default-src 'none'",
"script-src 'self' 'sha256-9WQo6QEeDR1Qf5aOmvWdM6FJv6hDF22Gbk7IKakIW4A='",
"script-src 'self'",
// Why: React Native Web emits runtime style elements and attributes for the existing mobile UI.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
@@ -24,22 +29,26 @@ private let mobileWebCsp = [
"form-action 'none'",
"frame-ancestors 'none'"
].joined(separator: "; ")
private let mobileWebMermaidFrameCsp = [
"default-src 'none'",
"script-src 'sha256-JHwlo5V7HtwqexHUhXguW04dF71kAVlQOX1QdtyCkjg=' blob:",
"style-src 'unsafe-inline'",
"img-src data:",
"font-src 'none'",
"connect-src 'none'",
"media-src 'none'",
"object-src 'none'",
"frame-src 'none'",
"child-src 'none'",
"worker-src 'none'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'self'"
].joined(separator: "; ")
// The embedded frames are sandboxed, so their origin is opaque and WebKit resolves `'self'`
// against it. The package origin is named outright so each frame can load its own script.
private func mobileWebEmbeddedFrameCsp(origin: String) -> String {
[
"default-src 'none'",
"script-src \(origin) blob:",
"style-src 'unsafe-inline'",
"img-src data:",
"font-src 'none'",
"connect-src 'none'",
"media-src 'none'",
"object-src 'none'",
"frame-src 'none'",
"child-src 'none'",
"worker-src 'none'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'self'"
].joined(separator: "; ")
}
private let mobileWebNetworkRules = """
[
{
@@ -326,8 +335,9 @@ private final class MobileWebSchemeHandler: NSObject, WKURLSchemeHandler {
"X-Content-Type-Options": "nosniff"
]
if asset.isDocument {
headers["Content-Security-Policy"] =
path == mobileWebMermaidFramePath ? mobileWebMermaidFrameCsp : mobileWebCsp
headers["Content-Security-Policy"] = mobileWebEmbeddedDocumentPaths.contains(path)
? mobileWebEmbeddedFrameCsp(origin: "\(mobileWebScheme)://\(sessionId)")
: mobileWebCsp
}
guard let response = HTTPURLResponse(
url: url,
@@ -630,11 +640,12 @@ final class MobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return false }
guard let activeSessionId else { return false }
let path = String(url.path.dropFirst())
return isAllowedMobileWebOriginForSession(url, sessionId: activeSessionId)
&& url.path == "/\(mobileWebMermaidFramePath)"
&& mobileWebEmbeddedDocumentPaths.contains(path)
&& url.query == nil
&& url.fragment == nil
&& components.percentEncodedPath == "/\(mobileWebMermaidFramePath)"
&& components.percentEncodedPath == "/\(path)"
}
private func finishNetworkBlockerInstallation(_ ready: Bool) {
@@ -5,7 +5,7 @@ import type {
MobileRichMarkdownEditorTransport
} from './mobile-rich-markdown-editor-contract'
import { MOBILE_RICH_MARKDOWN_EDITOR_CHANNEL } from './mobile-rich-markdown-editor-contract'
import { buildMobileRichMarkdownEditorHtml } from './mobile-rich-markdown-editor-html'
import { MOBILE_WEB_MARKDOWN_EDITOR_PATH } from './markdown-editor-document'
import {
MobileRichMarkdownEditorPresentation,
mobileRichMarkdownEditorSurfaceStyle
@@ -27,14 +27,6 @@ function MobileRichMarkdownEditorWeb({
}: MobileRichMarkdownEditorProps) {
const frameRef = useRef<HTMLIFrameElement | null>(null)
const frameToken = useMemo(() => createFrameToken(), [])
const documentUrl = useMemo(
() =>
`data:text/html;charset=utf-8,${encodeURIComponent(
buildMobileRichMarkdownEditorHtml({ inline: true })
)}`,
[]
)
const postToEditor = useCallback(
(
payload: Extract<
@@ -105,7 +97,7 @@ function MobileRichMarkdownEditorWeb({
ref={frameRef}
title="Markdown editor"
aria-label="Markdown editor"
src={documentUrl}
src={`/${MOBILE_WEB_MARKDOWN_EDITOR_PATH}`}
name={frameToken}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
@@ -1,2 +0,0 @@
export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH =
"'sha256-9WQo6QEeDR1Qf5aOmvWdM6FJv6hDF22Gbk7IKakIW4A='"
@@ -0,0 +1,5 @@
export const MOBILE_WEB_MARKDOWN_EDITOR_PATH = 'markdown-editor.html'
// The in-app WebView document inlines the editor script; the packaged document loads it by src.
export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH =
"'sha256-9WQo6QEeDR1Qf5aOmvWdM6FJv6hDF22Gbk7IKakIW4A='"
@@ -6,6 +6,8 @@ import {
MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH
} from './mobile-rich-markdown-editor-html'
const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SELECTOR = '<main id="editor" contenteditable="true"'
function editorScript(): string {
const html = buildMobileRichMarkdownEditorHtml()
const script = html.match(/<script>([\s\S]*)<\/script>/)?.[1]
@@ -232,6 +234,16 @@ describe('mobile rich markdown editor HTML', () => {
expect(() => new Function(nativeScript)).not.toThrow()
})
it('serves the packaged editor document without an inline script or a pinned policy', () => {
const packaged = buildMobileRichMarkdownEditorHtml({ src: './assets/editor.js' })
expect(packaged).toContain('<script src="./assets/editor.js"></script>')
expect(packaged).not.toMatch(/<script(?![^>]*\bsrc=)/)
expect(packaged).not.toContain('Content-Security-Policy')
expect(packaged).not.toContain('sha256-')
expect(packaged).toContain(MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SELECTOR)
})
it('renders the Markdown XSS corpus as inert content and rejects active URL schemes', () => {
const html = runtimeMarkdownToHtml(
[
@@ -1,11 +1,14 @@
import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH } from './markdown-editor-csp'
import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH } from './markdown-editor-document'
import { colors } from '../theme/mobile-theme'
import { MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_BODY } from './mobile-rich-markdown-editor-document-body'
import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT } from './mobile-rich-markdown-editor-script'
export { escapeInjectedJavaScriptString } from './mobile-rich-markdown-editor-script-string'
export { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT } from './mobile-rich-markdown-editor-script'
export { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH } from './markdown-editor-csp'
export {
MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH,
MOBILE_WEB_MARKDOWN_EDITOR_PATH
} from './markdown-editor-document'
// Why: `https:` keeps the remote markdown images main rendered; plaintext `http:` stays blocked.
const MOBILE_RICH_MARKDOWN_EDITOR_FRAME_CSP = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src ${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH}; style-src 'unsafe-inline'; img-src data: https:; connect-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; child-src 'none'; base-uri 'none'; form-action 'none'" />`
@@ -30,9 +30,9 @@ describe('mobile rich markdown editor web source', () => {
expect(webEditor).not.toContain("label: 'Body'")
})
it('runs the exact editor document in an isolated data frame', () => {
expect(webEditor).toContain('buildMobileRichMarkdownEditorHtml({ inline: true })')
expect(webEditor).toContain('data:text/html;charset=utf-8,')
it('runs the packaged editor document in an isolated frame', () => {
expect(webEditor).toContain('src={`/${MOBILE_WEB_MARKDOWN_EDITOR_PATH}`}')
expect(webEditor).not.toContain('data:text/html;charset=utf-8,')
expect(webEditor).toContain('sandbox="allow-scripts"')
expect(webEditor).toContain('name={frameToken}')
expect(webEditor).not.toContain('allow-same-origin')
@@ -4,7 +4,6 @@ import { gunzipSync } from 'node:zlib'
import { describe, expect, it } from 'vitest'
import {
MOBILE_WEB_MERMAID_FRAME_PATH,
MOBILE_WEB_MERMAID_FRAME_SCRIPT_CSP_HASH,
buildMobileWebMermaidFrameDocument
} from './mermaid-frame-document'
import {
@@ -63,13 +62,16 @@ describe('Mermaid diagram document', () => {
primary: '#1a1a1a',
text: '#e0e0e0',
line: '#888888'
}
},
script: { src: './assets/mermaid.js' }
})
expect(Buffer.byteLength(document)).toBeLessThan(16 * 1024)
expect(document).not.toContain(MERMAID_WEBVIEW_ENGINE_GZIP_BASE64)
expect(document).toContain(`script-src ${MOBILE_WEB_MERMAID_FRAME_SCRIPT_CSP_HASH} blob:`)
expect(document).toContain("frame-ancestors 'self'")
expect(document).toContain('<script src="./assets/mermaid.js"></script>')
expect(document).not.toMatch(/<script(?![^>]*\bsrc=)/)
expect(document).not.toContain('Content-Security-Policy')
expect(document).not.toContain('sha256-')
expect(document).not.toContain('graph TD; A-->B')
expect(document).not.toContain('frame-token')
expect(MERMAID_DIAGRAM_SCRIPT.indexOf('window.parent !== window')).toBeLessThan(
@@ -4,6 +4,7 @@ import {
MOBILE_WEB_MERMAID_FRAME_SCRIPT_CSP_HASH,
buildMobileWebMermaidFrameDocument
} from './mermaid-frame-document'
import { mobileWebEmbeddedFrameCsp } from '../../mobile-web/embedded-frame-csp'
import { colors } from '../../theme/mobile-theme'
import {
MERMAID_WEBVIEW_ENGINE_CSP_HASH,
@@ -23,14 +24,16 @@ const theme = {
}
export function buildMermaidDiagramDocument(source: string, token = ''): string {
const document = buildMobileWebMermaidFrameDocument({
return buildMobileWebMermaidFrameDocument({
theme,
// The in-app WebView inlines both the engine and the frame script, so both are hashed.
script: {
inlineCsp: mobileWebEmbeddedFrameCsp(
`${MERMAID_WEBVIEW_ENGINE_CSP_HASH} ${MOBILE_WEB_MERMAID_FRAME_SCRIPT_CSP_HASH}`
)
},
embeddedEngine: MERMAID_WEBVIEW_ENGINE_GZIP_BASE64,
encodedSource: Buffer.from(source, 'utf8').toString('base64'),
encodedToken: Buffer.from(token, 'utf8').toString('base64')
})
return document.replace(
`script-src ${MOBILE_WEB_MERMAID_FRAME_SCRIPT_CSP_HASH} blob:`,
`script-src ${MERMAID_WEBVIEW_ENGINE_CSP_HASH} ${MOBILE_WEB_MERMAID_FRAME_SCRIPT_CSP_HASH}`
)
}
@@ -177,29 +177,6 @@ export const MOBILE_WEB_MERMAID_FRAME_SCRIPT = String.raw`(function () {
export const MOBILE_WEB_MERMAID_FRAME_SCRIPT_CSP_HASH =
"'sha256-JHwlo5V7HtwqexHUhXguW04dF71kAVlQOX1QdtyCkjg='"
export function mobileWebMermaidFrameCspDirectives() {
return [
"default-src 'none'",
`script-src ${MOBILE_WEB_MERMAID_FRAME_SCRIPT_CSP_HASH} blob:`,
"style-src 'unsafe-inline'",
'img-src data:',
"font-src 'none'",
"connect-src 'none'",
"media-src 'none'",
"object-src 'none'",
"frame-src 'none'",
"child-src 'none'",
"worker-src 'none'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'self'"
] as const
}
export function mobileWebMermaidFrameCsp(): string {
return mobileWebMermaidFrameCspDirectives().join('; ')
}
type MermaidFrameTheme = {
background: string
primary: string
@@ -207,8 +184,16 @@ type MermaidFrameTheme = {
line: string
}
/**
* The packaged frame loads its script from the package and takes its policy from the native
* response header, which is the only place that knows the per-session origin. The in-app document
* has no server, so it carries its own meta policy over an inline script.
*/
type MermaidFrameScript = { src: string } | { inlineCsp: string }
type MermaidFrameDocumentOptions = {
theme: MermaidFrameTheme
script: MermaidFrameScript
embeddedEngine?: string
encodedSource?: string
encodedToken?: string
@@ -216,16 +201,24 @@ type MermaidFrameDocumentOptions = {
export function buildMobileWebMermaidFrameDocument({
theme,
script,
embeddedEngine = '',
encodedSource = '',
encodedToken = ''
}: MermaidFrameDocumentOptions): string {
const policy =
'src' in script
? ''
: `\n <meta http-equiv="Content-Security-Policy" content="${script.inlineCsp}" />`
const scriptElement =
'src' in script
? `<script src="${script.src}"></script>`
: `<script>${MOBILE_WEB_MERMAID_FRAME_SCRIPT}</script>`
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
<meta http-equiv="Content-Security-Policy" content="${mobileWebMermaidFrameCsp()}" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />${policy}
<style>:root{--diagram-background:${theme.background};--diagram-primary:${theme.primary};--diagram-text:${theme.text};--diagram-line:${theme.line}}html,body{box-sizing:border-box;margin:0;background:var(--diagram-background)}#c{padding:8px}#c svg{max-width:100%;height:auto}</style>
</head>
<body>
@@ -233,7 +226,7 @@ export function buildMobileWebMermaidFrameDocument({
<textarea id="source" hidden>${encodedSource}</textarea>
<textarea id="token" hidden>${encodedToken}</textarea>
<div id="c"></div>
<script>${MOBILE_WEB_MERMAID_FRAME_SCRIPT}</script>
${scriptElement}
</body>
</html>`
}
@@ -0,0 +1,30 @@
/**
* Policy the native shell serves for the package's embedded frame documents (mermaid, markdown
* editor). The in-app WebView mermaid document reuses the shape over its own inline script.
*
* `scriptSources` is spelled out by the caller because the frames are sandboxed: WebKit resolves
* `'self'` against the frame's opaque origin, so a served frame has to name the package origin to
* load its own script. Chromium accepts `'self'` there; WebKit does not.
*/
export function mobileWebEmbeddedFrameCspDirectives(scriptSources: string) {
return [
"default-src 'none'",
`script-src ${scriptSources}`,
"style-src 'unsafe-inline'",
'img-src data:',
"font-src 'none'",
"connect-src 'none'",
"media-src 'none'",
"object-src 'none'",
"frame-src 'none'",
"child-src 'none'",
"worker-src 'none'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'self'"
] as const
}
export function mobileWebEmbeddedFrameCsp(scriptSources: string): string {
return mobileWebEmbeddedFrameCspDirectives(scriptSources).join('; ')
}
@@ -1,8 +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 { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH } from '../components/markdown-editor-csp'
import { mobileWebMermaidFrameCspDirectives } from '../components/pr-sidebar/mermaid-frame-document'
import { mobileWebEmbeddedFrameCspDirectives } from './embedded-frame-csp'
const iosSource = readFileSync(
new URL('../../packages/expo-mobile-web-shell/ios/MobileWebShellView.swift', import.meta.url),
@@ -199,13 +198,16 @@ describe('mobile web native bridge transport', () => {
expect(nativeCspDirectives(iosSource, 'mobileWebCsp')).toEqual(
nativeCspDirectives(androidSource, 'MOBILE_WEB_CSP')
)
expect(nativeCspDirectives(iosSource, 'mobileWebMermaidFrameCsp')).toEqual(
mobileWebMermaidFrameCspDirectives()
// The frame policies interpolate the per-session package origin, so the mirror keeps the
// native interpolation token in the script source it compares against.
expect(nativeCspDirectives(iosSource, 'func mobileWebEmbeddedFrameCsp')).toEqual(
mobileWebEmbeddedFrameCspDirectives('\\(origin) blob:')
)
expect(nativeCspDirectives(androidSource, 'MOBILE_WEB_MERMAID_FRAME_CSP')).toEqual(
mobileWebMermaidFrameCspDirectives()
expect(nativeCspDirectives(androidSource, 'fun mobileWebEmbeddedFrameCsp')).toEqual(
mobileWebEmbeddedFrameCspDirectives('$origin blob:')
)
for (const source of [iosSource, androidSource]) {
expect(source).not.toContain('sha256-')
expect(source).toContain('"frame-src \'self\' data:"')
expect(source).toContain('"child-src \'self\' data:"')
expect(source).toContain('"connect-src \'none\'"')
@@ -214,13 +216,9 @@ describe('mobile web native bridge transport', () => {
expect(source).toContain('"form-action \'none\'"')
expect(source).not.toContain("\"script-src 'self' 'unsafe-inline'\"")
}
expect(iosSource).toContain(
`"script-src 'self' ${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH}"`
)
expect(iosSource).toContain('"script-src \'self\'"')
expect(iosSource).toContain("\"style-src 'self' 'unsafe-inline'\"")
expect(androidSource).toContain(
`"script-src 'self' ${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH}"`
)
expect(androidSource).toContain('"script-src \'self\'"')
expect(androidSource).toContain("\"style-src 'self' 'unsafe-inline'\"")
expect(androidSource).toContain('"font-src \'self\'"')
expect(androidSource).toContain('"img-src \'self\' data: blob:"')
@@ -251,10 +249,15 @@ function nativeBlockerScript(source: string, declaration: string): string {
}
function nativeCspDirectives(source: string, declaration: string): string[] {
const kotlinStart = source.indexOf(`${declaration} = listOf(`)
const swiftStart = source.indexOf(`${declaration} = [`)
const opening = kotlinStart !== -1 ? kotlinStart : swiftStart
const closing = source.indexOf(kotlinStart !== -1 ? ').joinToString' : '].joined', opening)
const declared = source.indexOf(declaration)
if (declared === -1) {
return []
}
const kotlinStart = source.indexOf('listOf(', declared)
const swiftStart = source.indexOf('[', declared)
const kotlin = kotlinStart !== -1 && (swiftStart === -1 || kotlinStart < swiftStart)
const opening = kotlin ? kotlinStart : swiftStart
const closing = source.indexOf(kotlin ? ').joinToString' : '].joined', opening)
if (opening === -1 || closing === -1) {
return []
}
@@ -1,6 +1,5 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH } from '../components/markdown-editor-csp'
const iosShellViewSource = readFileSync(
new URL('../../packages/expo-mobile-web-shell/ios/MobileWebShellView.swift', import.meta.url),
@@ -30,9 +29,7 @@ describe('mobile web shell root route', () => {
expect(iosShellViewSource).toContain('url.path == "/"')
expect(iosShellViewSource).not.toContain('url.path == "/index.html"')
expect(iosShellViewSource).toContain("\"style-src 'self' 'unsafe-inline'\"")
expect(iosShellViewSource).toContain(
`"script-src 'self' ${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH}"`
)
expect(iosShellViewSource).toContain('"script-src \'self\'"')
expect(iosShellViewSource).not.toContain("\"script-src 'self' 'unsafe-inline'\"")
})
+4 -1
View File
@@ -8,7 +8,10 @@ export const MOBILE_WEB_MAX_ASSET_COUNT = 256
export const MOBILE_WEB_MAX_PATH_CHARS = 240
export const MOBILE_WEB_MAX_BRIDGE_VERSION = 65_535
export const MOBILE_WEB_ENTRYPOINT_PATH = 'index.html'
export const MOBILE_WEB_EMBEDDED_DOCUMENT_PATHS = ['mermaid-frame.html'] as const
export const MOBILE_WEB_EMBEDDED_DOCUMENT_PATHS = [
'markdown-editor.html',
'mermaid-frame.html'
] as const
const SHA256_PATTERN = /^[a-f0-9]{64}$/
const SAFE_PATH_PATTERN = /^[A-Za-z0-9._/-]+$/