diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index abb0085de78..8e0e27291ff 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -192,9 +192,10 @@ export function mobileWebAppBuildOptions(routes) { '.js', '.json' ], - // Images are emitted as same-origin assets, not data: URLs: the shell's CSP sets - // img-src 'self', which refuses data:. Content-hashed names keep the buildId reproducible. - // A font would fail the build here rather than silently ship under font-src 'none'. + // Images are emitted as same-origin assets, not data: URLs, so their content-hashed names keep + // the buildId reproducible and the bytes out of every chunk that imports one. The policy now + // admits data: for images, but that is for a preview the page composes at runtime, not for a + // bundled asset. A font would fail the build here rather than silently ship under font-src 'none'. loader: { ...ROUTE_SOURCE_LOADERS, '.png': 'file', diff --git a/config/scripts/build-mobile-web-bundle.mjs b/config/scripts/build-mobile-web-bundle.mjs index f623ec271e0..74d2836f288 100644 --- a/config/scripts/build-mobile-web-bundle.mjs +++ b/config/scripts/build-mobile-web-bundle.mjs @@ -17,8 +17,9 @@ const CONTENT_TYPE_BY_EXTENSION = { html: 'text/html; charset=utf-8', js: 'text/javascript; charset=utf-8', png: 'image/png', - // The Phase C app bundle emits images as same-origin assets rather than data: URLs, which the - // shell's img-src 'self' refuses. Fonts are absent by design: the policy sets font-src 'none'. + // The Phase C app bundle emits images as same-origin assets rather than data: URLs, so each one + // is content-hashed and served from here. Fonts are absent by design: the policy sets + // font-src 'none'. jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', diff --git a/config/scripts/mobile-web-app-render.test.mjs b/config/scripts/mobile-web-app-render.test.mjs index 8c83e41c9f6..3b60d9db47c 100644 --- a/config/scripts/mobile-web-app-render.test.mjs +++ b/config/scripts/mobile-web-app-render.test.mjs @@ -468,6 +468,46 @@ describe('the shell policy this page is tested under', () => { expect(cspHeader).toContain("script-src 'self';") expect(cspHeader).not.toContain("script-src 'self' 'unsafe-inline'") }) + + it('admits data: for images and for nothing else', () => { + expect(cspHeader.split('; ').filter((entry) => entry.includes('data:'))).toEqual([ + "img-src 'self' data:" + ]) + }) +}) + +/** A 1x1 PNG: the smallest payload that proves an image decoded rather than merely being allowed. */ +const DATA_URI_IMAGE = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + +describeRender('an image preview under the shell policy', () => { + it('decodes a data: URI, which is the only shape a file preview has', async () => { + // What a preview actually is: normalizeMobileFilePreviewResult composes + // `data:;base64,` out of a reply the page already holds and hands it to React + // Native Web's Image, which paints it as a CSS background. The `new Image()` below is not a + // stand-in for that: react-native-web 0.21.2 loads through `ImageLoader.load`, which is + // `new window.Image()` with `onload`/`onerror` on it, and the hidden the component also + // renders carries neither — it is there for the browser's image context menu and for + // `getBackgroundSize()`. So this is the same mechanism the screen's own load runs through, and + // its failure is what turns the screen into "Unable to load preview". + const { page, errors } = await openPage() + await page.goto(`${origin}/`, { waitUntil: 'load' }) + const naturalWidth = await page.evaluate( + (uri) => + new Promise((resolve) => { + const image = new Image() + image.addEventListener('load', () => resolve(image.naturalWidth)) + image.addEventListener('error', () => resolve(0)) + image.src = uri + }), + DATA_URI_IMAGE + ) + await page.close() + expect({ + naturalWidth, + refused: errors.filter((entry) => entry.includes('Content Security Policy')) + }).toEqual({ naturalWidth: 1, refused: [] }) + }) }) describeRender('the page server this check runs against', () => { diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt index 4aa96ce4a4f..60b2f69ac56 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt @@ -12,7 +12,17 @@ internal val MOBILE_WEB_SHELL_CSP = listOf( // Phase C page cannot paint under 'self' alone (measured: the render check under this exact // header). This relaxes styling only; script-src 'self' is untouched. "style-src 'self' 'unsafe-inline'", - "img-src 'self'", + // `data:` because a file preview has no other shape: the desktop answers a base64 body and the + // page composes `data:;base64,` for React Native Web's Image. + // + // The bound is the destination, not the provenance. CSP matches `data:` as a scheme, so this + // admits any `data:` image URL and cannot tell one the page composed from one it was handed; + // the mime type and the body are both the host's, and the page only checks the mime type is a + // non-empty string. What holds is that the URL is never fetched as anything but an image: + // img-src is the only directive admitting it, an image fetch executes nothing (an SVG inside + // an runs no script), and script-src 'self', connect-src 'self' and object-src 'none' + // are untouched. + "img-src 'self' data:", "font-src 'none'", // The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the // page cannot already read, and the bootstrap page reads ./manifest.json through it. This is the diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt index 3ee20a7832b..1771f3de3a5 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt @@ -13,7 +13,9 @@ class MobileWebShellCspTest { assertTrue(directives.contains("script-src 'self'")) // React Native Web injects runtime styles with no nonce; see MobileWebShellCsp. assertTrue(directives.contains("style-src 'self' 'unsafe-inline'")) - assertTrue(directives.contains("img-src 'self'")) + // A file preview is a `data:;base64,` URI the page composed from a reply it already + // holds; see MobileWebShellCsp. + assertTrue(directives.contains("img-src 'self' data:")) // The bootstrap page reads ./manifest.json from its own origin, which is one read-only // directory behind the manifest map, so 'self' reaches nothing it cannot already read. assertTrue(directives.contains("connect-src 'self'")) @@ -37,7 +39,12 @@ class MobileWebShellCspTest { ) assertTrue(directives.contains("script-src 'self'")) assertFalse(MOBILE_WEB_SHELL_CSP.contains("unsafe-eval")) - assertFalse(MOBILE_WEB_SHELL_CSP.contains("data:")) + // Narrowed rather than absent: `data:` is a fetch source for images and for nothing else, so a + // directive that grew one would fail here instead of passing a blanket absence check. + assertEquals( + listOf("img-src 'self' data:"), + directives.filter { it.contains("data:") } + ) assertFalse(MOBILE_WEB_SHELL_CSP.contains("blob:")) assertFalse(MOBILE_WEB_SHELL_CSP.contains("http")) } diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift index a467bf67a24..1155da42698 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift @@ -8,7 +8,17 @@ enum MobileWebShellCsp { // Phase C page cannot paint under 'self' alone (measured: the render check under this exact // header). This relaxes styling only; script-src 'self' is untouched. "style-src 'self' 'unsafe-inline'", - "img-src 'self'", + // `data:` because a file preview has no other shape: the desktop answers a base64 body and the + // page composes `data:;base64,` for React Native Web's Image. + // + // The bound is the destination, not the provenance. CSP matches `data:` as a scheme, so this + // admits any `data:` image URL and cannot tell one the page composed from one it was handed; + // the mime type and the body are both the host's, and the page only checks the mime type is a + // non-empty string. What holds is that the URL is never fetched as anything but an image: + // img-src is the only directive admitting it, an image fetch executes nothing (an SVG inside + // an runs no script), and script-src 'self', connect-src 'self' and object-src 'none' + // are untouched. + "img-src 'self' data:", "font-src 'none'", // The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the // page cannot already read, and the bootstrap page reads ./manifest.json through it. This is diff --git a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift index 4748b6c3310..396d4eb500e 100644 --- a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift +++ b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift @@ -208,6 +208,9 @@ import Foundation precondition(directives.contains("script-src 'self'")) // React Native Web injects runtime styles with no nonce; see MobileWebShellCsp. precondition(directives.contains("style-src 'self' 'unsafe-inline'")) + // A file preview is a `data:;base64,` URI the page composed from a reply it already + // holds; see MobileWebShellCsp. + precondition(directives.contains("img-src 'self' data:")) precondition(directives.contains("connect-src 'self'")) precondition(directives.contains("worker-src 'none'")) precondition(directives.contains("frame-src 'none'")) @@ -218,7 +221,9 @@ import Foundation // arrive as a fetched same-origin script, which is the directive that matters. precondition(directives.filter { $0.contains("unsafe-inline") } == ["style-src 'self' 'unsafe-inline'"]) precondition(!header.contains("unsafe-eval")) - precondition(!header.contains("data:")) + // Narrowed rather than absent: `data:` is a fetch source for images and for nothing else, so a + // directive that grew one would fail here instead of passing a blanket absence check. + precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data:"]) precondition(!header.contains("blob:")) precondition(!header.contains("\r") && !header.contains("\n")) }