@ai-ecoverse/slicc-browsers 1.18.0 → 1.18.1
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 +3 -3
- package/guard-vectors.json +1 -1
- package/package.json +2 -2
- package/protected-error-page.mjs +57 -37
package/README.md
CHANGED
|
@@ -215,7 +215,7 @@ After the observer confirms the persisted protected document, the client repeate
|
|
|
215
215
|
|
|
216
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
217
|
|
|
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,
|
|
218
|
+
`C8.protected-error-page` touches an allowed tab, scripts a protected navigation, and requires the guard to fail its Document request. That navigation stays script-initiated, because a protected `Page.navigate` destination must be refused: the client sends `Page.navigate` to the ordinary `/page?k=deferred&defer=0` trigger, whose own script navigates to the protected URL, passed fully percent-encoded so it never appears in the trigger's URL. No step navigates with client or observer script, so a guard that refuses client `Runtime.evaluate` in a tab whose history holds a protected entry still gets a verdict; a failed or refused client `Page.navigate` leaves the row `incomplete`. 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 (close after the same trigger fails the URL again), 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 on the protected origin and keeps raw discovery values: its `fullURL` must be `true`. `C8.protected-document-restore` seeds a real protected document before attachment, navigates away, then the observer restores it from BFCache with `Page.navigateToHistoryEntry` 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, a plain client with only request-failure injection, and a guard that refuses client `Runtime.evaluate` in a tab whose history holds a protected entry.
|
|
219
219
|
|
|
220
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.
|
|
221
221
|
|
|
@@ -273,7 +273,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
273
273
|
- `/listener.js`: one line registering a window `load` listener, served with `SourceMap: /listener.js.map`;
|
|
274
274
|
- `/data?n=`: `real-<n>`, with CORS;
|
|
275
275
|
- `/script?n=`: a script setting `globalThis.vectorScript` to `real-<n>`;
|
|
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;
|
|
276
|
+
- `/page?k=&to=`: a trigger page whose `go()` sends the page to `to` (`k=href`, `replace`, `popup` or `form`; `deferred` runs `href` itself after `defer` ms, default 3000), and `/canary/<tag>`: a document titled `canary` with the marker;
|
|
277
277
|
- `/nest?n=&kind=` (and any path under `/nest`): the `C4.iframe-preattach` frame, with its `about:blank` or `about:srcdoc` child;
|
|
278
278
|
- `/img`: a 1×1 PNG;
|
|
279
279
|
- `/auth`: Basic `u`:`p`, then `authed`;
|
|
@@ -283,7 +283,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
283
283
|
|
|
284
284
|
`{ "extensionWorld": "hidden" | "select" | "debugger" | "drivable", "as": "world", "requires": ["extensionWorld"] }` runs the C9 compound probe on the current ordinary page. The runner attaches an independent observer to discover actual context ids, unique ids and names, and sends probes through the client session. Discovery matches the expected fixture extension’s exact origin, never the first extension world: `run()`’s optional `extensionWorldId` defaults to shared-web’s harness id `ankmbodmgimjemnojmeddfcdlbocolob`. Multiple unrelated content-script worlds are ignored for fixture selection, while guards must still protect foreign worlds. A custom fixture must provide the same content-script marker contract. Object handles are minted and validated on the unguarded control session, then replayed as guesses on the guarded leg; Chrome object handles and DOM node ids are session-local, so observer handles must never be mistaken for client handles. Numeric ids from the control are also replayed alongside fresh observer-discovered selectors, but numeric ids are local to a tab: a control id may name the next tab’s exposed default context, which must work and return no extension marker. The same-tab hidden world’s actual numeric id and every extension unique-id guess still require a matched refusal. Unknown numeric ids and stale object handles (`not exposed`) need explicit refusals rather than Chrome’s invalid-id errors. `guessesRefused` aggregates only required refusals; `guessesReached` must be false on the guarded leg, and `exposedContextsWork` must remain true. Its result contains boolean evidence consumed by ordinary `result` checks. The unguarded leg validates those selectors against Chrome itself before guarded evidence can pass.
|
|
285
285
|
|
|
286
|
-
`{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no whole-vector capability. Only `eventsRedacted` requires `discovery`: without it the probe does not call `Target.setDiscoverTargets`, that one field is `not-supported` in both legs, and the notes say `not-supported:discovery`; info, list, attach, recovery and lock checks still run. Discovery absence is structural: a guarded transport delivering either `Target.targetCreated` or `Target.targetInfoChanged` during such a row without declaring `discovery` fails the row, even when the event names an ordinary target. `Target.attachedToTarget` remains independently checked by `attachRedacted`. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself. Each of the probe’s waits is bounded at 5000 ms, scaled by `settleScale`. When the probe’s own evidence is missing, it returns `incomplete` with `protected-error-page: <reason>` instead of throwing: the observer target could not be created or attached (an `openerId` included, after cleaning the target up), the observer’s history seed or allowed document did not commit (or its setup failed), the control’s own Fetch failure injection left no error commit, the protected history entry is missing, no real `BackForwardCacheRestore` arrived, or the observer’s sticky failure left no error page. The guarded leg’s missing error commit is not incomplete: it is the guard’s, and stays `live: false`.
|
|
286
|
+
`{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no whole-vector capability. Only `eventsRedacted` requires `discovery`: without it the probe does not call `Target.setDiscoverTargets`, that one field is `not-supported` in both legs, and the notes say `not-supported:discovery`; info, list, attach, recovery and lock checks still run. Discovery absence is structural: a guarded transport delivering either `Target.targetCreated` or `Target.targetInfoChanged` during such a row without declaring `discovery` fails the row, even when the event names an ordinary target. `Target.attachedToTarget` remains independently checked by `attachRedacted`. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself. Each of the probe’s waits is bounded at 5000 ms, scaled by `settleScale`. When the probe’s own evidence is missing, it returns `incomplete` with `protected-error-page: <reason>` instead of throwing: the observer target could not be created or attached (an `openerId` included, after cleaning the target up), the observer’s history seed or allowed document did not commit (or its setup failed), the client’s `Page.navigate` to the trigger failed or was refused, the control’s own Fetch failure injection left no error commit, the protected history entry is missing, no real `BackForwardCacheRestore` arrived, or the observer’s sticky failure left no error page. The guarded leg’s missing error commit is not incomplete: it is the guard’s, and stays `live: false`.
|
|
287
287
|
|
|
288
288
|
`{ "redirectSecret": "headers" | "mix" | "body", "as": "redirect" }` runs a C2 compound probe and owns its Fetch pauses. The client must expose `messages`, an append-only array of every raw received CDP text frame, recorded before parsing; an events-only or reply-only tap fails `rawRecorded`. Whenever a run includes a `C2.redirect-*` row or a `redirectSecret` step, `run()` retains an explicit `fixture.redirectSecret` hook or automatically installs a fetch-only `POST /redirect` bridge. Its base is `fixtureBase`, then `fixture.origin`, then `targets.ordinary`; a browser-page alias or reverse proxy must forward that endpoint to the bundled fixture. The bundled `fixtureServer()` always supplies both its direct hook and HTTP control endpoint, so no custom registration method is needed. Missing, unreachable or non-successful registration is an explicit row/summary error containing `redirectSecret fixture registration required`, never a pass or a skipped observation. Fixture logs must include headers for the cookie check. The guard’s events and raw messages must represent the same delivered stream. The leak scan memoizes each decoded string and yields to the event loop between items, so concurrent rows in the same process are not starved. When the probe’s own unguarded observer setup (attach, `Page.enable` and the seven cookie) fails before the client is used, the probe returns `incomplete` with `redirect-secret: observer setup failed: <reason>` instead of throwing.
|
|
289
289
|
|
package/guard-vectors.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-browsers",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.1",
|
|
4
4
|
"description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"validate": "node scripts/validate.mjs",
|
|
69
69
|
"lint": "slicc-lint && test -z \"$(gofmt -l .)\" && go mod tidy -diff && go vet ./... && golangci-lint run && cargo fmt --check && cargo clippy --all-features --all-targets -- -D warnings",
|
|
70
70
|
"format": "biome check --write . && gofmt -w . && cargo fmt",
|
|
71
|
-
"test": "npm run validate && node --test --test-concurrency=1 test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/release-line.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/guard-redirect-session-end.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-exit-steps.test.mjs test/integration/guard-frame-read.test.mjs test/integration/guard-bfcache.test.mjs test/integration/guard-bfcache-review.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
|
|
71
|
+
"test": "npm run validate && node --test --test-concurrency=1 test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/release-line.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/guard-redirect-session-end.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-exit-steps.test.mjs test/integration/guard-frame-read.test.mjs test/integration/guard-bfcache.test.mjs test/integration/guard-bfcache-review.test.mjs test/integration/guard-error-page-navigation.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
|
|
72
72
|
"test:unit": "mkdir -p coverage/unit && node --experimental-test-coverage --test --test-concurrency=1 --test-reporter=lcov --test-reporter-destination=coverage/unit/lcov.info test/unit/*.test.mjs && go test -race -cover ./...",
|
|
73
73
|
"build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
|
|
74
74
|
"trust": "npx fledgling sync -y --skip-publish",
|
package/protected-error-page.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { outcome, waitFor } from './guard-driver.mjs';
|
|
1
|
+
import { outcome, parseURL, waitFor } from './guard-driver.mjs';
|
|
2
2
|
import { createObserverTarget } from './observer-target.mjs';
|
|
3
3
|
|
|
4
4
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -104,6 +104,43 @@ async function seed(reader, observer, loaded, initial, allowed) {
|
|
|
104
104
|
return out.ok ? out.result : `observer setup failed: ${out.error.message}`;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
const encoded = (url) =>
|
|
108
|
+
Array.from(
|
|
109
|
+
new TextEncoder().encode(url),
|
|
110
|
+
(byte) => `%${byte.toString(16).toUpperCase().padStart(2, '0')}`
|
|
111
|
+
).join('');
|
|
112
|
+
|
|
113
|
+
async function scripted(client, session, ordinary, url) {
|
|
114
|
+
const out = await outcome(
|
|
115
|
+
client.send(
|
|
116
|
+
'Page.navigate',
|
|
117
|
+
{ url: `${ordinary}/page?k=deferred&defer=0&to=${encoded(url)}` },
|
|
118
|
+
session
|
|
119
|
+
)
|
|
120
|
+
);
|
|
121
|
+
return out.ok ? undefined : `client navigation failed: ${out.error.message}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function recover({ client, session, targetId, ordinary, failedURL, start, result, reached }) {
|
|
125
|
+
if (result.blank) {
|
|
126
|
+
const unsent = await scripted(client, session, ordinary, failedURL);
|
|
127
|
+
if (unsent) return unsent;
|
|
128
|
+
result.closeError = await reached();
|
|
129
|
+
}
|
|
130
|
+
result.attached &&= !client.events
|
|
131
|
+
.slice(start)
|
|
132
|
+
.some((e) => e.method === 'Target.detachedFromTarget' && e.params.sessionId === session);
|
|
133
|
+
const close = await outcome(client.send('Target.closeTarget', { targetId }));
|
|
134
|
+
result.close = result.closeError === true && close.result?.success === true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function answer(event, failing, origin) {
|
|
138
|
+
const { requestId, request } = event.params;
|
|
139
|
+
return failing && parseURL(request?.url)?.origin === origin
|
|
140
|
+
? ['Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }]
|
|
141
|
+
: ['Fetch.continueRequest', { requestId }];
|
|
142
|
+
}
|
|
143
|
+
|
|
107
144
|
const secrets = {
|
|
108
145
|
all: (tag) => `/doc/path-secret-${tag}?query-secret=${tag}#fragment-secret-${tag}`,
|
|
109
146
|
query: (tag) => `/doc?query-secret=${tag}`,
|
|
@@ -126,6 +163,7 @@ export async function protectedErrorPage({
|
|
|
126
163
|
}) {
|
|
127
164
|
const initial = `${protectedOrigin}/doc?n=${tag}-history`;
|
|
128
165
|
const failedURL = `${protectedOrigin}${secrets[secret](tag)}`;
|
|
166
|
+
const origin = parseURL(protectedOrigin)?.origin;
|
|
129
167
|
const result = {};
|
|
130
168
|
const until = (check) => waitFor(check, budget);
|
|
131
169
|
const missing = (reason) => ({ ...result, incomplete: `protected-error-page: ${reason}` });
|
|
@@ -147,15 +185,7 @@ export async function protectedErrorPage({
|
|
|
147
185
|
for (const event of reader.events.slice(cursor)) {
|
|
148
186
|
cursor++;
|
|
149
187
|
if (event.method === 'Fetch.requestPaused' && event.sessionId === observer)
|
|
150
|
-
await outcome(
|
|
151
|
-
reader.send(
|
|
152
|
-
failing ? 'Fetch.failRequest' : 'Fetch.continueRequest',
|
|
153
|
-
failing
|
|
154
|
-
? { requestId: event.params.requestId, errorReason: 'BlockedByClient' }
|
|
155
|
-
: { requestId: event.params.requestId },
|
|
156
|
-
observer
|
|
157
|
-
)
|
|
158
|
-
);
|
|
188
|
+
await outcome(reader.send(...answer(event, failing, origin), observer));
|
|
159
189
|
}
|
|
160
190
|
await pause(5);
|
|
161
191
|
}
|
|
@@ -179,11 +209,8 @@ export async function protectedErrorPage({
|
|
|
179
209
|
observer
|
|
180
210
|
);
|
|
181
211
|
}
|
|
182
|
-
await client
|
|
183
|
-
|
|
184
|
-
{ expression: `setTimeout(()=>location.href=${JSON.stringify(failedURL)},0)` },
|
|
185
|
-
session
|
|
186
|
-
);
|
|
212
|
+
const unsent = await scripted(client, session, ordinary, failedURL);
|
|
213
|
+
if (unsent) return missing(unsent);
|
|
187
214
|
let evidence;
|
|
188
215
|
const live = await until(async () => {
|
|
189
216
|
const f = await frame();
|
|
@@ -219,32 +246,25 @@ export async function protectedErrorPage({
|
|
|
219
246
|
.slice(start)
|
|
220
247
|
.some((e) => e.method === 'Target.detachedFromTarget' && e.params.sessionId === session);
|
|
221
248
|
if (mode === 'recover') {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const close = await outcome(client.send('Target.closeTarget', { targetId }));
|
|
234
|
-
result.close = result.closeError === true && close.result?.success === true;
|
|
235
|
-
return result;
|
|
249
|
+
const unclosed = await recover({
|
|
250
|
+
client,
|
|
251
|
+
session,
|
|
252
|
+
targetId,
|
|
253
|
+
ordinary,
|
|
254
|
+
failedURL,
|
|
255
|
+
start,
|
|
256
|
+
result,
|
|
257
|
+
reached: () => until(async () => (await frame()).unreachableUrl === failedURL),
|
|
258
|
+
});
|
|
259
|
+
return unclosed ? missing(unclosed) : result;
|
|
236
260
|
}
|
|
237
261
|
if (side === 'control') await reader.send('Fetch.disable', {}, observer);
|
|
238
262
|
failing = false;
|
|
239
|
-
const { entries
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
263
|
+
const { entries } = await reader.send('Page.getNavigationHistory', {}, observer);
|
|
264
|
+
const entry = entries.find((e) => e.url === initial);
|
|
265
|
+
if (!entry) return missing('missing protected history');
|
|
242
266
|
const beforeRestore = reader.events.length;
|
|
243
|
-
await reader.send(
|
|
244
|
-
'Runtime.evaluate',
|
|
245
|
-
{ expression: `setTimeout(() => history.go(${index - currentIndex}), 0)` },
|
|
246
|
-
observer
|
|
247
|
-
);
|
|
267
|
+
await outcome(reader.send('Page.navigateToHistoryEntry', { entryId: entry.id }, observer));
|
|
248
268
|
const restored = await until(() =>
|
|
249
269
|
reader.events
|
|
250
270
|
.slice(beforeRestore)
|