fix(mobile): let the shell's page paint a file preview (OTA phase C, C3.0) (#21591)

* fix(mobile): let the shell's page paint a file preview

A file preview has one shape on the wire: the desktop answers a base64 body
and `normalizeMobileFilePreviewResult` composes `data:<mime>;base64,<content>`
for React Native Web's `Image`. Under `img-src 'self'` the browser refuses to
load it, so every image preview in the page paints nothing — reproduced in the
render check, which logged the refusal naming `img-src 'self'` before this.

`data:` is granted to images and to nothing else, so what it admits is what the
page itself composed out of a reply it already holds; `script-src 'self'` and
`connect-src 'self'` are untouched, and `blob:` is not added because nothing in
the closure needs one. Both platform pins narrow from "the header contains no
`data:`" to "`data:` appears on `img-src` and nowhere else", which is the check
that still fails if a later directive grows one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what the data: image case actually loads through

Round 1 is right on both counts. In react-native-web 0.21.2 the hidden <img>
the Image component renders carries `alt`, `style`, `draggable`, `ref` and
`src` and no load handlers at all — it is there for the browser's image context
menu and for `getBackgroundSize()`. The load signal comes from
`ImageLoader.load`, which is `new window.Image()` with `onload`/`onerror` on it,
so the `new Image()` in this case is the same mechanism the screen's own load
runs through rather than a stand-in for it.

And the screen maps `onImageError` to "Unable to load preview"
(`MobileFilePreviewScreen.tsx:282`); "Binary preview unavailable" is the
normalizer's `binary_file` branch, which a CSP refusal never reaches.

Comment only. No assertion moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the img-src data: bound as the destination, not provenance

"admits only what the page itself built" read as a provenance guarantee, and CSP
has none to give: `data:` is matched as a scheme, so the directive admits any
`data:` image URL and the browser cannot tell one the page composed from one it
was handed. Nor is the content the page's own — the mime type and the base64
body both come from the host, and `normalizeImagePreviewResult` only checks the
mime type is a non-empty string.

The true bound is where the URL goes: it 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 `<img>` runs no script), and `script-src 'self'`,
`connect-src 'self'` and `object-src 'none'` are untouched.

Both copies reworded identically, since they are kept in step by the render
check's own policy comparison.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-09-19 02:36:39 -04:00
committed by GitHub
parent d966927013
commit 428558b941
7 changed files with 84 additions and 10 deletions
@@ -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',
+3 -2
View File
@@ -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',
@@ -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:<mime>;base64,<content>` 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 <img> 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', () => {
@@ -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:<mime>;base64,<content>` 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 <img> 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
@@ -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:<mime>;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"))
}
@@ -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:<mime>;base64,<content>` 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 <img> 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
@@ -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:<mime>;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"))
}