@ai-ecoverse/slicc-browsers 1.16.2 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -4
- package/bfcache-restore.mjs +514 -0
- package/fixture-server.mjs +22 -0
- package/frame-read.mjs +182 -0
- package/guard-runner.mjs +66 -6
- package/guard-vectors.json +810 -2
- package/package.json +8 -6
- package/schema/guard-vectors.schema.json +38 -3
package/README.md
CHANGED
|
@@ -189,6 +189,8 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
|
|
|
189
189
|
|
|
190
190
|
`X1.after-exit` (family `X`, client lifecycle) needs `clientExit`. Through the guard, the client opens an ordinary tab, registers `Page.addScriptToEvaluateOnNewDocument` with `{beacon}`, `Network.setExtraHTTPHeaders` with `x-probe: {tag}` and `Fetch.enable` `*` with a rule that fulfils the destination with a forged `FULFILLED` page, then loads the `/page?k=href&tag={tag}&to={target}/canary/{tag}` trigger whose `go()` navigates to `{target}/canary/{tag}`. Before the exit it must see its init-script beacon, its header on the trigger request and the trigger's `Fetch.requestPaused`, matched up to `&to=`, so a guard that redacts the embedded protected URL to its origin still matches: those are the positive evidence on both legs. Then the client exits, the unguarded observer calls `go()`, and the destination must be served and read as the real `canary` document, with no beacon from `{target}`, no `x-probe` on its request, no forged body and no `Fetch.requestPaused` for it reaching the client: nothing the client registered may outlive it. The control runs the same on `{plain}` through the guard, and expects the same, since an ordinary navigation after the exit must be just as clean. A guard that keeps its upstream session, or its own init scripts, headers or interception, past the client's exit fails the row.
|
|
191
191
|
|
|
192
|
+
`C4.iframe-preattach.blank` and `C4.iframe-preattach.srcdoc` read a protected frame's same-process child that committed before the client attached. The unguarded observer opens `<from>/framed` around `{target}/nest?n=<tag>&kind=blank` or `kind=srcdoc`: the protected frame holds an `about:blank` child that it fills, or an `about:srcdoc` child, each named `frame-marker-<tag>` and showing that marker, and then moves itself with `history.replaceState` to `/nest/path-secret-<tag>?query-secret=<tag>#fragment-secret-<tag>`. `from` is `{ordinary-same-site}` on the guarded leg, so both frames share the page's process under site isolation, and `{plain}` on the control. The `.shared` rows open the page from `{ordinary}` on both legs and require `siteIsolationOff`; a transport without it reports them as `skip` with `not-supported:siteIsolationOff`. The observer must find the child in the page's own frame tree and pierced document, and must see the marker or the secret through every read. Then the client attaches to the settled page and makes the same reads: `Page.getFrameTree`, `Accessibility.getFullAXTree` with the protected frame's and the child's `frameId`, `Accessibility.getPartialAXTree` of the marker element's `backendNodeId` with `fetchRelatives`, `DOM.getDocument` with `depth: -1` and `pierce: true`, `DOM.describeNode` of the child's document and `DOM.getOuterHTML` of the marker element, and `DOMSnapshot.captureSnapshot`. Each `<read>Shown` holds when a reply carries the marker or the secret path, query or fragment, and `eventsShown` when an event on the client's session does. The guarded leg expects all of them false: a refusal, an error or origin-only data passes, and a refused attach refuses every read. The control runs unguarded and expects every read to show the content. Missing evidence is `incomplete`, with `frame-read: <reason>`: no same-process child within 5000 ms (scaled by `settleScale`), an observer read that doesn't show the content (that field reads `incomplete`), a client read or attach that was never answered (an error that says `timed out` or `closed`), or a control attach without a session.
|
|
193
|
+
|
|
192
194
|
The `C9` foreign extension isolated-world family requires `extensionWorld`: a browser hosting a foreign content-script extension on the ordinary fixture page. `C9.world-hidden` requires the page default context to remain visible after `Runtime.enable`, while the extension context and Runtime/Debugger identifiers remain hidden without cutting or detaching the session. `C9.world-select` probes numeric and unique context selectors, object and argument handles, promises, properties, named bindings and DOM node resolution; each command selecting the hidden world must fail with the `slicc: refused:` prefix, never an ordinary browser error. Numeric guesses also sweep ids 1–32: an id not exposed to this client must be refused, while every exposed numeric context must remain usable; none of those guarded probes may reach the fixture content-script marker. The marker combines the content script’s global lexical `root` binding with its extension-id DOM marker and `chrome.runtime.id`, and is independently required to be absent from the page default world. `C9.world-debugger` pauses inside that content script: the control must reach its frame, while the guard must refuse or hide it. The control waits up to 5000 ms (scaled by `settleScale`) for `Debugger.paused`; when it never pauses, `reachable` records `"incomplete"`, not `false`, and the row fails with `extensionWorld debugger: no Debugger.paused within <ms>ms`. The guarded leg waits up to 1500 ms, scaled by `refusalScale`. `C9.page-drivable` evaluates `self.origin`, captures a DOM snapshot and dispatches an Input click on the same ordinary page; cutting the entire session fails this regression row. All four use an unguarded control, the default browser context and observer discovery of the real extension world, not an invented isolated world. Protected default contexts remain session-cutting evidence; hiding a foreign isolated world does not weaken any seven-origin row.
|
|
193
195
|
|
|
194
196
|
The integration launcher loads shared-web 1.25.2’s harness extension exactly once through `Extensions.loadUnpacked` (`ankmbodmgimjemnojmeddfcdlbocolob`), whose content script runs on HTTP(S) pages. shared-web 1.25.2 also loads it by default in its own harness. A transport unable to host a foreign content-script extension omits `extensionWorld` and reports `not-supported:extensionWorld`; an advertised capability with no observed world fails, rather than silently skipping.
|
|
@@ -205,6 +207,14 @@ Every C2 redirect probe reports `sessionEnded`. It becomes true when the guarded
|
|
|
205
207
|
|
|
206
208
|
The `C8.newtab` reachability control attaches through the guard to a settled ordinary new tab, including its internal frames, then navigates that same tab to an ordinary document. Both legs must succeed, including without a guard. It requires `chromeUrlsAttach`, so an extension transport reports it as `skip` with `not-supported:chromeUrlsAttach`. The C8 protection rows expect a refusal on the guarded leg and run their controls unguarded, so they don't require it and still run on extension transports. It uses the default browser context, since an incognito new tab omits the ordinary new tab's internal frames. It covers `chrome://newtab/` and directly opened `chrome://new-tab-page/`.
|
|
207
209
|
|
|
210
|
+
`C8.protected-bfcache-restore` and `.history` test history restoration on seven and vault, with both detach variants. The independent observer seeds ordinary A, protected P and ordinary B on one tab. `/bfcache?n=<tag>` has no unload handler: it retains a global and sessionStorage marker, records `pageshow.persisted` and counts key events. The guarded client must answer on B before the gate is armed. The first row restores P with the observer's `Page.navigateToHistoryEntry`; `.history` sends that navigation through the client instead. A matched client navigation refusal lets the observer restore the same entry. The unguarded control must observe a real `BackForwardCacheRestore`, persisted pageshow, the marker and protected origin in evaluate, a touched global, and positive evidence from every probe method.
|
|
211
|
+
|
|
212
|
+
The rows require `inboundDelay`. The guarded client supplies `inboundDelay({ targetId, sessionId, ms, side, client, gate: true })`, a harness gate before the guard's inbound stream. It holds every browser-to-guard message until explicit release; guard-to-browser writes remain possible. The control gates only its unguarded client stream, not the independent observer session. Immediately before restoration the runner requests a gate with a 1500 ms minimum hold. The hook returns `{ targetId, sessionId, ms, gate: true, protectedObserved, ordinaryObserved, release, audit? }`. Both observed flags acknowledge delivery of the protected seed and subsequent ordinary B through the relevant stream before injection. `release()` restores delivery and returns `{ delayedEvents, firstDelayedAt, releasedAt }`, counting held restore events and recording their first hold and actual release times. Missing capability or hook is `not-supported:inboundDelay`; an unacknowledged gate, absent seed acknowledgement or no held restore event is `incomplete`. A timer-only event delay does not meet this contract.
|
|
213
|
+
|
|
214
|
+
After the observer confirms the persisted protected document, the client repeatedly probes `Runtime.evaluate`, `Runtime.callFunctionOn`, `DOM.getDocument`, `Accessibility.getFullAXTree`, `Input.dispatchKeyEvent`, `Page.captureScreenshot` and `IO.read` for 2500 ms while the gate stays closed. Runtime probes write `__touched`; Input is measured by the fixture's key counter. The observer supplies a real network-resource stream for IO's positive control: the read must use that handle and show the document marker, including base64-encoded bodies. Screenshot controls must return PNG bytes captured while P is current that match the independent observer's capture of P; unrelated or merely nonempty data is not evidence. The guard must hide the marker from all received messages and events and hide the protected origin from every probe reply, not only Runtime replies, and leave both side effects absent on P. A returned screenshot while P is current fails, regardless of its encoded contents. Refused, answered and unanswered outcomes are retained individually; an unanswered call alone never proves protection. The observer measures P's marker, persisted state and side effects before and after release. Missing restore, fixture, hold or side-effect evidence is `incomplete`, even when every measured observation matches; measured leaks or side effects fail even beside missing evidence. After release the runner drains probe replies for up to 5000 ms, scaled by `settleScale`, before scanning them and judging screenshots. An unresolved reply needs a complete final forwarding ledger proving terminal non-forwarding; otherwise its missing measurement is `incomplete`. A measured failure takes precedence over incomplete evidence.
|
|
215
|
+
|
|
216
|
+
`forwardAudit` gates only `commandsUnforwarded`. When advertised, `audit({ probes, final })` must return `{ complete: true, probes: [{ id, forwarded }] }` with a browser-side forwarding decision for every probe id; any probe sent while the gate is armed that was forwarded fails, even when its frame state was unknown. The gate-window audit runs before release. The final audit runs after reply settlement and certifies terminal forwarding decisions, including commands queued at release; its ledger must remain available after `release()`. An observed forward fails even beside a partial ledger. Guard-generated internal calls and the history trigger are not probe calls. A missing or partial ledger is `incomplete`. Without the capability the audit is `not-supported`, and the independent side-effect, leak and screenshot checks remain required. Each row's `evidence.control.restore` and `evidence.guarded.restore` retains gate acknowledgement/release, the restore timestamp, every probe's send time and outcome, forwarding evidence when available, and the independent side-effect reads. Cleanup releases the gate and closes only that row's targets, streams and sessions.
|
|
217
|
+
|
|
208
218
|
`C8.protected-error-page` touches an allowed tab, scripts a protected navigation, and requires the guard to fail its Document request. The independent observer verifies the committed error document and the exact unreachable/reported URL pair. The session stays attached; reload, navigation to `about:blank` and close succeed, while `Target.getTargetInfo`, `Target.getTargets`, the `Target.attachedToTarget` of a fresh attach and the `targetCreated`/`targetInfoChanged` events redact URL and title to the origin (`infoRedacted`, `listRedacted`, `attachRedacted`, `eventsRedacted`), and none of them shows the full failed URL (`fullURL`). The failed URL carries a secret path, `?query` and `#fragment`; the `.query` and `.fragment` variants of this row and of `C8.protected-error-page-sticky` fail `/doc?query-secret=…` and `/doc#fragment-secret-…` instead, so no path, query or fragment secret may survive alone either. The unguarded control uses independent Fetch failure injection and keeps raw discovery values: its `fullURL` must be `true`. `C8.protected-document-restore` seeds a real protected document before attachment, navigates away, then restores it from BFCache in the same tab after the error/recovery sequence; evaluate, attach and close must be refused. A real `BackForwardCacheRestore` commit is required, so a second failed network request cannot masquerade as this control. `C8.protected-error-page-sticky` additionally fails a request after that real protected restore and requires the already-protected tab to remain locked. They run on browser-session and page-session-only transports alike, have unguarded controls and clean up their own targets. The regression tests include URL-only locking, unconditional error-page unlocking, and a plain client with only request-failure injection.
|
|
209
219
|
|
|
210
220
|
`C8.network-error` and `C8.iframe-error` are ordinary-error reachability controls: request the configured `network-error` URL (default `http://nonexistent.invalid/`) directly or in an ordinary iframe, observe a failed Document request, reattach to the settled tab, evaluate and snapshot its document, then navigate the same tab to an ordinary document. Both control and guarded legs must pass. The failing URL is unchanged on the control, rather than replaced with a healthy origin, and the rows require `browserSession` but not `chromeUrlsAttach`. With Chrome for Testing 156.0.8078.4, scheme-wide `chrome-error` protection made the direct-error row fail both legs because reattachment was refused; removing it passed both rows, with readable DOM and successful recovery. The old emulator still allowed the cross-process iframe parent because the iframe TargetInfo kept the failing HTTP URL, but that layout-specific behavior is not grounds for protecting the error scheme. Plain-client controls and reachability legs also passed both rows.
|
|
@@ -218,7 +228,7 @@ A client may register `Fetch.enable` patterns naming a protected origin in eithe
|
|
|
218
228
|
- `setVersion`: the package version. The release flow stamps it into the npm package and the crate (`scripts/set-crate-version.mjs`). In git, and so in the Go module, it reads `0.0.0`; Go code gets the version from the module.
|
|
219
229
|
- `targets`: the names a runner maps to a scheme, host and port: `seven`, `vault`, `vault-x` (a vault branch preview), `apex`, `own` (the client page's origin, `{client}`), `ordinary`, `ordinary-same-site`, `chrome-newtab`, `chrome-new-tab-page`, `chrome-settings`, `chrome-passwords`, `chrome-search`, `network-error`, `chrome-extension-page` and `file-root`. Each has a `description` and, for the protected ones, a default `host`. `chrome-newtab` (`chrome://newtab/`), `chrome-new-tab-page` (`chrome://new-tab-page/`), `chrome-settings` (`chrome://settings/`), `chrome-passwords` (`chrome://password-manager/`), `chrome-search` (`chrome-search://most-visited/`), `network-error` (`http://nonexistent.invalid/`), `chrome-extension-page` (a page of Chrome's built-in PDF viewer extension) and `file-root` (`file:///`) carry a `url`, which the runner uses unless `targets` maps the name to something else. No other URL in the file is one a runner must use.
|
|
220
230
|
- `variants`: the detach variants in ms. `prompt` and `lagging` are documented; a runner may add others, such as `lagging-8s`, and reports the name it ran.
|
|
221
|
-
- `capabilities`: what a transport may lack. `autoAttach` covers `Target.setAutoAttach`, `children` steps and child sessions; `browserSession` covers calls on the browser session; `browserContexts` covers `Target.createBrowserContext` and `Target.disposeBrowserContext`; `extensionWorld` means it can host a foreign content-script extension isolated world on the ordinary fixture; `discovery` means session-less `Target.setDiscoverTargets` and `Target.targetCreated`/`Target.targetInfoChanged` delivery; `chromeUrlsAttach` means the transport can attach to and drive `chrome://` pages. CDP over a DevTools port can; `chrome.debugger` can't, since Chrome refuses it every `chrome://` page, even with `--extensions-on-chrome-urls`. `clientExit` means the harness can close the guarded client in the middle of a row: the client has `close()`, the `clientFactory` makes a fresh guarded client for the next leg, and an unguarded observer independent of the client outlives it.
|
|
231
|
+
- `capabilities`: what a transport may lack. `autoAttach` covers `Target.setAutoAttach`, `children` steps and child sessions; `browserSession` covers calls on the browser session; `browserContexts` covers `Target.createBrowserContext` and `Target.disposeBrowserContext`; `extensionWorld` means it can host a foreign content-script extension isolated world on the ordinary fixture; `discovery` means session-less `Target.setDiscoverTargets` and `Target.targetCreated`/`Target.targetInfoChanged` delivery; `chromeUrlsAttach` means the transport can attach to and drive `chrome://` pages. CDP over a DevTools port can; `chrome.debugger` can't, since Chrome refuses it every `chrome://` page, even with `--extensions-on-chrome-urls`. `clientExit` means the harness can close the guarded client in the middle of a row: the client has `close()`, the `clientFactory` makes a fresh guarded client for the next leg, and an unguarded observer independent of the client outlives it. `siteIsolationOff` means the browser runs without site isolation, so a cross-site iframe shares its parent's process.
|
|
222
232
|
- `placeholders`: every `{name}` used in the vectors, with what it stands for. `{client}` is the client page's origin, which the runner maps.
|
|
223
233
|
|
|
224
234
|
**A vector** has:
|
|
@@ -264,6 +274,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
264
274
|
- `/data?n=`: `real-<n>`, with CORS;
|
|
265
275
|
- `/script?n=`: a script setting `globalThis.vectorScript` to `real-<n>`;
|
|
266
276
|
- `/page?k=&to=`: a trigger page whose `go()` sends the page to `to` (`k=href`, `replace`, `popup` or `form`), and `/canary/<tag>`: a document titled `canary` with the marker;
|
|
277
|
+
- `/nest?n=&kind=` (and any path under `/nest`): the `C4.iframe-preattach` frame, with its `about:blank` or `about:srcdoc` child;
|
|
267
278
|
- `/img`: a 1×1 PNG;
|
|
268
279
|
- `/auth`: Basic `u`:`p`, then `authed`;
|
|
269
280
|
- `/rewritten` (title `rewritten`);
|
|
@@ -280,6 +291,10 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
280
291
|
|
|
281
292
|
For remote/page harnesses, `fixtureClient(base)` is exported by `./guard-runner` and `./fixture-client`, or `run(set, { fixtureBase: base, ... })` constructs it. The bundled server serves CORS-safe `GET /log?since=N` as `{ from: N, entries: [...] }`, with an append-only history unaffected by `fixture.reset()`, and `POST /redirect` with a JSON-encoded string body (a simple text/plain request, without a preflight). Control requests do not appear in the log. Registration descriptors are `{ path, host?, search?, to?, code?, body, remove? }`: `path` starts with `/`, `host` selects the callback hostname, `search` distinguishes query-bearing callbacks, `to` creates a redirect with the four Location/Content-Location/Refresh/Link headers, `code` defaults to 302, an omitted `to` serves the callback body, and `remove: true` unregisters it. The bundled endpoint returns 204; custom servers must acknowledge with a successful status and actually serve the registered routes. Custom fixtures may instead supply an async `fixture.redirectSecret` hook directly; a separate log/control server can implement the same `GET /log` and `POST /redirect` contract. A Node harness using browser-only DNS aliases should pass a reachable `fixtureBase` or its direct hook.
|
|
282
293
|
|
|
294
|
+
`{ "bfcacheRestore": "observer" | "history", "origin": "{protectedTarget}", "as": "restore", "requires": ["inboundDelay"] }` runs the history-restore compound probe. `{protectedTarget}` is the row target on both legs, so the unguarded control exercises the same protected fixture origin.
|
|
295
|
+
|
|
296
|
+
`{ "frameRead": "blank" | "srcdoc", "from", "as": "frames", "on"?, "requires"? }` runs the `C4.iframe-preattach` compound probe: the observer's page and reads, then the client's attach and reads, with boolean evidence for ordinary `result` checks.
|
|
297
|
+
|
|
283
298
|
`{ "unparseable": true, "as": "unparseable" }` runs the C8 malformed-URL compound probe on the current ordinary page: independent browser ground truth, raw/canonical destination commands, discovery/attach and cookie checks. Boolean evidence is consumed by ordinary `result` checks, with native Chrome outcomes retained in its diagnostics.
|
|
284
299
|
|
|
285
300
|
**The control rule.** Every vector has a control on an ordinary origin: the same setup and trigger against an unprotected origin must succeed. A missing or failing control fails the id. With `via: "guard"`, the control runs through the guard. With `via: "unguarded"`, the runner runs setup and trigger through its own CDP against the ordinary origin and against the protected stand-in, and both must show the effect. The runner reports the control's transport as `via`.
|
|
@@ -404,13 +419,13 @@ every alternative of an `any`, or a required (`all`, `not` or plain) check, need
|
|
|
404
419
|
selects one variant or an array; omission selects all. `parallel` defaults to 1.
|
|
405
420
|
With the `browserContexts` capability each leg runs in its own browser context. A `Target.createBrowserContext` that fails with a timeout is retried once, and the row's `notes` records it; any other failure fails the row.
|
|
406
421
|
|
|
407
|
-
Every target created by the runner’s own unguarded observer uses `Target.createTarget` with `newWindow: true`, then verifies through `Target.getTargetInfo` that `openerId` is absent. A supplied opener or missing target evidence fails and cleans up that target (in the protected-error-page probe, the row is `incomplete` instead), rather than inheriting the active client tab (which could be seven). This includes ordinary control setup, storage canaries, protected-error history seeds and the malformed-URL observer. The row notes record `newWindow=true, openerId absent`. Intentional guarded-client creation commands and popup/opener tests keep their original parameters.
|
|
422
|
+
Every target created by the runner’s own unguarded observer uses `Target.createTarget` with `newWindow: true`, then verifies through `Target.getTargetInfo` that `openerId` is absent. A supplied opener or missing target evidence fails and cleans up that target (in the protected-error-page probe, the row is `incomplete` instead), rather than inheriting the active client tab (which could be seven). This includes ordinary control setup, storage canaries, protected-error history seeds, the frame-read page and the malformed-URL observer. The row notes record `newWindow=true, openerId absent`. Intentional guarded-client creation commands and popup/opener tests keep their original parameters.
|
|
408
423
|
|
|
409
424
|
`settleScale` (default 1) multiplies the bounds of waits for events the leg expects to happen: settles (beacon, served, title and load) on the control leg and `required` settles on either leg, `paused` on the control leg, page and child loads, the wait for a background step's `as` result (`<as> did not complete within <ms>ms`), the wait for the pre-action beacons, the C9 control's `Debugger.paused`, and the wait for an observation the leg expects to be `true`. Adopters on slow hosts can set 3 or 4. Every wait polls and returns as soon as its event arrives, so a larger bound only costs time when something is really missing, and each timeout error names the bound it used. An observation read from the fixture (`observer: "fixture"`, such as a beacon or a served request) that the leg expects to be `true` is polled for up to 5000 ms (scaled) instead of read once. One that the leg expects to be `false` is still read once.
|
|
410
425
|
|
|
411
426
|
`refusalScale` (default 1) multiplies the bounds of waits for what the guarded leg expects to be absent, the beacon, request, title or navigation that the guard should block: settles that are not `required`, and `paused`, on the guarded leg. Such a wait runs to its full bound whenever the guard works, so `settleScale` leaves it alone. The trade-off: a larger `refusalScale` gives more sensitivity to a leak that arrives late on a slow host, because the observations after the wait see it, at the cost of that much more time per guarded row. Adopters should keep `refusalScale` at 1 or higher, never below 1; the runner refuses a `refusalScale` below 1 with `refusalScale must be a number of at least 1`.
|
|
412
427
|
|
|
413
|
-
**Incomplete.** A probe that returns `incomplete` (
|
|
428
|
+
**Incomplete.** A probe that returns `incomplete` (including `errorPage`, `redirectSecret`, `frameRead` and `bfcacheRestore` steps) records the reason in the row's `incomplete` as `<leg> <probe>: <reason>`. Its unmeasured observations read `"incomplete"`, and so does guarded liveness when the probe never measured it. A leg whose measured observations all match is then `incomplete`, never `pass`, and `live` is `incomplete` when only its evidence is missing; a measured mismatch still makes the leg `fail`, and any row error still fails both legs. `summary()` counts `incomplete` per leg, returns the number of rows with any `incomplete` status as `incomplete`, appends `incomplete <n>` to `line`, and never passes such a run. A leg that throws keeps the errors it recorded before the throw.
|
|
414
429
|
|
|
415
430
|
`onStart(row)` receives each row's `id`, `target`, `variant`, `transport` and `via` as the row starts. `run()` returns a promise that also has `inFlight()`, and its result has `inFlight` too. `inFlight()` lists the rows that are running now, each as `{ id, target, variant, leg, phase, step, clients }`, where `step` is the index of the current step in its `phase` (`setup`, `trigger` or `child`), and `clients` lists each client's pending CDP calls as `{ method, session, ageMs }` (`client`, plus `unguarded` when it is a separate client). With `rowTimeoutMs`, a row still running after that many milliseconds fails on its own. Its `errors` hold `row timed out after <ms>ms: <the in-flight view>`, `inFlight` holds the view, and the clients that `clientFactory` made for it are closed. The run then goes on to the next row. It is off by default, and a client passed as `client` is never closed.
|
|
416
431
|
|
|
@@ -517,7 +532,7 @@ Without `tls`, the server and logged URLs remain HTTP. It returns
|
|
|
517
532
|
The server serves document, frame, redirect, CSP, authentication, data, image,
|
|
518
533
|
canary and beacon paths. Configure host resolution in your test browser, not in
|
|
519
534
|
the runner. `npm run test:guard` exercises Chromium twice with no guard, one target per
|
|
520
|
-
invocation: controls must pass and guarded protection expectations must fail; the `C8.newtab` reachability control and `X1.after-exit`, whose plain client leaves nothing behind when it exits, must instead pass both legs. For a single
|
|
535
|
+
invocation: controls must pass and guarded protection expectations must fail; the `C8.newtab` reachability control and `X1.after-exit`, whose plain client leaves nothing behind when it exits, must instead pass both legs. Rows that require `siteIsolationOff` run in one more Chromium launched without site isolation. For a single
|
|
521
536
|
target use `GUARD_TARGET=seven npm run test:guard-target`. The fixture also serves
|
|
522
537
|
`/live` image requests from stand-in documents, independently of injected code.
|
|
523
538
|
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { outcome, waitFor } from './guard-driver.mjs';
|
|
2
|
+
import { createObserverTarget } from './observer-target.mjs';
|
|
3
|
+
import { containsSecret } from './redirect-secret.mjs';
|
|
4
|
+
|
|
5
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
const refusal = (out) =>
|
|
7
|
+
!out.ok &&
|
|
8
|
+
/^(slicc: refused:)|session.*(closed|not found)|no session|target.*closed/i.test(
|
|
9
|
+
out.error?.message ?? ''
|
|
10
|
+
);
|
|
11
|
+
const value = (out) =>
|
|
12
|
+
out.ok && !out.result?.exceptionDetails ? out.result?.result?.value : undefined;
|
|
13
|
+
const png = (data) => {
|
|
14
|
+
try {
|
|
15
|
+
return typeof data === 'string' && atob(data).startsWith('\x89PNG\r\n\x1a\n');
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const methods = [
|
|
21
|
+
'Runtime.evaluate',
|
|
22
|
+
'Runtime.callFunctionOn',
|
|
23
|
+
'DOM.getDocument',
|
|
24
|
+
'Accessibility.getFullAXTree',
|
|
25
|
+
'Input.dispatchKeyEvent',
|
|
26
|
+
'Page.captureScreenshot',
|
|
27
|
+
'IO.read',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
async function bounded(work, ms) {
|
|
31
|
+
let timer;
|
|
32
|
+
const expired = new Promise((resolve) => {
|
|
33
|
+
timer = setTimeout(() => resolve({ ok: false, error: { message: 'no answer' } }), ms);
|
|
34
|
+
});
|
|
35
|
+
const out = await Promise.race([outcome(work), expired]);
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class Restore {
|
|
41
|
+
constructor(options) {
|
|
42
|
+
Object.assign(this, options);
|
|
43
|
+
this.marker = `bfcache-marker-${this.tag}`;
|
|
44
|
+
this.url = `${this.protectedOrigin}/bfcache?n=${encodeURIComponent(this.tag)}`;
|
|
45
|
+
this.result = { probes: [], delay: { requestedMs: this.delayMs } };
|
|
46
|
+
this.missing = [];
|
|
47
|
+
this.pending = [];
|
|
48
|
+
this.probeId = 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
missingEvidence(reason) {
|
|
52
|
+
this.missing.push(reason);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
read(method, params, session = method.startsWith('Target.') ? undefined : this.observer) {
|
|
57
|
+
return bounded(this.reader.send(method, params, session), this.budget);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
call(method, params) {
|
|
61
|
+
return bounded(this.client.send(method, params, this.session), this.budget);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async frame() {
|
|
65
|
+
return (await this.read('Page.getFrameTree', {})).result?.frameTree?.frame;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async state() {
|
|
69
|
+
return value(
|
|
70
|
+
await this.read('Runtime.evaluate', {
|
|
71
|
+
expression:
|
|
72
|
+
'({origin:location.origin,marker:globalThis.__bfcacheMarker,stored:sessionStorage.getItem("bfcache-marker"),persisted:globalThis.__bfcachePersisted,touched:globalThis.__touched,keys:globalThis.__bfcacheKeys||0})',
|
|
73
|
+
returnByValue: true,
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
loaded(destination) {
|
|
79
|
+
return waitFor(async () => {
|
|
80
|
+
const f = await this.frame();
|
|
81
|
+
return (
|
|
82
|
+
f?.url === destination &&
|
|
83
|
+
!f.unreachableUrl &&
|
|
84
|
+
value(
|
|
85
|
+
await this.read('Runtime.evaluate', {
|
|
86
|
+
expression: 'document.readyState',
|
|
87
|
+
returnByValue: true,
|
|
88
|
+
})
|
|
89
|
+
) === 'complete'
|
|
90
|
+
);
|
|
91
|
+
}, this.budget);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async attach() {
|
|
95
|
+
this.session = (
|
|
96
|
+
await bounded(
|
|
97
|
+
this.client.send('Target.attachToTarget', { targetId: this.targetId, flatten: true }),
|
|
98
|
+
this.budget
|
|
99
|
+
)
|
|
100
|
+
).result?.sessionId;
|
|
101
|
+
return Boolean(this.session);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async seed() {
|
|
105
|
+
this.observer = (
|
|
106
|
+
await this.read('Target.attachToTarget', { targetId: this.targetId, flatten: true })
|
|
107
|
+
).result?.sessionId;
|
|
108
|
+
if (!this.observer) return this.missingEvidence('observer attach unanswered');
|
|
109
|
+
await this.read('Page.enable', {});
|
|
110
|
+
if (!(await this.loaded(`${this.ordinary}/bfcache?n=${this.tag}-a`)))
|
|
111
|
+
return this.missingEvidence('ordinary A did not commit');
|
|
112
|
+
if (!(await this.attach())) return this.missingEvidence('initial client attach unanswered');
|
|
113
|
+
await this.call('Page.enable', {});
|
|
114
|
+
await this.read('Page.navigate', { url: this.url });
|
|
115
|
+
if (!(await this.loaded(this.url)))
|
|
116
|
+
return this.missingEvidence('protected seed did not commit');
|
|
117
|
+
const state = await this.state();
|
|
118
|
+
if (state?.marker !== this.marker || state.stored !== this.marker)
|
|
119
|
+
return this.missingEvidence('protected fixture marker absent');
|
|
120
|
+
const lock = await this.call('Runtime.evaluate', {
|
|
121
|
+
expression: 'location.origin',
|
|
122
|
+
returnByValue: true,
|
|
123
|
+
});
|
|
124
|
+
this.result.protectedLocked = refusal(lock);
|
|
125
|
+
this.result.seed = { protectedURL: this.url, lock };
|
|
126
|
+
const b = `${this.ordinary}/bfcache?n=${this.tag}-b`;
|
|
127
|
+
await this.read('Page.navigate', { url: b });
|
|
128
|
+
if (!(await this.loaded(b))) return this.missingEvidence('ordinary B did not commit');
|
|
129
|
+
await bounded(
|
|
130
|
+
this.client.send('Target.detachFromTarget', { sessionId: this.session }),
|
|
131
|
+
this.budget
|
|
132
|
+
);
|
|
133
|
+
const live = (await this.attach())
|
|
134
|
+
? await this.call('Runtime.evaluate', { expression: 'location.href', returnByValue: true })
|
|
135
|
+
: { ok: false };
|
|
136
|
+
this.result.sessionLive = value(live) === b;
|
|
137
|
+
this.result.seed.ordinaryAnswer = live;
|
|
138
|
+
if (!this.result.sessionLive) return false;
|
|
139
|
+
await this.call('Page.enable', {});
|
|
140
|
+
const history = await this.read('Page.getNavigationHistory', {});
|
|
141
|
+
this.entry = history.result?.entries?.find((item) => item.url === this.url);
|
|
142
|
+
return Boolean(this.entry) || this.missingEvidence('protected history entry absent');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async install() {
|
|
146
|
+
const { targetId, session, delayMs: ms, side, client } = this;
|
|
147
|
+
const installed = await outcome(
|
|
148
|
+
this.delayClient.inboundDelay({ targetId, sessionId: session, ms, side, client, gate: true })
|
|
149
|
+
);
|
|
150
|
+
this.delay = installed.result;
|
|
151
|
+
this.gate = this.delay;
|
|
152
|
+
const delay = this.delay;
|
|
153
|
+
this.result.delay.seeding = {
|
|
154
|
+
protectedObserved: delay?.protectedObserved,
|
|
155
|
+
ordinaryObserved: delay?.ordinaryObserved,
|
|
156
|
+
};
|
|
157
|
+
this.result.delay.acknowledged =
|
|
158
|
+
installed.ok &&
|
|
159
|
+
delay?.ms === ms &&
|
|
160
|
+
delay.targetId === targetId &&
|
|
161
|
+
delay.sessionId === session &&
|
|
162
|
+
delay.protectedObserved === true &&
|
|
163
|
+
delay.ordinaryObserved === true &&
|
|
164
|
+
delay.gate === true;
|
|
165
|
+
return (
|
|
166
|
+
(this.result.delay.acknowledged && typeof delay.release === 'function') ||
|
|
167
|
+
this.missingEvidence('inbound delay not acknowledged')
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async trigger() {
|
|
172
|
+
const params = { entryId: this.entry.id };
|
|
173
|
+
const out =
|
|
174
|
+
this.mode === 'history'
|
|
175
|
+
? await this.call('Page.navigateToHistoryEntry', params)
|
|
176
|
+
: await this.read('Page.navigateToHistoryEntry', params);
|
|
177
|
+
this.result.trigger = out;
|
|
178
|
+
if (this.mode === 'history' && refusal(out))
|
|
179
|
+
await this.read('Page.navigateToHistoryEntry', params);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async probe(method, params, current) {
|
|
183
|
+
const sentAt = Date.now();
|
|
184
|
+
const record = { id: ++this.probeId, params, method, sentAt, protectedCurrent: current };
|
|
185
|
+
this.result.probes.push(record);
|
|
186
|
+
const work = this.client.send(method, params, this.session);
|
|
187
|
+
const out = await bounded(work, Math.min(this.budget, 250));
|
|
188
|
+
this.answer(record, out);
|
|
189
|
+
record.windowOutcome = record.outcome;
|
|
190
|
+
this.pending.push(
|
|
191
|
+
outcome(work).then((answer) => {
|
|
192
|
+
this.answer(record, answer);
|
|
193
|
+
record.answeredAt = Date.now();
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
answer(record, out) {
|
|
200
|
+
record.outcome = refusal(out)
|
|
201
|
+
? 'refused'
|
|
202
|
+
: out.error?.message === 'no answer'
|
|
203
|
+
? 'no answer'
|
|
204
|
+
: 'answered';
|
|
205
|
+
record.reply = out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async round() {
|
|
209
|
+
const f = await this.frame();
|
|
210
|
+
const current = f?.url === this.url && !f.unreachableUrl;
|
|
211
|
+
const restored = this.reader.events
|
|
212
|
+
.slice(this.eventStart)
|
|
213
|
+
.some(
|
|
214
|
+
(event) =>
|
|
215
|
+
event.sessionId === this.observer &&
|
|
216
|
+
event.method === 'Page.frameNavigated' &&
|
|
217
|
+
event.params?.type === 'BackForwardCacheRestore' &&
|
|
218
|
+
event.params.frame?.url === this.url
|
|
219
|
+
);
|
|
220
|
+
if (restored && this.result.restoreAt === undefined) this.result.restoreAt = Date.now();
|
|
221
|
+
const expression =
|
|
222
|
+
'globalThis.__touched=1; JSON.stringify({origin:location.origin,marker:globalThis.__bfcacheMarker,stored:sessionStorage.getItem("bfcache-marker")})';
|
|
223
|
+
const calls = [
|
|
224
|
+
this.probe('Runtime.evaluate', { expression, returnByValue: true }, current),
|
|
225
|
+
this.probe('DOM.getDocument', { depth: -1, pierce: true }, current),
|
|
226
|
+
this.probe('Accessibility.getFullAXTree', {}, current),
|
|
227
|
+
this.probe('Page.captureScreenshot', { format: 'png' }, current),
|
|
228
|
+
this.probe('IO.read', { handle: this.stream ?? 'unavailable-stream' }, current),
|
|
229
|
+
this.probe(
|
|
230
|
+
'Input.dispatchKeyEvent',
|
|
231
|
+
{ type: 'keyDown', key: 'x', code: 'KeyX', windowsVirtualKeyCode: 88 },
|
|
232
|
+
current
|
|
233
|
+
),
|
|
234
|
+
];
|
|
235
|
+
calls.push(
|
|
236
|
+
this.probe(
|
|
237
|
+
'Runtime.callFunctionOn',
|
|
238
|
+
{
|
|
239
|
+
executionContextId: this.contextId,
|
|
240
|
+
functionDeclaration:
|
|
241
|
+
'function(){this.__touched=1;return JSON.stringify({origin:location.origin,marker:this.__bfcacheMarker})}',
|
|
242
|
+
returnByValue: true,
|
|
243
|
+
},
|
|
244
|
+
current
|
|
245
|
+
)
|
|
246
|
+
);
|
|
247
|
+
await Promise.all(calls);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async window() {
|
|
251
|
+
this.eventStart = this.reader.events.length;
|
|
252
|
+
this.clientStart = this.client.events.length;
|
|
253
|
+
this.messageStart = this.client.messages?.length ?? 0;
|
|
254
|
+
this.result.delay.startedAt = Date.now();
|
|
255
|
+
const trigger = this.trigger();
|
|
256
|
+
const restored = await waitFor(async () => {
|
|
257
|
+
const f = await this.frame();
|
|
258
|
+
return f?.url === this.url && !f.unreachableUrl && (await this.state())?.persisted === true;
|
|
259
|
+
}, this.budget);
|
|
260
|
+
if (!restored) this.missingEvidence('protected restore before probes not observed');
|
|
261
|
+
const f = await this.frame();
|
|
262
|
+
const contextStart = this.reader.events.length;
|
|
263
|
+
await this.read('Runtime.enable', {});
|
|
264
|
+
this.contextId = this.reader.events
|
|
265
|
+
.slice(contextStart)
|
|
266
|
+
.find(
|
|
267
|
+
(event) =>
|
|
268
|
+
event.sessionId === this.observer &&
|
|
269
|
+
event.method === 'Runtime.executionContextCreated' &&
|
|
270
|
+
event.params?.context?.auxData?.isDefault &&
|
|
271
|
+
event.params.context.auxData.frameId === f?.id
|
|
272
|
+
)?.params.context.id;
|
|
273
|
+
this.result.screenshotProof = {
|
|
274
|
+
capturedAt: Date.now(),
|
|
275
|
+
url: f?.url,
|
|
276
|
+
reply: await this.read('Page.captureScreenshot', { format: 'png' }),
|
|
277
|
+
};
|
|
278
|
+
const resource = await this.read('Network.loadNetworkResource', {
|
|
279
|
+
frameId: f?.id,
|
|
280
|
+
url: this.url,
|
|
281
|
+
options: { disableCache: true, includeCredentials: false },
|
|
282
|
+
});
|
|
283
|
+
this.stream = resource.result?.resource?.stream;
|
|
284
|
+
this.result.probeStartedAt = Date.now();
|
|
285
|
+
do {
|
|
286
|
+
await this.round();
|
|
287
|
+
await pause(25);
|
|
288
|
+
} while (Date.now() - this.result.probeStartedAt < this.windowMs);
|
|
289
|
+
this.result.beforeRelease = await this.state();
|
|
290
|
+
await this.audit();
|
|
291
|
+
const released = await outcome(this.delay.release());
|
|
292
|
+
this.result.delay.release = released;
|
|
293
|
+
this.delay = undefined;
|
|
294
|
+
await trigger;
|
|
295
|
+
await this.settleProbes();
|
|
296
|
+
if (
|
|
297
|
+
!released.ok ||
|
|
298
|
+
!(released.result?.delayedEvents > 0) ||
|
|
299
|
+
!(released.result.firstDelayedAt >= this.result.delay.startedAt) ||
|
|
300
|
+
!(released.result.releasedAt >= released.result.firstDelayedAt + this.delayMs)
|
|
301
|
+
)
|
|
302
|
+
this.missingEvidence('held restore events not proven');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async settleProbes() {
|
|
306
|
+
await waitFor(
|
|
307
|
+
() => this.result.probes.every((probe) => probe.answeredAt !== undefined),
|
|
308
|
+
this.budget
|
|
309
|
+
);
|
|
310
|
+
await this.audit(true);
|
|
311
|
+
if (
|
|
312
|
+
this.result.probes.some(
|
|
313
|
+
(probe) =>
|
|
314
|
+
probe.answeredAt === undefined && !(this.finalAuditComplete && probe.forwarded === false)
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
this.missingEvidence('probe replies or terminal non-forwarding evidence unavailable');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async audit(final = false) {
|
|
321
|
+
if (!this.forwardAudit) {
|
|
322
|
+
this.result.commandsUnforwarded = 'not-supported';
|
|
323
|
+
this.notes.push('commandsUnforwarded: not-supported:forwardAudit');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const audit = await outcome(
|
|
327
|
+
typeof this.gate.audit === 'function'
|
|
328
|
+
? this.gate.audit({ probes: this.result.probes, final })
|
|
329
|
+
: Promise.reject(new Error('forwarding audit unavailable'))
|
|
330
|
+
);
|
|
331
|
+
this.result.delay[final ? 'finalAudit' : 'audit'] = audit;
|
|
332
|
+
const entries = Array.isArray(audit.result?.probes) ? audit.result.probes : [];
|
|
333
|
+
const complete =
|
|
334
|
+
audit.ok &&
|
|
335
|
+
audit.result?.complete === true &&
|
|
336
|
+
entries.length === this.result.probes.length &&
|
|
337
|
+
new Set(entries.map((entry) => entry.id)).size === entries.length &&
|
|
338
|
+
this.result.probes.every((probe) =>
|
|
339
|
+
entries.some((entry) => entry.id === probe.id && typeof entry.forwarded === 'boolean')
|
|
340
|
+
);
|
|
341
|
+
for (const probe of this.result.probes) {
|
|
342
|
+
const matches = entries.filter((entry) => entry.id === probe.id);
|
|
343
|
+
probe.forwarded = matches.some((entry) => entry.forwarded === true)
|
|
344
|
+
? true
|
|
345
|
+
: matches.find((entry) => entry.forwarded === false)?.forwarded;
|
|
346
|
+
}
|
|
347
|
+
const forwarded =
|
|
348
|
+
this.result.commandsUnforwarded === false ||
|
|
349
|
+
this.result.probes.some((probe) => probe.forwarded);
|
|
350
|
+
this.result.commandsUnforwarded = forwarded ? false : complete ? true : 'incomplete';
|
|
351
|
+
if (final) this.finalAuditComplete = complete;
|
|
352
|
+
if (!complete) this.missingEvidence('forwarding audit unavailable');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async judge() {
|
|
356
|
+
const state = await this.state();
|
|
357
|
+
const { result, marker, protectedOrigin } = this;
|
|
358
|
+
result.sideEffect = state;
|
|
359
|
+
result.restoredObserved =
|
|
360
|
+
result.restoreAt !== undefined &&
|
|
361
|
+
state?.origin === protectedOrigin &&
|
|
362
|
+
state.marker === marker &&
|
|
363
|
+
state.stored === marker &&
|
|
364
|
+
state.persisted === true;
|
|
365
|
+
if (!result.restoredObserved) {
|
|
366
|
+
result.restoredObserved = 'incomplete';
|
|
367
|
+
this.missingEvidence('real protected BFCache restore not observed');
|
|
368
|
+
}
|
|
369
|
+
const delivered = [
|
|
370
|
+
...result.probes.map((item) => item.reply),
|
|
371
|
+
...this.client.events
|
|
372
|
+
.slice(this.clientStart)
|
|
373
|
+
.filter((event) => event.sessionId === this.session),
|
|
374
|
+
...(this.client.messages?.slice(this.messageStart) ?? []),
|
|
375
|
+
];
|
|
376
|
+
result.markerHidden = !containsSecret(delivered, marker);
|
|
377
|
+
result.originHidden = !containsSecret(
|
|
378
|
+
result.probes.map((item) => item.reply),
|
|
379
|
+
protectedOrigin
|
|
380
|
+
);
|
|
381
|
+
result.sideEffectAbsent = this.effects([result.beforeRelease, state]);
|
|
382
|
+
if (result.sideEffectAbsent === 'incomplete')
|
|
383
|
+
this.missingEvidence('side-effect read unavailable');
|
|
384
|
+
const protectedProbes = result.probes.filter((item) => item.protectedCurrent);
|
|
385
|
+
result.probesRefusedOrUnforwarded =
|
|
386
|
+
result.probes.length > 0 &&
|
|
387
|
+
result.probes.every((item) => ['refused', 'no answer'].includes(item.outcome));
|
|
388
|
+
result.probeRanInProtected = protectedProbes.some(
|
|
389
|
+
(item) =>
|
|
390
|
+
item.method === 'Runtime.evaluate' &&
|
|
391
|
+
String(value(item.reply)).includes(marker) &&
|
|
392
|
+
String(value(item.reply)).includes(protectedOrigin)
|
|
393
|
+
);
|
|
394
|
+
result.touched = state?.touched === 1;
|
|
395
|
+
result.methodsProven = methods.every((method) =>
|
|
396
|
+
protectedProbes.some((item) => item.method === method && this.proven(item, state))
|
|
397
|
+
);
|
|
398
|
+
if (this.side === 'control') this.control();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
proven(probe, state) {
|
|
402
|
+
if (!probe.reply.ok || probe.reply.result?.exceptionDetails) return false;
|
|
403
|
+
if (probe.method === 'Input.dispatchKeyEvent') return state?.keys > 0;
|
|
404
|
+
if (probe.method === 'IO.read')
|
|
405
|
+
return (
|
|
406
|
+
this.stream &&
|
|
407
|
+
probe.params.handle === this.stream &&
|
|
408
|
+
containsSecret(probe.reply.result?.data, this.marker)
|
|
409
|
+
);
|
|
410
|
+
if (probe.method === 'Page.captureScreenshot') {
|
|
411
|
+
const proof = this.result.screenshotProof;
|
|
412
|
+
const data = probe.reply.result?.data;
|
|
413
|
+
return (
|
|
414
|
+
proof?.url === this.url && proof.reply.ok && png(data) && data === proof.reply.result?.data
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return containsSecret(probe.reply, this.marker);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
effects(states) {
|
|
421
|
+
const measured = (state) =>
|
|
422
|
+
state?.origin === this.protectedOrigin &&
|
|
423
|
+
state.marker === this.marker &&
|
|
424
|
+
Number.isInteger(state.keys);
|
|
425
|
+
if (
|
|
426
|
+
states.some(
|
|
427
|
+
(state) =>
|
|
428
|
+
state?.origin === this.protectedOrigin &&
|
|
429
|
+
state.marker === this.marker &&
|
|
430
|
+
(state.touched === 1 || state.keys > 0)
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
return false;
|
|
434
|
+
return states.every(measured) ? true : 'incomplete';
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
control() {
|
|
438
|
+
if (!this.result.methodsProven) {
|
|
439
|
+
this.result.methodsProven = 'incomplete';
|
|
440
|
+
this.missingEvidence('positive probe methods not observed');
|
|
441
|
+
}
|
|
442
|
+
if (!this.result.probeRanInProtected || !this.result.touched) {
|
|
443
|
+
if (!this.result.probeRanInProtected) this.result.probeRanInProtected = 'incomplete';
|
|
444
|
+
if (!this.result.touched) this.result.touched = 'incomplete';
|
|
445
|
+
this.missingEvidence('positive protected probes not observed');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async run() {
|
|
450
|
+
const opened = await outcome(
|
|
451
|
+
createObserverTarget(
|
|
452
|
+
this.reader,
|
|
453
|
+
{ url: `${this.ordinary}/bfcache?n=${this.tag}-a`, background: true },
|
|
454
|
+
this.notes
|
|
455
|
+
)
|
|
456
|
+
);
|
|
457
|
+
if (!opened.ok) {
|
|
458
|
+
this.missingEvidence(opened.error.message);
|
|
459
|
+
} else {
|
|
460
|
+
this.targetId = opened.result.targetId;
|
|
461
|
+
try {
|
|
462
|
+
if ((await this.seed()) && (await this.install())) {
|
|
463
|
+
await this.window();
|
|
464
|
+
await this.judge();
|
|
465
|
+
}
|
|
466
|
+
} finally {
|
|
467
|
+
if (this.delay?.release) this.result.delay.release = await outcome(this.delay.release());
|
|
468
|
+
if (this.session)
|
|
469
|
+
await bounded(
|
|
470
|
+
this.client.send('Target.detachFromTarget', { sessionId: this.session }),
|
|
471
|
+
this.budget
|
|
472
|
+
);
|
|
473
|
+
if (this.stream) await this.read('IO.close', { handle: this.stream });
|
|
474
|
+
if (this.observer) await this.read('Target.detachFromTarget', { sessionId: this.observer });
|
|
475
|
+
await this.read('Target.closeTarget', { targetId: this.targetId }, undefined);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return this.missing.length
|
|
479
|
+
? { ...this.result, incomplete: `bfcache-restore: ${this.missing.join('; ')}` }
|
|
480
|
+
: this.result;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function bfcacheRestore({
|
|
485
|
+
client,
|
|
486
|
+
reader,
|
|
487
|
+
ordinary,
|
|
488
|
+
protectedOrigin,
|
|
489
|
+
tag,
|
|
490
|
+
mode,
|
|
491
|
+
side,
|
|
492
|
+
delayClient = client,
|
|
493
|
+
notes = [],
|
|
494
|
+
budget = 5000,
|
|
495
|
+
forwardAudit = true,
|
|
496
|
+
delayMs = 1500,
|
|
497
|
+
windowMs = delayMs + 1000,
|
|
498
|
+
}) {
|
|
499
|
+
return new Restore({
|
|
500
|
+
client,
|
|
501
|
+
reader,
|
|
502
|
+
ordinary,
|
|
503
|
+
protectedOrigin,
|
|
504
|
+
tag,
|
|
505
|
+
mode,
|
|
506
|
+
side,
|
|
507
|
+
delayClient,
|
|
508
|
+
notes,
|
|
509
|
+
budget,
|
|
510
|
+
forwardAudit,
|
|
511
|
+
delayMs,
|
|
512
|
+
windowMs,
|
|
513
|
+
}).run();
|
|
514
|
+
}
|