Files
orca/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift
T
Brennan Benson 7b91405547 Let in-page links switch sections inside a chat visual (#26729)
* Let in-page links switch sections inside a chat visual

* Make in-page links work in chat visuals on mobile and in WebKit

The first fix (an about:srcdoc base in the visual) only reached desktop
Chromium. On mobile, the page hosting the visual carried a policy with
base-uri 'none', and a srcdoc visual inherits its host's policy before
its own, so the base was refused and an in-page link still took the
visual off its page and showed it as failed. The mobile web shell sends
the same rule as a header. Separately, WebKit (iOS, and the web client
in Safari) fires the frame's load event for an in-page link inside a
sandboxed frame, and both hosts treated any second load as the visual
navigating away and retired it.

- The mobile host page and the mobile web shell header now allow only an
  about: base, so the shell's own base applies; the visual's own policy
  still refuses any base it declares.
- Instead of retiring a visual on its second load, the host now asks the
  visual's shell after every load. A later load must be answered by the
  same run of the shell within the plugin panel watchdog's 5 s deadline;
  no answer, or an answer from a new run (a reload), still retires it.
- Tests: unit tests for the policy, the answer reader and the frame's
  keep/retire rules, and a Chromium + WebKit test of both mobile surfaces
  that runs in the mobile web app CI job.

* Keep any answered later load, and say plainly what the check is for

The previous commit tagged each run of a visual's wrapper with a random
id so a reload would still retire it. The visual shares a window with
its wrapper, so it could read or set that id itself, and a reload only
reruns the same Orca-written document, which is not an escape. Drop it:
after any load past the first, the host asks once, and any answer on
this visual's channel within the wait keeps it; silence retires it.

- Comments now say the check is a tripwire for a frame gone blank or to
  another page; the frame guard (desktop), the host page's policy plus
  the app's load filter (mobile) and the sandbox are the boundaries.
- Visuals get their own 5 s wait instead of borrowing the plugin panel
  watchdog's, because a 1 s wait retired a busy visual in WebKit.
- Tests: a reload is now kept; going blank, or dropping the base and
  following a link, still retires. The CI routing for the shared wrapper
  is now pinned in the path-classification test.

* Load the visual links check's mobile modules only where they can build

The unit shards run every config/scripts test with only the root
dependencies installed. This check imported a mobile/ module at load,
and transforming it reads mobile/tsconfig.json, which extends a package
those shards do not have. The transform error could not be sent back to
the test worker under Bun, so the whole shard stopped with
"DataCloneError: The object can not be cloned".

Import the visual shell and the mobile host page inside the dependency
gate, as the session-dictation check already does, so the shards skip
the file without loading them. The mobile web app job, which installs
mobile dependencies, still runs it in full.
2026-10-09 10:07:59 -07:00

56 lines
3.4 KiB
Swift

enum MobileWebShellCsp {
/// Sent as a response header on the document and nowhere else: a served document must never
/// carry its own policy, so there is no meta tag to find and no bundle change that can relax it.
static let header = [
"default-src 'none'",
"script-src 'self'",
// React Native Web 0.21.2 injects its stylesheet at runtime with no nonce support, so the
// 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'",
// `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. `https:` for the
// remote images the page already tries to render and cannot: an agent's favicon, which is a
// fixed `google.com/s2/favicons` URL; a project's icon, which is a favicon, an avatar or an
// upload the host names; and whatever an artifact names inside the sealed HTML preview frame,
// which inherits this policy because a `srcdoc` frame has no URL of its own.
//
// Not markdown, not the rich editor and not a review comment's avatar, whatever a later reader
// assumes from ruling 26: markdown paints `![](...)` as a tappable link, the editor is still a
// plain source field, and the avatar is skipped under `Platform.OS !== 'web'` by a card that
// pins the skip in a test. All three are anticipated surfaces, and C7.10 is where they become
// real ones.
//
// The bound is the destination, not the provenance. CSP matches both as schemes, so this admits
// any image URL of either and cannot tell one the page composed from one it was handed; for
// `data:` 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 them, 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. What `https:` adds is a request whose target the rendered content
// chose, so a document can learn it was opened; the shell's Referrer-Policy header keeps the
// session id out.
//
// `http:` stays out: it reaches no image TLS cannot serve, and a cleartext image is readable
// and replaceable in flight by anything on the path.
"img-src 'self' data: https:",
"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 fence for fetch and XMLHttpRequest; the document-start script covers only the two things
// the native layer cannot see.
"connect-src 'self'",
// Media previews play bytes downloaded through authenticated RPC, never a network URL.
"media-src blob:",
"object-src 'none'",
"frame-src 'none'",
"child-src 'none'",
"worker-src 'none'",
// `about:` only, so a chat visual's srcdoc frame (which inherits this) keeps its own
// `about:srcdoc` base and in-page links stay in it; no network base can apply.
"base-uri about:",
"form-action 'none'",
"frame-ancestors 'none'"
].joined(separator: "; ")
}