mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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')) {
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+16
-6
@@ -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
|
||||
|
||||
+1
@@ -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'\"")
|
||||
})
|
||||
|
||||
|
||||
@@ -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._/-]+$/
|
||||
|
||||
Reference in New Issue
Block a user