mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* fix(mobile): admit https: images on the web shell's CSP (OTA phase C, ruling 27)
Native markdown and the native rich editor load images the author referenced
by URL, so the page has to as well or a remote image is a blank where native
paints a picture. `img-src` widens to `img-src 'self' data: https:` on both
platforms; `script-src`, `connect-src`, `object-src`, `frame-src` and
`child-src` do not move.
`http:` stays out, and the pins say so directly rather than by absence: the
Kotlin test's blanket `!contains("http")` could not survive `https:`, so both
native pins now check `http:` (not a substring of `https:`) and check that
`https:` appears in `img-src` and nowhere else, the same shape the `data:`
pin already had.
No behaviour change on released phones: the shell ships in no released tag
(mobile-v0.0.9 predates it), so this reaches devices with the Phase E native
build and not before.
Neither native module has a CI job, so both ran locally: swiftc over the
module plus MobileWebShellChecks, and
`:orca-mobile-web-shell:testDebugUnitTest`. Both were confirmed red against
the old directive first.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct what the sealed preview frame is stricter about
The doc comment said the page was deliberately stricter than the native
preview because it loads no remote image and runs no script. Since `img-src`
gained `https:` only the script half is true: the frame loads a remote image
exactly as the native WebView does.
Says instead what an artifact's image URL now is -- a channel that fires on
view and carries whatever its author encoded, with nothing dynamic behind it
because no script runs -- and names `referrerPolicy` as what keeps the
document's own origin out of the request.
Comment only; no behaviour and no test moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): measure both halves of the preview frame's image fence
"fetches nothing of the artifact that leaves the origin" stopped being what
the sealed arm proves once `img-src` gained `https:`. The fixture's foreign
origin is `http://127.0.0.1`, so its two images are refused on the scheme
alone and only the font is refused by `font-src 'none'`. Renamed to say
exactly that.
The half that was missing is an https arm. Playwright route interception
answers an `https://…invalid` origin in the page, so the arm needs no TLS
server and no new dependency, and a request only reaches the handler if the
policy let it out. Under the shipped header, on Chromium and WebKit, the
`<img>` and the CSS background are both requested -- `img-src` governs a
background too -- and the font still is not.
`artifact()` takes the subresource origin; the links stay on the cleartext
one so no existing navigation case changes.
Red-first: with `img-src 'self' data:` put back into the parsed Kotlin
policy, the new arm fails on both engines with `expected [] to deeply equal
[ '/css-bg.png', '/img.png' ]`. The directive was restored byte-identical
before this commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(scripts): split the preview frame's settling out of the render check
The https arm pushed mobile-web-app-html-preview-render.test.mjs to 620
counted lines, over the 600 cap config/scripts carries. Split at a module
boundary rather than bumped: the four wait-and-settle functions are rig
mechanics with no assertion in them, and they now sit beside the diagnosis
module they already reported through.
`waitForLoadedFrame` and `settleAfterMount` are the two the render check
calls; `waitForRecordedNavigation` and `settleWithoutNavigation` stay
internal to the new module.
Move only. Same 20 tests pass on both engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): send Referrer-Policy: no-referrer on the shell document
`img-src https:` gave the page somewhere to send a request, and the document
origin is `orca-mobile-web://<sessionId>/`, so a request that carries a
referrer carries the session id to whatever host an artifact or a markdown
document named.
`referrerPolicy="no-referrer"` on the preview iframe does not cover it.
Measured in the render rig against a permissive control policy: WebKit puts
the embedder's URL on a srcdoc frame's image request despite the attribute,
and Chromium sends none. So the guarantee belongs on the document, where one
header covers every request the page makes, and it rides the document alone
with the policy -- the referrer of a request is decided by the document that
made it, so on a subresource response it would govern nothing.
WKWebView under the custom scheme is unverified: the rig is Playwright
WebKit over http, not WKWebView over `orca-mobile-web://`. The header is the
hedge, and it costs nothing if that host never leaked.
Pinned three ways, each confirmed red first:
- Swift, exit 133 with the header removed.
- Kotlin, MobileWebShellResponseHeadersTest "sends the policy on the
document" FAILED at :17 with it removed.
- The rig, through a new `readShellDocumentHeaders` that parses the Kotlin
source the way `readShellCsp` does and throws rather than returning an
empty map. With the value flipped to `unsafe-url` the WebKit arm fails
`expected [ …(2) ] to deeply equal [ null, null ]`; with the line deleted
the parse throws "could not parse the shell document headers".
The rig's arm carries its own presence precondition: a third server serves
the shipped policy with `unsafe-url`, so the WebKit reading is the header
doing the work, and Chromium's null either way is pinned as the browser's
behaviour rather than sold as evidence the header arrived.
MobileHtmlPreview.web.tsx said the iframe attribute kept the origin out of
the request. Corrected to name the header, since the measurement above is
what disproved it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): quote the current directive where the old text was written down
Three comments still read `img-src 'self' data:`, so a grep for the old
directive found live prose that no longer matches the header. Each stays
about `data:`, which is what those paths rest on; only the quoted policy
changes.
The two remaining hits in the repo are src/main/browser/doc-preview-protocol,
which is the desktop preview's own policy and not this one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the surfaces img-src https: actually unblocks today
The comment justified `https:` with markdown and the rich editor, and
neither renders a remote image on the page. Verified in the tree:
MobileMarkdown paints `` as a tappable link at both of its image
branches and never mounts an Image, and it has no `.web` sibling, so that is
what native does too; MobileRichMarkdownEditor.web.tsx is a 92-line
multiline TextInput, still C7.6's plain source field.
What the directive unblocks today is four surfaces, none of them overridden
on the page:
- MobileAgentIcon's favicon, a hardcoded `google.com/s2/favicons` URL, used
by thirteen callers including the session header and the worktree rows;
- MobileRepoIcon's project icon, a host-named favicon, avatar or upload, on
the worktree list and the host workspace list;
- PRCommentCard's author avatar, from the review reply schema;
- the sealed HTML preview frame, which inherits the policy.
Markdown and the editor are named as the anticipated surfaces ruling 26
points at, so a later reader does not take the loosening as already covering
them. Both native pins carried the same wrong claim and are corrected.
That comment is the only record of why the policy loosened, so it says what
is true now and what is coming, separately.
Comment only: the parsed header is unchanged, checked through the harness
reader the render suite uses.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): point the new source-control route pin at the current directive
Merge resolution, not a conflict git could see. #21957 landed the
source-control and review page routes on main while this branch was open,
and its render check pins the directive text twice: `cspHeader` by substring,
which survives the widening, and the Swift source by the quoted literal
`"img-src 'self' data:"`, which does not. Two PRs green alone, red on the
merge.
Both pins now read the current directive.
One comment goes with it. "Not one request left the origin, so there is
nothing for the policy to have refused" now needs saying why: `https:` is
admitted, so an empty host list is these two closures fetching nothing
rather than the policy refusing something. The avatar that would fetch needs
provider data this page never gets, which the file's own closing note
already explains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): wait for the admitted images before reading their hits
CI's Chrome 152 recorded the CSS background and not the `<img>` by the time
the bounded settle returned, so both https arms failed on a count: "expected
[ '/css-bg.png' ] to deeply equal [ '/css-bg.png', '/img.png' ]" and
"expected 1 to be 2". The reads were absence-shaped -- two frames and 200 ms
-- and the claim they carry is a presence.
So the arms wait for their own evidence, the way the `'refusal'` arm already
does. `frameReady: 'images'` polls until both admitted paths are recorded,
bounded by nothing but the case's own `ctx.signal`. It sits after the marker
wait, because an image is requested by a document that has parsed, and the
arm hands its reader in rather than the settling module reaching for state
that belongs to an arm.
One reader now serves the wait and the reading. An arm that waits on one
list and asserts on another has proved nothing about the list it asserts on.
The `/probe.woff2` absence is untouched and is now an absence standing
behind two presences rather than beside them.
What the wait prints when it does not arrive, captured by making the paths
unsatisfiable against a 12 s case:
[html-preview-render] the arm recorded ["/img.png","/css-bg.png"] of
["/css-bg.png","/img.png","/never-arrives.png"]; #remote
{"complete":true,"naturalWidth":1,
"currentSrc":"https://artifact-images.invalid/img.png?n=n1",
"loading":null}: arm csp=shipped sandbox=product frameReady=images
nonce=n1 | browser 147.0.7727.15 | ... | frames [...]
`complete` with a zero `naturalWidth` is a request that finished and
produced no image; `complete` false is one still in flight. So a Chrome that
never issues the request says which of those it was, instead of a bare count.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): say why an admitted image never arrived, and hand back the context
CI's Chrome 152 read the `<img>` as complete with a zero naturalWidth and a
resolved currentSrc while the route handler never saw the request, and the
CSS background from the same origin did reach it. The diagnosis could say
the image failed but not why, because nothing was watching the request.
Now four sources are, for the `.invalid` origin only, in a module of their
own so the rig file stays under its cap: `request` says whether the page
asked at all, `requestfailed` carries the browser's `errorText`, and CDP's
`Network.loadingFailed` adds `blockedReason` and `corsErrorStatus`, which is
the only place a refusal names itself once the request never reaches a route
handler. `Network.requestWillBeSent` records the resource type, the initiator
and the frame, which separates an image the parser found from one nothing
asked for. They fill arrays while an arm passes and are only read on abort.
Proved by forcing the abort rather than assuming: with the awaited paths made
unsatisfiable, the reading names the font's refusal in both vocabularies at
once, `failed [{"url":".../probe.woff2","errorText":"csp"}]` and `cdp
loadingFailed [{"errorText":"","blockedReason":"csp",...,"type":"Font"}]`,
beside `cdp sent` showing every request's type, initiator and frameId.
Teardown: `open()` now takes an explicit context and closes both the page and
the context in a `finally`. The close used to sit on the happy path, so an
arm whose wait aborted and whose result reads then raced vitest's teardown
left its page and its implicit context open on a browser every later case in
that engine still runs on.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct three rationales the widening left wrong
(a) A review comment's avatar is not a surface the widening unblocks.
PRCommentCard renders it only under `Platform.OS !== 'web'` and a component
test pins the skip, so on the page it never renders. Dropped from both native
rationales and moved to the anticipated list beside markdown and the editor,
with the reason each is anticipated rather than current.
(b) The Kotlin rationale quoted the iOS origin. Android serves from
`https://<sha256(sessionId) first 32 hex>.orca-mobile-web.invalid/`, so a
referrer there carries a stable per-session handle and not the id itself,
while iOS serves `orca-mobile-web://<sessionId>/` and carries it verbatim.
Both are something an image host can key on across requests, which is what
the header is for; each file now names its own origin.
(c) "Only the script half of that is stricter than native" overstated it.
`font-src 'none'` and `connect-src 'self'` are stricter too. Images are the
one of the four that stopped being stricter, and the comment now says which
three remain and why.
A fourth, found while checking (a): the skip's own comment justified itself
with `img-src` being `'self' data:`, so a provider avatar would be "one
refused request per card". That is no longer true -- the avatar would load
now -- so the skip is a page capability gap rather than a policy consequence.
Recorded as such at the guard. Whether to lift the guard is a ruling-26
question and not this PR's.
Comments only. The parsed policy and document headers are unchanged, checked
through the harness readers the render suite uses.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): probe why Chrome never asks for the artifact image
CI's read was decisive: on Chrome 152 only the CSS background was requested,
while the `<img>` reported complete with a zero naturalWidth and a resolved
currentSrc. A request that went out and failed cannot produce both readings,
so the next probe asks the frame rather than the network.
On abort it now reads, inside the artifact frame: readyState, the init
script's own moment, document.images.length, every
`performance.getEntriesByType('resource')` name, the navigation entry types,
and for #remote its src, isConnected, complete, naturalWidth, currentSrc and
the outcome of decode(). A resource entry for a URL the rig never saw would
mean the request left the frame and died before reaching it.
Then it issues a `new Image()` at a URL that has never existed and reports two
seconds later whether the rig saw it. That splits the two live explanations: if
the fresh request is seen and the artifact's was not, the frame can fetch and
the parser-inserted element is the cause; if neither is seen, requests from
this frame are not reaching the rig at all. Subframe document commits are
counted from mount, because a second parse is a new window and leaves nothing
behind to count, and a second parse could be meeting a failure the first
cached.
`cdp sent` was empty on CI even for a request Playwright did record, so the
page's own session is blind to the frame. Chromium isolates sandboxed iframes
into their own process, srcdoc included, so flattened Target.setAutoAttach now
puts each child target on the same connection with Network.enable on the
child, and the attached list reports whether the frame is a separate target
at all.
The navigation arm gets the same reading, since CI showed it fails on its own
rather than behind the aborted image arms.
Verified by forcing the abort rather than assumed. Locally the reading prints
one subframe parse, decode resolved, every resource the document fetched, and
`fresh ... issued true seen true`, with the attached list empty, which is
consistent with this Chrome not isolating the frame and its page session
seeing the requests.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): time the artifact image against the frame's attachment
CI's second read showed the frame did issue the request -- it has a
resource-timing entry and decode rejected with EncodingError -- while the rig
saw only the CSS background, and a fresh image created later from the same
frame was both issued and seen. The remaining question is whether the entry
starts before anything was listening to that frame.
So the entry is now reported in full for the element under test:
responseStatus, transferSize, encodedBodySize, nextHopProtocol, startTime and
duration. A zero status with a zero transferSize is a fetch that reached the
network stack and came back with nothing, which is what an unintercepted
request looks like once `.invalid` fails to resolve.
Both sides of the comparison get a wall clock: `Target.attachedToTarget` and
Playwright's own `frameattached` now carry the moment they fired, and every
recorded request carries the moment it was seen. An entry that starts before
the attachment is the race stated rather than inferred.
Abort path only; the passing run is unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): serve the artifact's https assets from a real TLS listener
Interception could not measure what the directive admits. Chrome 152 isolates
the sandboxed srcdoc frame into its own target and the parser-inserted `<img>`
is the document's first fetch, issued before interception attaches there: the
request escaped to the real network, `artifact-images.invalid` did not
resolve, and the rig recorded nothing while the frame's own resource timing
showed the fetch and a later fresh image was both issued and seen.
So the assets come from a listener that is already accepting before the page
exists. It cannot be raced: the request arrives or it does not, and either
answer is the measurement. Hits and referrers are recorded server-side, the
way this rig's cleartext origin already does it, and read per arm by nonce.
`img-src 'self' data: https:` matches on scheme, so `https://127.0.0.1:<port>`
exercises the same directive as any other https host.
Lifecycle: started in beforeAll before any browser, closed in afterAll beside
the other servers. Its certificate is generated per run by openssl into the
suite's own scratch directory under `mobile/.tmp`, which the root gitignore
already covers and into which the server writes a second `.gitignore` as well;
the key never leaves that directory and nothing trusts it, since the context
is created with `ignoreHTTPSErrors`. No arm shares state: one hit list keyed
by each arm's nonce, and the permissive-Referrer-Policy control stays what it
was, a second bundle server serving the page, because the control is the
document's header and not the image host's.
The navigation record moves off interception too. It is now `page.on('request')`,
one subscription over every frame, armed after the rig's own `goto` exactly
where the route used to be registered; the route stays only for what only a
route can do, refuse the navigation. That answers the top-nav arm's `recorded
[]`: its record depended on the same per-target interception.
And the arms stop swallowing their clicks. `click(...).catch(() => {})` made a
tap that never landed and a tap that produced no navigation the same empty
counter; `open()` now records the error and the two top-nav arms assert it is
null before reading any count.
One correction to the reading added in the previous commit. The resource-timing
fields came back zero for a request that had plainly succeeded: they are opaque
cross-origin. The listener now sends `Timing-Allow-Origin`, after which
transferSize, encodedBodySize and nextHopProtocol carry real values.
`responseStatus` still reads zero on a successful request, so the comment names
the three that discriminate rather than the four that are printed.
24/24 on both local engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare the artifact fetch and the attachment on one clock
The early-or-late comparison spanned two clocks and could not answer the
question it was written for. Every `at` in the request log is Node's
`performance.now()`, counting from process start; the resource entry's
`startTime` is the frame's own, counting from that document's navigation. A
frame entry reads as earlier than a Node attachment by roughly the process
uptime, so the comparison would have reported the race as confirmed on every
run, including runs where there was no race. A green CI would not have caught
it.
So the comparison is stated where both numbers actually live: `asked` against
`attached` in the request log, on the Node clock alone. `startTime` and
`duration` stay, labelled as the frame's own account and explicitly not
comparable to an attachment time. The module docstring says the same, so the
next reading added here starts from the rule rather than rediscovering it.
The commit message of b5e82065f3 carries the same overstatement and is left
as it stands; this is the correction.
Also the stale route-handler references, now that the asset listener records
the secure origin and the navigation record is a page subscription. Three were
in the review; two more were not, and both were stale for the same reason:
`waitForRecordedNavigation`'s docstring still credited the route with
recording a main-frame navigation, which stopped being true when the record
moved off interception, and the request log described a refusal as one the
request never reached a route handler with. The route now only refuses; it
counts nothing. The one remaining mention is the deliberate contrast in the
rig that says the record is the page's event and not the route's.
Comments only. 24/24 on both local engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
879 lines
43 KiB
JavaScript
879 lines
43 KiB
JavaScript
/**
|
|
* The HTML preview's sealed frame, in a real browser under the shipped policy, on both engines.
|
|
*
|
|
* The frame holds an agent-produced artifact inside the page's own document, so every claim about
|
|
* what it cannot do has to be measured rather than reasoned about — and every one of those claims is
|
|
* an absence, which is also what a frame that never rendered reports. So each case runs against a
|
|
* no-header control where the same artifact does the thing: the script runs, the remote subresources
|
|
* are fetched, the navigation happens. Without those controls a preview that failed to load would
|
|
* pass every assertion here.
|
|
*
|
|
* WebKit as well as Chromium, because the iOS shell is WKWebView and the two disagree: a `blob:`
|
|
* frame that Chromium admits under `frame-src blob:` is refused in WebKit by the
|
|
* `frame-ancestors 'none'` it inherits. `srcdoc` is what both admit under the policy that already
|
|
* ships, which is why this costs no CSP change and why a case below pins `frame-src 'none'` as still
|
|
* shipped.
|
|
*
|
|
* The paint oracle is a pixel rather than a read inside the frame: the frame is an opaque origin, and
|
|
* WebKit refuses to evaluate in one, so reading its DOM would make the instrument engine-dependent.
|
|
*/
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { createServer } from 'node:http'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
import * as esbuild from 'esbuild'
|
|
import { PNG } from 'pngjs'
|
|
import { chromium, webkit } from 'playwright-core'
|
|
import { lucideBarrelPlugin } from './build-mobile-web-app-bundle.mjs'
|
|
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
|
import {
|
|
createBundleServer,
|
|
readShellCsp,
|
|
readShellDocumentHeaders
|
|
} from './mobile-web-app-render-harness.mjs'
|
|
import { createCspReportSink, reportedDirectives } from './mobile-web-app-preview-csp-reports.mjs'
|
|
import { recordRequestsTo } from './mobile-web-app-preview-request-log.mjs'
|
|
import { startArtifactAssetServer } from './mobile-web-app-preview-asset-server.mjs'
|
|
import { watchImageEvidence } from './mobile-web-app-preview-image-evidence.mjs'
|
|
import {
|
|
ARTIFACT_RGB,
|
|
ENTRY_SOURCE,
|
|
artifact,
|
|
artifactScript
|
|
} from './mobile-web-app-preview-artifact-fixture.mjs'
|
|
import {
|
|
previewFrame,
|
|
settleAfterMount,
|
|
waitForLoadedFrame,
|
|
waitForRecordedNavigation
|
|
} from './mobile-web-app-preview-frame-readiness.mjs'
|
|
|
|
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
|
|
|
|
/** Where the preview sits once mounted, which is what the pixel oracle samples. */
|
|
const FRAME_PROBE = { x: 60, y: 200, width: 4, height: 4 }
|
|
|
|
/** The page behind the frame, so a frame that painted nothing reads as this instead. */
|
|
const PAGE_RGB = '17,17,17'
|
|
|
|
/** Where the artifact's links and subresources point, and the origin that counts what it asked for. */
|
|
let foreignOrigin = null
|
|
const foreignHits = []
|
|
let foreign = null
|
|
|
|
/**
|
|
* The artifact's https asset origin: a real TLS listener rather than route interception.
|
|
*
|
|
* Interception could not measure it. Chrome 152 isolates the sandboxed `srcdoc` frame into its own
|
|
* target, and the parser-inserted `<img>` is the document's first fetch, issued before interception
|
|
* attaches there: the request escaped to the network, the unresolvable host failed it, and the rig
|
|
* recorded nothing while the frame's own resource timing showed the fetch. A listener already
|
|
* accepting before the page exists cannot be raced that way -- the request arrives or it does not,
|
|
* and either answer is the measurement. `img-src https:` matches on scheme, so `https://127.0.0.1`
|
|
* exercises the same directive any other https host would.
|
|
*/
|
|
let assetServer = null
|
|
|
|
let nonceCounter = 0
|
|
|
|
const bundles = mobileWebAppDependenciesPresent()
|
|
const describeRender = bundles ? describe : describe.skip
|
|
|
|
let scratch = null
|
|
let outDir = null
|
|
let shippedCsp = null
|
|
|
|
const browsers = {}
|
|
/**
|
|
* Two servers over one bundle rather than one server with a switch: the policy is a response header
|
|
* the harness reads once per server, and a control arm that shared a server with the sealed arm
|
|
* would be one race away from measuring the wrong header.
|
|
*/
|
|
let sealedServer = null
|
|
let openServer = null
|
|
/**
|
|
* A third server, serving the shipped policy with a deliberately permissive `Referrer-Policy`.
|
|
* It is the presence precondition for the referrer reading: Chromium sends no referrer from a
|
|
* srcdoc frame's image whatever the header says, so without an arm that does send one, "no
|
|
* `Referer`" there would pass on a rig that dropped the header entirely.
|
|
*/
|
|
let leakyServer = null
|
|
const origins = {}
|
|
let shippedDocumentHeaders = null
|
|
/** Every refusal the sealed server's policy was told about, by the arm that caused it. */
|
|
const cspReports = createCspReportSink()
|
|
|
|
beforeAll(async () => {
|
|
shippedCsp = await readShellCsp()
|
|
shippedDocumentHeaders = await readShellDocumentHeaders()
|
|
if (!bundles) {
|
|
return
|
|
}
|
|
foreignHits.length = 0
|
|
foreign = createServer((request, response) => {
|
|
foreignHits.push(request.url)
|
|
if (request.url.endsWith('.png')) {
|
|
response.writeHead(200, { 'content-type': 'image/png' })
|
|
response.end(
|
|
Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==',
|
|
'base64'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
response.writeHead(200, { 'content-type': 'text/html', 'access-control-allow-origin': '*' })
|
|
response.end('<html><body>FOREIGN</body></html>')
|
|
})
|
|
await new Promise((resolve) => foreign.listen(0, '127.0.0.1', resolve))
|
|
foreignOrigin = `http://127.0.0.1:${String(foreign.address().port)}`
|
|
|
|
await mkdir(join(mobileDir, '.tmp'), { recursive: true })
|
|
scratch = await mkdtemp(join(mobileDir, '.tmp', 'html-preview-render-'))
|
|
// Before any page exists, which is the point of it being a listener.
|
|
assetServer = await startArtifactAssetServer(scratch)
|
|
outDir = join(scratch, 'bundle')
|
|
await mkdir(outDir, { recursive: true })
|
|
await esbuild.build({
|
|
absWorkingDir: mobileDir,
|
|
stdin: {
|
|
contents: ENTRY_SOURCE,
|
|
resolveDir: join(mobileDir, 'src/components'),
|
|
loader: 'tsx',
|
|
sourcefile: 'html-preview-check.tsx'
|
|
},
|
|
bundle: true,
|
|
format: 'iife',
|
|
outfile: join(outDir, 'html-preview-check.js'),
|
|
target: ['es2022'],
|
|
jsx: 'automatic',
|
|
logLevel: 'silent',
|
|
// The page's own icon shim, imported rather than copied: `lucide-react-native` imports a
|
|
// `LucideProvider` its context module does not export, so the toolbar's icons do not link
|
|
// without it.
|
|
plugins: [lucideBarrelPlugin],
|
|
nodePaths: [join(mobileDir, 'node_modules')],
|
|
alias: { 'react-native': 'react-native-web' },
|
|
// The web sibling is what the page runs; naming the native file would measure the module that
|
|
// needs `react-native-webview` to exist. `.web.jsx`/`.web.js` are in the list for the same reason
|
|
// the real bundle has them: without them `react-native-svg`, which the toolbar's icons pull in,
|
|
// resolves its Fabric components and fails on `codegenNativeComponent`.
|
|
resolveExtensions: ['.web.tsx', '.web.ts', '.web.jsx', '.web.js', '.tsx', '.ts', '.jsx', '.js'],
|
|
define: { __DEV__: 'false', 'process.env.NODE_ENV': '"production"' }
|
|
})
|
|
await writeFile(
|
|
join(outDir, 'index.html'),
|
|
'<!doctype html><html><head><meta charset="utf-8"></head>' +
|
|
`<body style="margin:0;background:rgb(${PAGE_RGB})">` +
|
|
// A flex column at the viewport's height: the component's outermost `View` is `flex: 1`, and
|
|
// in a plain block container that resolves to no height at all and the frame never paints.
|
|
'<div id="root" style="display:flex;flex-direction:column;height:100vh"></div>' +
|
|
'<script src="/html-preview-check.js"></script></body></html>'
|
|
)
|
|
const sealed = await createBundleServer({
|
|
outDir,
|
|
// Per document, because each arm's policy names an endpoint carrying that arm's nonce.
|
|
cspHeader: (request) => cspReports.policyFor(shippedCsp, request),
|
|
documentHeaders: shippedDocumentHeaders,
|
|
handleRequest: (request, response, path) => cspReports.handleRequest(request, response, path)
|
|
})
|
|
sealedServer = sealed.server
|
|
origins.shipped = sealed.origin
|
|
const bare = await createBundleServer({ outDir, cspHeader: null })
|
|
openServer = bare.server
|
|
origins.none = bare.origin
|
|
const leaky = await createBundleServer({
|
|
outDir,
|
|
cspHeader: shippedCsp,
|
|
documentHeaders: { 'Referrer-Policy': 'unsafe-url' }
|
|
})
|
|
leakyServer = leaky.server
|
|
origins.leaky = leaky.origin
|
|
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
|
browsers.chromium = await chromium.launch({
|
|
headless: true,
|
|
...(executablePath ? { executablePath } : {})
|
|
})
|
|
// No override for WebKit: there is no system WebKit for Playwright to borrow, so a runner without
|
|
// the download skips rather than testing Chromium twice under another name.
|
|
browsers.webkit = await webkit.launch({ headless: true }).catch(() => null)
|
|
}, 300_000)
|
|
|
|
afterAll(async () => {
|
|
await browsers.chromium?.close()
|
|
await browsers.webkit?.close()
|
|
sealedServer?.close()
|
|
openServer?.close()
|
|
leakyServer?.close()
|
|
foreign?.close()
|
|
assetServer?.server.close()
|
|
if (scratch) {
|
|
// This run's directory only: `mobile/.tmp` is a shared ignored root and another suite may hold
|
|
// one of its own.
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Mounts the preview with one artifact and reports everything a case can assert on.
|
|
*
|
|
* `csp: null` is the control arm. The foreign origin's hit list is reset per open, so what it holds
|
|
* is this artifact's doing.
|
|
*/
|
|
async function open(
|
|
browser,
|
|
{
|
|
extra = {},
|
|
csp = 'shipped',
|
|
sandbox,
|
|
act,
|
|
expectNavigation = null,
|
|
frameReady = 'artifact',
|
|
assets,
|
|
reportReady = null,
|
|
signal
|
|
} = {}
|
|
) {
|
|
const origin = origins[csp === 'shipped' ? 'shipped' : csp === 'leaky' ? 'leaky' : 'none']
|
|
nonceCounter += 1
|
|
const nonce = `n${String(nonceCounter)}`
|
|
// Read here and carried as a string: asked for at the abort it lost its race with teardown and
|
|
// printed "browser unknown" in the CI log this diagnostic exists for.
|
|
const browserVersion = browser.version()
|
|
// An explicit context, so an arm that aborts mid-read can hand back everything it holds. The
|
|
// arms share one browser per engine; only the context is theirs.
|
|
// The asset listener's certificate is generated per run and trusted by nothing, which is what
|
|
// this flag is for; the page's own origin is still plain http from the bundle server.
|
|
const context = await browser.newContext({
|
|
viewport: { width: 390, height: 844 },
|
|
ignoreHTTPSErrors: true
|
|
})
|
|
const page = await context.newPage()
|
|
// Subscribed before the first navigation, so a request made during load is in the log. Cheap
|
|
// while an arm passes: it fills arrays, and only an abort asks them to speak.
|
|
const requestLog = await recordRequestsTo(page, assetServer.origin)
|
|
// Asked only when an arm has aborted, so the fresh-image probe and its wait cost a failing run
|
|
// and never a passing one.
|
|
const describeRequests = watchImageEvidence(page, assetServer.origin, requestLog, assetServer.saw)
|
|
try {
|
|
const navigations = []
|
|
const popups = []
|
|
let servedCsp = null
|
|
page.on('response', (response) => {
|
|
if (response.url().startsWith(`${origin}/preview`)) {
|
|
servedCsp = response.headers()['content-security-policy'] ?? null
|
|
}
|
|
})
|
|
page.on('popup', (popup) => {
|
|
popups.push(popup.url())
|
|
void popup.close().catch(() => {})
|
|
})
|
|
// The record is the page's own event, not the route handler's. Interception is per target and
|
|
// attaches late on a Chrome that isolates the sandboxed frame, which is what left the CI log
|
|
// saying `recorded []`; `page.on('request')` is one subscription over every frame the page has.
|
|
// Armed after the rig's own `goto`, exactly where the route used to be registered: the initial
|
|
// navigation is a main-frame navigation to this origin and would otherwise count as one the
|
|
// artifact asked for.
|
|
let recordingNavigations = false
|
|
page.on('request', (request) => {
|
|
if (!recordingNavigations || !request.isNavigationRequest()) {
|
|
return
|
|
}
|
|
const url = request.url()
|
|
if (!url.startsWith(foreignOrigin) && !url.startsWith(origin)) {
|
|
return
|
|
}
|
|
navigations.push({
|
|
url,
|
|
foreign: url.startsWith(foreignOrigin),
|
|
main: request.frame() === page.mainFrame()
|
|
})
|
|
})
|
|
// The route stays for what only a route can do: refuse the navigation. Playwright is not the
|
|
// shell, so a top-frame navigation is aborted here the way the shell's delegate would refuse
|
|
// it, and a frame navigating itself is left alone -- aborting that would make "the frame stayed
|
|
// on the artifact" true by the rig's own doing.
|
|
const record = (route) => {
|
|
const request = route.request()
|
|
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
|
|
return void route.abort()
|
|
}
|
|
return void route.continue()
|
|
}
|
|
await page.route(`${foreignOrigin}/**`, record)
|
|
// The shell page's violations, and only those: an artifact's own listener would have to run, and
|
|
// the fence under test is that nothing in the artifact runs.
|
|
await page.addInitScript(() => {
|
|
// When this ran, in every frame it ran in. The collector below can only report what it was
|
|
// present for, so its own moment is a reading rather than an assumption.
|
|
window.__initAt = `${String(Math.round(performance.now()))} ${document.readyState}`
|
|
window.__violations = []
|
|
document.addEventListener('securitypolicyviolation', (event) => {
|
|
window.__violations.push(`${event.violatedDirective} ${event.blockedURI || 'inline'}`)
|
|
})
|
|
})
|
|
// The nonce in the document's own URL: the policy this response carries names a report endpoint
|
|
// with the same nonce, which is how a report from a `srcdoc` frame with no URL of its own is
|
|
// attributed to the arm that caused it.
|
|
await page.goto(`${origin}/preview?n=${nonce}`, { waitUntil: 'load' })
|
|
recordingNavigations = true
|
|
// Registered after the page's own load, not before it: this handler aborts main-frame navigations
|
|
// and the initial `goto` is one. `href="/"` and `href=""` inside an artifact resolve against the
|
|
// embedder's base, so a tap on either asks to navigate the top frame to the shell's own document.
|
|
// The rig has no shell, so what this pins is the request the shell is handed; refusing it is
|
|
// `MobileWebShellDroppedNavigationTest`'s "refuses every navigation to the document that the shell
|
|
// did not ask for" and its `checkNavigationVerdict` twin on iOS.
|
|
await page.route(`${origin}/**`, record)
|
|
// `sandbox` undefined is the product's own token, which is what every non-control case runs.
|
|
await page.evaluate(
|
|
([html, override]) => window.__mount(html, override),
|
|
[
|
|
artifact({ links: foreignOrigin, assets: assets ?? foreignOrigin, extra, nonce }),
|
|
sandbox ?? null
|
|
]
|
|
)
|
|
// Named in every diagnostic, because the log shows the case and not which of its arms spoke.
|
|
const arm =
|
|
`arm csp=${csp} sandbox=${sandbox ?? 'product'} frameReady=${frameReady} ` +
|
|
`reportReady=${reportReady ?? 'none'} nonce=${nonce}`
|
|
// One reader for the wait and for the reading: an arm that waits on one list and asserts on
|
|
// another proves nothing about the list it asserts on.
|
|
const readImageHits = () => assetServer.hitsFor(nonce)
|
|
const artifactFrame = await waitForLoadedFrame(page, {
|
|
frameReady,
|
|
reportReady,
|
|
signal,
|
|
browserVersion,
|
|
arm,
|
|
sink: cspReports,
|
|
nonce,
|
|
readImageHits,
|
|
describeRequests
|
|
})
|
|
// Sampled before the action as well as after: a case that taps a link is asking what the tap
|
|
// produced, and by then the top frame is mid-navigation and the iframe has blanked to its own
|
|
// background. So the precondition "there was a rendered artifact to tap" is this reading, and the
|
|
// one below is only meaningful for a case that did nothing.
|
|
const pixelBefore = await probePixel(page)
|
|
const readToggles = async () =>
|
|
await page
|
|
.evaluate(() =>
|
|
[...document.querySelectorAll('[role="tab"]')].map((one) => ({
|
|
label: one.getAttribute('aria-label'),
|
|
selected: one.getAttribute('aria-selected')
|
|
}))
|
|
)
|
|
.catch(() => null)
|
|
// Sampled before the action as well, because the toggle's whole claim is that it changes.
|
|
const togglesBefore = await readToggles()
|
|
let actError = null
|
|
if (act) {
|
|
// Recorded, never swallowed: a click that never landed and a click that produced no
|
|
// navigation are the same empty counter, and only one of them is the product's doing.
|
|
await act({ page, frame: previewFrame(page) }).catch((error) => {
|
|
actError = String(error).split('\n')[0]
|
|
})
|
|
}
|
|
// Every arm settles, acting or not: an artifact can start a navigation with no tap behind it --
|
|
// `<meta http-equiv="refresh">` is one -- and the arms that pin zero were reading their counters
|
|
// while that was still in flight.
|
|
await settleAfterMount(page, navigations, expectNavigation, signal, {
|
|
frame: artifactFrame,
|
|
browserVersion,
|
|
arm,
|
|
describeRequests
|
|
})
|
|
const result = {
|
|
page,
|
|
pixelBefore,
|
|
pixel: await probePixel(page),
|
|
declaredSandbox: await page.evaluate(() => window.__sandbox),
|
|
// What the toolbar emits into the DOM, not what the component was handed: react-native-web
|
|
// forwards `aria-*` and drops `accessibilityState` on the floor, so a selected state that reads
|
|
// fine in the test renderer can reach a screen reader as nothing at all.
|
|
togglesBefore,
|
|
toggles: await readToggles(),
|
|
// The attribute on the element the component actually rendered, not the constant it exports: a
|
|
// literal in the JSX would leave the constant correct and the frame unsealed, which is what the
|
|
// control run for this file did before this reading existed.
|
|
mountedSandbox: await page
|
|
.evaluate(() => document.querySelector('iframe')?.getAttribute('sandbox') ?? null)
|
|
.catch(() => null),
|
|
frameCount: page.frames().length - 1,
|
|
// Reported so a pixel that read the page instead of the frame names the layout rather than
|
|
// looking like a frame that refused to load.
|
|
frameBox: await page
|
|
.evaluate(() => {
|
|
const frame = document.querySelector('iframe')
|
|
if (!frame) {
|
|
return null
|
|
}
|
|
const box = frame.getBoundingClientRect()
|
|
return { x: box.x, y: box.y, width: box.width, height: box.height }
|
|
})
|
|
.catch(() => null),
|
|
// Reported, never asserted on: a `srcdoc` frame's URL reads `about:srcdoc` here and empty on
|
|
// CI's browser, so nothing may be decided by it.
|
|
frameUrl: previewFrame(page)?.url() ?? null,
|
|
// The element's own attributes, which is where "the artifact is parsed inside the frame rather
|
|
// than fetched into it" actually lives.
|
|
mountedSrcDoc: await page
|
|
.evaluate(() => document.querySelector('iframe')?.getAttribute('srcdoc') ?? null)
|
|
.catch(() => null),
|
|
mountedSrc: await page
|
|
.evaluate(() => document.querySelector('iframe')?.getAttribute('src') ?? null)
|
|
.catch(() => null),
|
|
inside: await (previewFrame(page)
|
|
?.evaluate(() => ({
|
|
marker: document.getElementById('marker')?.textContent ?? null,
|
|
title: document.title,
|
|
ran: document.documentElement.dataset.ran === '1' ? 1 : 0,
|
|
threw: document.documentElement.dataset.threw ?? null,
|
|
// The two moments the late-listener question turns on: when the page's init script ran in
|
|
// this frame, and when the artifact's own script did.
|
|
initAt: window.__initAt ?? null,
|
|
artifactAt: document.documentElement.dataset.artifactAt ?? null,
|
|
// The frame's own list, not the embedder's: `securitypolicyviolation` does not cross frames,
|
|
// and the page's init script installs the same collector in every one.
|
|
violations: window.__violations ?? null
|
|
}))
|
|
.catch(() => null) ?? Promise.resolve(null)),
|
|
// What this document was actually served, so "the shipped policy, plus a report endpoint and
|
|
// nothing else" is asserted rather than intended.
|
|
servedCsp,
|
|
// Every refusal the browser reported for this arm, which is the evidence an in-frame listener
|
|
// cannot be relied on to have collected.
|
|
reported: reportedDirectives(cspReports, nonce),
|
|
// Null on every arm that acted successfully, and on every arm that did not act at all.
|
|
actError,
|
|
topNavigations: navigations.filter((one) => one.main && one.foreign).length,
|
|
ownOriginTopNavigations: navigations.filter((one) => one.main && !one.foreign).length,
|
|
// What the frame asked for itself at the embedder's origin, which is a different escape from a
|
|
// top-frame request and is refused by a different line of the policy.
|
|
ownOriginFrameNavigations: navigations.filter((one) => !one.main && !one.foreign).length,
|
|
popups: popups.length,
|
|
// This arm's fetches only, by nonce: the paths, with the nonce stripped, so a case reads the
|
|
// subresource rather than the bookkeeping.
|
|
foreignHits: foreignHits
|
|
.filter((one) => one.includes(`n=${nonce}`))
|
|
.map((one) => one.split('?')[0]),
|
|
// Same shape as `foreignHits` and read the same way: this arm's requests only, by nonce, as
|
|
// paths. Absolute URLs go in, so the origin is stripped along with the query.
|
|
secureHits: readImageHits(),
|
|
// What each admitted request carried, this arm's only, so an absence is this artifact's.
|
|
// Read off the header the listener received rather than off a request object handed to a
|
|
// route: the header on the wire is what the shell's `Referrer-Policy` is about.
|
|
secureReferers: assetServer.referersFor(nonce),
|
|
violations: await page.evaluate(() => window.__violations),
|
|
body: await page.evaluate(() => document.body.innerText)
|
|
}
|
|
return result
|
|
} finally {
|
|
// The context and not just the page: an arm whose wait aborted still owns one, and the case
|
|
// after it runs on the same browser. On the happy path this is the close that always ran.
|
|
await page.close().catch(() => {})
|
|
await context.close().catch(() => {})
|
|
}
|
|
}
|
|
|
|
for (const engine of ['chromium', 'webkit']) {
|
|
describeRender(
|
|
`the HTML preview's sealed frame on ${engine}`,
|
|
() => {
|
|
const browser = () => {
|
|
const one = browsers[engine]
|
|
if (!one) {
|
|
throw new Error(`${engine} is not installed for playwright-core`)
|
|
}
|
|
return one
|
|
}
|
|
|
|
it('paints the artifact under the policy the shell already ships', async (ctx) => {
|
|
const read = await open(browser(), { signal: ctx.signal })
|
|
expect(read.frameCount).toBe(1)
|
|
// The artifact is the frame's own document, not something it went and fetched: `srcdoc`
|
|
// carries it and there is no `src` at all. Read from the element rather than from the
|
|
// frame's URL, which is `about:srcdoc` on one browser and empty on another.
|
|
expect(read.mountedSrcDoc).toContain('ARTIFACT_RENDERED')
|
|
expect(read.mountedSrc).toBeNull()
|
|
// The rendered frame carries the constant, so the token case below is about the frame the
|
|
// page mounts rather than about a string nothing reads.
|
|
expect(read.mountedSandbox).toBe(read.declaredSandbox)
|
|
expect(read.mountedSandbox).toBe('allow-top-navigation-by-user-activation')
|
|
// The policy this document was served is the shell's own text plus the rig's report
|
|
// endpoint, and nothing else: `report-uri` says where a refusal is sent and changes nothing
|
|
// about what is enforced, so the arms below measure the shipped policy.
|
|
const servedParts = (read.servedCsp ?? '').split('; report-uri ')
|
|
expect(servedParts[0]).toBe(shippedCsp)
|
|
expect(servedParts).toHaveLength(2)
|
|
// The pixel, not a read inside the frame: the frame is an opaque origin.
|
|
expect(read.pixel).toBe(ARTIFACT_RGB)
|
|
// The shell page's own violations, which is all this can be: `securitypolicyviolation` does
|
|
// not cross into a frame, so an empty list here says the embedder raised none -- not that the
|
|
// frame raised none. What the frame's inherited policy did to the frame is measured where it
|
|
// can be: the pixel above is its inline `<style>` applying, and the counting server in the
|
|
// case below is its `img-src` and `font-src`.
|
|
expect(read.violations).toEqual([])
|
|
}, 120_000)
|
|
|
|
it('does not run the artifact, behind two fences either of which would hold', async (ctx) => {
|
|
const sealed = await open(browser(), {
|
|
extra: { body: artifactScript(foreignOrigin) },
|
|
signal: ctx.signal,
|
|
// The refusal this arm does cause, waited for so the missing one below is an absence
|
|
// measured beside a presence rather than a list read too early.
|
|
reportReady: 'img-src'
|
|
})
|
|
expect(sealed.pixel).toBe(ARTIFACT_RGB)
|
|
expect(sealed.inside?.ran).toBe(0)
|
|
expect(sealed.inside?.title).toBe('ARTIFACT')
|
|
expect(sealed.inside?.marker).toBe('ARTIFACT_RENDERED')
|
|
|
|
// The oracle's presence precondition: grant the frame `allow-scripts` and drop the policy,
|
|
// and this very fixture runs. Without this arm, "did not run" is also what an artifact with
|
|
// no script in it reports.
|
|
const loose = await open(browser(), {
|
|
signal: ctx.signal,
|
|
extra: { body: artifactScript(foreignOrigin) },
|
|
csp: null,
|
|
sandbox: 'allow-scripts allow-top-navigation-by-user-activation',
|
|
// The oracle here is what the script did, and the marker element exists before it runs,
|
|
// so this arm waits for the script's own write instead.
|
|
frameReady: 'script'
|
|
})
|
|
expect(loose.pixel).toBe(ARTIFACT_RGB)
|
|
expect(loose.inside?.ran).toBe(1)
|
|
expect(loose.inside?.title).toBe('SCRIPT_RAN')
|
|
// Nothing refused it, which is what "no policy" looks like: this arm's server sends no
|
|
// header at all, so there is no policy to report against and the script ran.
|
|
expect(loose.reported).toEqual([])
|
|
|
|
// The second fence, measured on its own: grant `allow-scripts` and keep the shipped policy,
|
|
// and the script still does not run, because a `srcdoc` frame inherits its embedder's
|
|
// `script-src 'self'` and the artifact's script is inline. So the seal does not rest on the
|
|
// sandbox attribute alone -- which is what makes the token list below a defence in depth
|
|
// rather than the only thing standing between the page and an agent's script.
|
|
const inherited = await open(browser(), {
|
|
signal: ctx.signal,
|
|
extra: { body: artifactScript(foreignOrigin) },
|
|
sandbox: 'allow-scripts allow-top-navigation-by-user-activation',
|
|
// The refusal below is this arm's oracle, so the arm waits for the browser to have
|
|
// reported it rather than reading whatever a list inside the frame happens to hold.
|
|
reportReady: 'script-src'
|
|
})
|
|
expect(inherited.pixel).toBe(ARTIFACT_RGB)
|
|
expect(inherited.inside?.ran).toBe(0)
|
|
expect(inherited.inside?.title).toBe('ARTIFACT')
|
|
// This arm's own precondition, and the thing CI showed a rig can get wrong: a frame that was
|
|
// never really widened refuses the script too, silently and with no report, and would pass
|
|
// every line above under a name that says the policy held. A `script-src` refusal can only
|
|
// be reported if the sandbox let the script start, so this is the reading that separates the
|
|
// two -- and it comes from the browser rather than from a listener in the frame, which on
|
|
// CI's Chrome intermittently missed this very entry while catching the image one beside it.
|
|
expect(inherited.reported.join(' ')).toContain('script-src')
|
|
// The sealed arm is the contrast, and it is why that line means what it says: the same
|
|
// artifact under the same policy was reported only for its image. Nothing refused its
|
|
// script, because the sandbox never let it begin.
|
|
expect(sealed.reported.join(' ')).toContain('img-src')
|
|
expect(sealed.reported.join(' ')).not.toContain('script-src')
|
|
}, 180_000)
|
|
|
|
it('refuses the artifact cleartext subresources by scheme and its font by directive', async (ctx) => {
|
|
const sealed = await open(browser(), { signal: ctx.signal })
|
|
expect(sealed.pixel).toBe(ARTIFACT_RGB)
|
|
expect(sealed.foreignHits).toEqual([])
|
|
// Two fences, not one, and the case name says which is which: this origin is cleartext
|
|
// `http:`, so `img-src 'self' data: https:` refuses both images on the scheme alone, and
|
|
// `font-src 'none'` refuses the font whatever its scheme. The https arm below is the other
|
|
// half -- remove it and an empty list here reads as "no remote subresource ever loads",
|
|
// which stopped being true when the directive gained `https:`.
|
|
const control = await open(browser(), { csp: null, signal: ctx.signal })
|
|
expect(control.pixel).toBe(ARTIFACT_RGB)
|
|
expect(control.foreignHits).toEqual(
|
|
expect.arrayContaining(['/img.png', '/css-bg.png', '/probe.woff2'])
|
|
)
|
|
}, 120_000)
|
|
|
|
it('loads the artifact https images the directive admits, and still refuses its font', async (ctx) => {
|
|
// Waited for, not hoped for: `frameReady: 'images'` is what makes the presence below a read
|
|
// after the requests rather than after a clock. CI's Chrome 152 had recorded the background
|
|
// and not the element when the old bounded settle expired.
|
|
const read = await open(browser(), {
|
|
assets: assetServer.origin,
|
|
frameReady: 'images',
|
|
signal: ctx.signal
|
|
})
|
|
expect(read.pixel).toBe(ARTIFACT_RGB)
|
|
// Both images, because `img-src` governs a CSS background as well as an `<img>` element,
|
|
// and a case that only watched the element would miss half of what the directive opened.
|
|
expect([...read.secureHits].sort()).toEqual(['/css-bg.png', '/img.png'])
|
|
// The directive that did not move, measured on the same origin in the same arm: `https:`
|
|
// reached `img-src` and nothing else, so the font is refused where the images are not.
|
|
expect(read.secureHits).not.toContain('/probe.woff2')
|
|
}, 120_000)
|
|
|
|
it('sends no referrer with an admitted https image, which is the shell header doing it', async (ctx) => {
|
|
const sealed = await open(browser(), {
|
|
assets: assetServer.origin,
|
|
frameReady: 'images',
|
|
signal: ctx.signal
|
|
})
|
|
// The presence precondition for the absence below: two requests were admitted and read, so
|
|
// an empty referrer list is what they carried rather than a list of nothing.
|
|
expect(sealed.secureHits.length).toBe(2)
|
|
expect(sealed.secureReferers).toEqual([null, null])
|
|
|
|
// Why the shell sends the header at all. Serve the same policy with a permissive
|
|
// `Referrer-Policy` and WebKit puts the embedder's URL on the image request, despite
|
|
// `referrerPolicy="no-referrer"` on the iframe element; on the phone that URL is
|
|
// `orca-mobile-web://<sessionId>/`, so the session id would reach the image host. Chromium
|
|
// sends none either way, which is worth pinning too: on that engine the reading above is
|
|
// the browser's own behaviour and not evidence the header arrived.
|
|
const leaky = await open(browser(), {
|
|
assets: assetServer.origin,
|
|
csp: 'leaky',
|
|
frameReady: 'images',
|
|
signal: ctx.signal
|
|
})
|
|
expect(leaky.secureHits.length).toBe(2)
|
|
const leaked = leaky.secureReferers.filter((one) => one !== null)
|
|
if (engine === 'webkit') {
|
|
expect(leaked.length).toBe(2)
|
|
expect(leaked.every((one) => one.startsWith(origins.leaky))).toBe(true)
|
|
} else {
|
|
expect(leaked).toEqual([])
|
|
}
|
|
}, 180_000)
|
|
|
|
it('asks to navigate the top frame to the shell itself, which the shell must refuse', async (ctx) => {
|
|
// `href="/"` resolves against the embedder's base, so this is a request to load the shell's
|
|
// own document -- one tap that would clear the bridge target, restart the load state and
|
|
// lose the page. The browser hands it up like any other, so refusing it is the shell's job
|
|
// and the native tests named above are where that is pinned; what this counts is that the
|
|
// request is real and reaches the shell at all.
|
|
const root = await open(browser(), {
|
|
signal: ctx.signal,
|
|
expectNavigation: 'main-frame',
|
|
act: async ({ frame }) => {
|
|
await frame?.click('#rootlink', { timeout: 2000 })
|
|
}
|
|
})
|
|
expect(root.pixelBefore).toBe(ARTIFACT_RGB)
|
|
// The tap landed. Without this the two counts below read the same whether the product
|
|
// refused to navigate or the rig never managed to click.
|
|
expect(root.actError).toBeNull()
|
|
expect(root.ownOriginTopNavigations).toBe(1)
|
|
expect(root.topNavigations).toBe(0)
|
|
|
|
// `href=""` is the same navigation spelled as "this document", and it resolves the same way.
|
|
const empty = await open(browser(), {
|
|
signal: ctx.signal,
|
|
expectNavigation: 'main-frame',
|
|
act: async ({ frame }) => {
|
|
await frame?.click('#emptylink', { timeout: 2000 })
|
|
}
|
|
})
|
|
expect(empty.pixelBefore).toBe(ARTIFACT_RGB)
|
|
expect(empty.actError).toBeNull()
|
|
expect(empty.ownOriginTopNavigations).toBe(1)
|
|
expect(empty.topNavigations).toBe(0)
|
|
}, 180_000)
|
|
|
|
it("hands a user's tap on a link to the top frame, exactly once", async (ctx) => {
|
|
const read = await open(browser(), {
|
|
signal: ctx.signal,
|
|
expectNavigation: 'main-frame',
|
|
act: async ({ frame }) => {
|
|
await frame?.click('#toplink', { timeout: 2000 })
|
|
}
|
|
})
|
|
expect(read.pixelBefore).toBe(ARTIFACT_RGB)
|
|
expect(read.topNavigations).toBe(1)
|
|
expect(read.ownOriginTopNavigations).toBe(0)
|
|
expect(read.popups).toBe(0)
|
|
}, 120_000)
|
|
|
|
it("cannot reach the shell through a meta refresh at the embedder's own URL", async (ctx) => {
|
|
// `content="0;url=/"` resolves against the embedder's base, so this is the artifact asking
|
|
// for the shell's own document with no tap behind it. The foreign meta-refresh arm below
|
|
// cannot say anything about that: its URL is off-origin, so its own-origin count is zero
|
|
// whatever the frame did.
|
|
const own = await open(browser(), {
|
|
signal: ctx.signal,
|
|
extra: { head: '<meta http-equiv="refresh" content="0;url=/">' }
|
|
})
|
|
// The frame is still showing the artifact, so what follows is about a refusal rather than
|
|
// about a frame that never rendered.
|
|
expect(own.pixelBefore).toBe(ARTIFACT_RGB)
|
|
// Zero against a counter that is not blind: the `href="/"` case above reads exactly 1 on this
|
|
// same reading, from this same rig.
|
|
expect(own.ownOriginTopNavigations).toBe(0)
|
|
expect(own.topNavigations).toBe(0)
|
|
// The other escape the same fixture could take: the frame fetching the shell's document for
|
|
// itself, which would put the session's own page inside the preview.
|
|
expect(own.ownOriginFrameNavigations).toBe(0)
|
|
|
|
// That zero's presence precondition: give the frame `allow-same-origin` and drop the policy
|
|
// and this very fixture navigates the frame to the embedder's `/`, so the reading is not
|
|
// blind.
|
|
const loose = await open(browser(), {
|
|
signal: ctx.signal,
|
|
csp: null,
|
|
sandbox: 'allow-scripts allow-same-origin allow-top-navigation',
|
|
extra: { head: '<meta http-equiv="refresh" content="0;url=/">' },
|
|
// This arm's frame leaves the artifact behind, which is the whole point of it, so the
|
|
// marker is not what says it is ready, and the navigation it makes is what it waits for.
|
|
frameReady: 'load',
|
|
expectNavigation: 'frame'
|
|
})
|
|
expect(loose.ownOriginFrameNavigations).toBe(1)
|
|
|
|
// Two fences, either of which would hold, each run with the other taken away -- the shape
|
|
// the script case above uses, rather than a claim in a comment.
|
|
//
|
|
// The token alone: no policy at all, and the navigation never starts, so nothing is served
|
|
// and nothing is reported.
|
|
const tokenOnly = await open(browser(), {
|
|
signal: ctx.signal,
|
|
csp: null,
|
|
extra: { head: '<meta http-equiv="refresh" content="0;url=/">' }
|
|
})
|
|
expect(tokenOnly.pixelBefore).toBe(ARTIFACT_RGB)
|
|
expect(tokenOnly.ownOriginFrameNavigations).toBe(0)
|
|
expect(tokenOnly.ownOriginTopNavigations).toBe(0)
|
|
expect(tokenOnly.violations).toEqual([])
|
|
|
|
// The policy alone: grant `allow-same-origin`, keep the shipped header, and the navigation
|
|
// does start -- and `frame-src 'none'` refuses it, which the embedder reports as its own
|
|
// violation because a parent's policy governs where its frame may go. The engines differ
|
|
// only in what is left behind: chromium swaps an error page into the frame, WebKit leaves
|
|
// the artifact showing. Neither is asserted; the request never reaching the server is.
|
|
const policyOnly = await open(browser(), {
|
|
signal: ctx.signal,
|
|
sandbox: 'allow-scripts allow-same-origin allow-top-navigation',
|
|
extra: { head: '<meta http-equiv="refresh" content="0;url=/">' },
|
|
frameReady: 'load'
|
|
})
|
|
expect(policyOnly.ownOriginFrameNavigations).toBe(0)
|
|
expect(policyOnly.ownOriginTopNavigations).toBe(0)
|
|
expect(policyOnly.violations.join(' ')).toContain('frame-src')
|
|
}, 180_000)
|
|
|
|
it('hands up nothing without a tap, and nothing for a form or a new window', async (ctx) => {
|
|
const meta = await open(browser(), {
|
|
signal: ctx.signal,
|
|
extra: { head: `<meta http-equiv="refresh" content="0;url=${foreignOrigin}/meta.html">` }
|
|
})
|
|
expect(meta.topNavigations).toBe(0)
|
|
expect(meta.ownOriginTopNavigations).toBe(0)
|
|
const form = await open(browser(), {
|
|
signal: ctx.signal,
|
|
act: async ({ frame }) => {
|
|
await frame?.click('#submit', { timeout: 2000 })
|
|
}
|
|
})
|
|
expect(form.pixelBefore).toBe(ARTIFACT_RGB)
|
|
expect(form.topNavigations).toBe(0)
|
|
const blank = await open(browser(), {
|
|
signal: ctx.signal,
|
|
act: async ({ frame }) => {
|
|
await frame?.click('#blanklink', { timeout: 2000 })
|
|
}
|
|
})
|
|
expect(blank.pixelBefore).toBe(ARTIFACT_RGB)
|
|
expect(blank.topNavigations).toBe(0)
|
|
expect(blank.popups).toBe(0)
|
|
}, 180_000)
|
|
|
|
// The navigation wait's sampling branch, driven once. It fires only when an arm is slow, so
|
|
// nothing here had ever executed it: a name out of scope inside it throws where no lint runs
|
|
// and no case looks. The printed reading is the proof that it ran and returned one.
|
|
it('reads the frame while a navigation it expects has not arrived', async (ctx) => {
|
|
void ctx
|
|
const page = await browser().newPage()
|
|
const printed = []
|
|
const spy = vi.spyOn(console, 'error').mockImplementation((line) => {
|
|
printed.push(String(line))
|
|
})
|
|
const stop = new AbortController()
|
|
const timer = setTimeout(() => stop.abort(), 300)
|
|
await waitForRecordedNavigation(
|
|
page,
|
|
[],
|
|
() => false,
|
|
stop.signal,
|
|
{ arm: 'arm sampling-probe', browserVersion: browser().version() },
|
|
25
|
|
)
|
|
clearTimeout(timer)
|
|
spy.mockRestore()
|
|
await page.close()
|
|
expect(printed).toHaveLength(1)
|
|
expect(printed[0]).toContain('arm sampling-probe')
|
|
// Not the placeholder: this string is only there if the sampling branch produced a reading.
|
|
expect(printed[0]).toContain('frames [')
|
|
}, 60_000)
|
|
|
|
it('keeps the Preview/Source toggle, and Source shows the source', async (ctx) => {
|
|
const read = await open(browser(), {
|
|
signal: ctx.signal,
|
|
act: async ({ page }) => {
|
|
await page.getByLabel('View HTML source').click({ timeout: 2000 })
|
|
}
|
|
})
|
|
// Both positions announce which one is showing, before and after the tap. Asserted on the
|
|
// DOM because that is where a screen reader reads it.
|
|
expect(read.togglesBefore).toEqual([
|
|
{ label: 'Preview rendered HTML', selected: 'true' },
|
|
{ label: 'View HTML source', selected: 'false' }
|
|
])
|
|
expect(read.toggles).toEqual([
|
|
{ label: 'Preview rendered HTML', selected: 'false' },
|
|
{ label: 'View HTML source', selected: 'true' }
|
|
])
|
|
expect(read.body).toContain('SOURCE_TAB_RENDERED')
|
|
// The frame went with the preview, which is why the toggle is not a control that lies.
|
|
expect(read.frameCount).toBe(0)
|
|
expect(read.pixel).toBe(PAGE_RGB)
|
|
}, 120_000)
|
|
},
|
|
600_000
|
|
)
|
|
}
|
|
|
|
describe('the HTML preview needs no policy change', () => {
|
|
it('runs under a policy that still forbids every nested frame by URL', async () => {
|
|
const directives = (await readShellCsp()).split('; ')
|
|
// A `srcdoc` frame has no URL for `frame-src` to match, so the sealed box costs nothing here.
|
|
// Pinned so a future relaxation is a decision rather than a side effect of this component.
|
|
expect(directives).toContain("frame-src 'none'")
|
|
expect(directives).toContain("child-src 'none'")
|
|
expect(directives).toContain("script-src 'self'")
|
|
expect(directives).toContain("frame-ancestors 'none'")
|
|
})
|
|
|
|
it('grants exactly one sandbox token, and neither of the two that would unseal the frame', async () => {
|
|
const source = await readFileText('mobile/src/components/MobileHtmlPreview.web.tsx')
|
|
const match = /MOBILE_HTML_PREVIEW_SANDBOX = '([^']*)'/.exec(source)
|
|
expect(match).not.toBeNull()
|
|
const tokens = (match?.[1] ?? '').split(' ').filter((one) => one.length > 0)
|
|
expect(tokens).toEqual(['allow-top-navigation-by-user-activation'])
|
|
// Named rather than left to the list comparison: these two are the sealing invariant, and a
|
|
// reader of a failure should see which one was granted.
|
|
expect(tokens).not.toContain('allow-scripts')
|
|
expect(tokens).not.toContain('allow-same-origin')
|
|
})
|
|
})
|
|
|
|
/** One pixel of the frame's own fill, which is what says the artifact parsed and painted. */
|
|
async function probePixel(page) {
|
|
const png = PNG.sync.read(await page.screenshot({ clip: FRAME_PROBE }))
|
|
return `${png.data[0]},${png.data[1]},${png.data[2]}`
|
|
}
|
|
|
|
async function readFileText(relativePath) {
|
|
const { readFile } = await import('node:fs/promises')
|
|
return await readFile(join(mobileDir, '..', relativePath), 'utf8')
|
|
}
|