diff --git a/SECURITY.md b/SECURITY.md index b9966972..c51fb1f5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -68,6 +68,9 @@ infrastructure we do not operate. - [packages/runtime/TELEMETRY.md](packages/runtime/TELEMETRY.md) and [PRIVACY.md](PRIVACY.md): what leaves the page. - [Verifying a published tarball](#verifying-a-published-tarball) (below): how to check that a published tarball was built from this repository. +- Advisories: [docs/security/](docs/security/). Latest: [2026-10, a link could raise the mode](docs/security/2026-10-mode-raise.md), + fixed in `0.3.0-beta.2` (`0.2.3` pending); the four further findings from the review of that fix (route look-alikes, + an invalid `mode`, a stale sample bucket, aggressiveness level names) are fixed in `0.2.3` and `0.3.0-beta.5` (both pending). Recommended for production: keep the default `observe` mode until you have looked at what GenClass reports for your app; set `mode` explicitly (a URL parameter can then only lower it); keep payment, checkout and sign-in endpoints diff --git a/docs/runtime/API.md b/docs/runtime/API.md index 470e5c60..3822759e 100644 --- a/docs/runtime/API.md +++ b/docs/runtime/API.md @@ -91,7 +91,7 @@ and `0.3.0-beta.0` have no analytics and ignore the option silently: install `@g ```ts interface InitOptions { - mode?: "observe" | "guard" | "heal"; // default "observe"; "guard" is opt-in, "heal" experimental + mode?: "observe" | "guard" | "heal"; // default "observe"; "guard" is opt-in, "heal" experimental; any other value runs as observe, with a warning model?: { // or false: no model (observe only) baseUrl?: string; // directory with model.json (default: jsDelivr CDN) device?: "auto" | "webgpu" | "wasm"; // default "auto" @@ -143,7 +143,10 @@ interface InitOptions { // [true]; false: nothing installed, model never downloaded; a predicate/source forces preload "lazy" sample?: number; // [1] fraction of sessions that act (stable per session); the rest observe routes?: { match: string | RegExp | ((route: string) => boolean); mode?: Mode | "off"; aggressiveness?: Aggressiveness }[]; - // first match wins; demote only + // first match wins; demote only. Matched against the raw pathname and its normalised + // form (percent-decoded, no ";" parameters, no trailing or repeated slashes), so + // "/se%61rch" and "/search/" fall under "/search"; case-sensitive; "/admin/*" does + // not cover "/admin" itself (use "/admin*" or a RegExp) requests?: { ignore?: RequestMatcher[]; // not observed at all (native pass-through) protect?: (RequestMatcher | `preset:${string}`)[]; // observed, never held/retried/cached/discarded, nor what its response causes; diff --git a/docs/runtime/OPTIONS-SPEC.md b/docs/runtime/OPTIONS-SPEC.md index cbba8aeb..850eac4f 100644 --- a/docs/runtime/OPTIONS-SPEC.md +++ b/docs/runtime/OPTIONS-SPEC.md @@ -211,7 +211,8 @@ Runtime API: - Results go in an LRU cache keyed by `method+" "+url`, 256 entries. Matchers are compiled once, in the `runtime.ts` constructor. - **`OpRec.scope`**: `{ mode, aggressiveness, protected, crossOrigin, label?, route?, correlationId? }`, set in `NetHost.startOp`. - **`runtime.ts computeScope()`** runs on init, on nav, on `setMode`/`setAggressiveness`, on a breaker trip or reset, and when `enabled` changes. It stores `this.scope`, which is snapshotted per op or mutation. -- **URL overrides** (`?genclass-mode`, `?genclass-aggr`, `?genclass-sample=0|1`) may only demote. With `debug:true` they may also raise the value, for QA. `?genclass-sample=1` under debug forces the session into the act cohort. +- **`mode`** is validated in the `runtime.ts` constructor: any value but `observe`, `guard` or `heal` (from `createRuntime`, `GenClass.init` or `window.GENCLASS_CONFIG`, which `cdn/config.ts readWindowConfig` checks too, as `fromPairs` already did for the meta tag and the data attributes) runs as `observe`, with one warning (since 0.2.3 and 0.3.0-beta.5; before, an unknown value such as `"HEAL"` fell through `minMode` and the gate's `modeAllows` and ran as heal, and `?genclass-mode=observe` could not lower it). `modeAllows` permits a non-passive tier only for `guard` and `heal`. +- **URL overrides** (`?genclass-mode`, `?genclass-aggr`, `?genclass-sample=0`) may only demote, on every host, with or without `debug:true` (debug is console logging only). `?genclass-sample=1` has no effect: no URL value puts a session in the act cohort. Before 0.2.3 and 0.3.0-beta.2, `debug:true` let them raise the value and `?genclass-sample=1` under debug forced the act cohort; a link could then raise any site that left debug on in production. The page's own `?genclass=` / `localStorage.genclass` (src/index.ts) may raise the mode only on a development host (src/devhost.ts: http(s) on a loopback name with an explicit port, or `*.test`). For QA on a staging or production host, set `mode` / `sample` per environment, call `rt.setMode()`, or use the devtools overlay. ## 4. Options @@ -239,7 +240,7 @@ Runtime API: ### 4.2 `sample` **Type** `number` in [0,1], clamped with a warning. **Default** `1`. -**Semantics.** Sessions outside the fraction get an `observe` cap. The model still loads, so they form the comparison group. The bucket is **decided once at init** as `hash(session.id)/2^32 < sample`, then frozen and stored in `sessionStorage["genclass:bucket"]` (wrapped in try/catch). A later `setSession({id})` does not change it. An app that passes a stable user id gets the same bucket in every tab, which is the intended behaviour. `?genclass-sample=0` always forces observe, and `=1` only works under debug. +**Semantics.** Sessions outside the fraction get an `observe` cap. The model still loads, so they form the comparison group. The bucket is **decided once at init** as `hash(session.id)/2^32 < sample`, then frozen and stored in `sessionStorage["genclass:bucket"]` as `act@` or `observe@` (wrapped in try/catch). A stored verdict is reused only under the `sample` it was drawn under, so a deploy that changes `sample` draws again, and `sample: 0` (like `1`) never reads or writes it (since 0.2.3 and 0.3.0-beta.5; before, a stored `act` overrode `sample: 0` until the tab closed). A later `setSession({id})` does not change it. An app that passes a stable user id gets the same bucket in every tab, which is the intended behaviour. `?genclass-sample=0` always forces observe, and `=1` has no effect (since 0.2.3 and 0.3.0-beta.2; before, it forced the act cohort under debug). **Precedence.** It is one term of the min. `setMode` cannot lift it. @@ -249,12 +250,15 @@ Runtime API: - Reload → same bucket. - Storage throws → deterministic within the page. - `setSession({id})` → bucket unchanged. +- `sample:0` with a stored act verdict → observe, storage untouched. +- A verdict stored under another `sample` (or in the bare pre-0.2.3 form) → drawn again. ### 4.3 `routes` **Type** `RouteRule[]`. **Default** `[]`. **Semantics.** - Rules are evaluated at init and on every `installNav` callback. The first match wins. A rule can only demote, and a rule that asks for a higher mode is clamped, with one warning. +- A string rule is an exact path or a glob, matched against the raw `location.pathname` **and** against its normalised form (percent-decoded, `;` path parameters dropped, dot segments and repeated slashes resolved, no trailing slash except `/`; `util/match.ts normalizeRoute`), so `/se%61rch`, `/search/`, `//search` and `/search;x=1` fall under a `/search` rule, as they reach the same page on most servers (since 0.2.3 and 0.3.0-beta.5; before, such a link escaped the rule and got the global mode). RegExp and predicate rules see both forms. Matching is case-sensitive (`/Search` is not `/search`; use a RegExp with the `i` flag), and `/admin/*` does not cover `/admin` itself (use `/admin*` or a RegExp). `status.scope.route` stays the raw path. - `mode:"off"` means ops created on that route are pass-through, though they are still recorded in the timeline. - Ops created under `off` or `observe` scope **are never the target of an action** later, even from another route (gate step 3). - The scope is snapshotted per op, so a write held under heal on route A is decided under heal after navigating to route B. @@ -268,6 +272,7 @@ Runtime API: - An op held on route A is decided with A's scope. - An op from an `off` route is not offered as a target from route B. - RegExp, glob and predicate rules all work, and a throwing predicate is skipped. +- `/se%61rch`, `/search/`, `//search` and `/search;x=1` fall under a `/search` rule; `/Search` does not; `/admin/*` does not cover `/admin`. ### 4.4 `requests` **Type** `RequestScope`. **Default** `{ ignore:[], protect:[], crossOrigin:"observe", labels:[], labelsToModel:false }`. diff --git a/docs/runtime/RESULTS.md b/docs/runtime/RESULTS.md index ab5a033f..1bb537f0 100644 --- a/docs/runtime/RESULTS.md +++ b/docs/runtime/RESULTS.md @@ -400,6 +400,9 @@ walk, which missed the two shared chunks (2.74 + 0.19 KB). | limit in `bundle.test.ts` (measured at `bff4a71` + 1.5 KB, owner-approved for 0.3.0; not re-set: the release candidate was 0.13 / 0.10 KB under it, and with the vendor-name warning `/auto` is 0.03 KB under it) | 107.74 KB | 99.71 KB | none | | + measured visual checks' first-load stub: wave2/visual `6ae209f` merged (`release/0.3.0-visual`, merge `2c7014f`, PR head `be8cb7c`, run 38676978903; §13) | **108.25 KB** (102.28 + 3.05 + 2.74 + 0.19) | **100.13 KB** (94.15 + 3.05 + 2.74 + 0.19) | **128.56 KB** (376.5 KB minified) | | limit in `bundle.test.ts` for 0.3.0-beta.0, **owner-approved 2026-10-15** (the measured sizes + 0.05 / 0.07 KB) | 108.30 KB | 100.20 KB | none | +| + interaction analytics' first-load stub (§15; limits owner-approved 2026-10-18) | 108.53 KB | 100.40 KB | not measured | +| + review of the mode-raise fix: `normalizeRoute`, the `mode` validation and its warning, the sample bucket's `act@` form, `readWindowConfig`'s mode check (branch `security/advisory-mode-raise`, 0.3.0-beta.5; local, 2026-10-27, `test/first-load-size.mjs` and gzip -9 against main `49c8c03`'s dist: 108.36 / 100.28 / 128.55 KB) | **108.63 KB** (+0.27) | **100.46 KB** (+0.17) | **128.83 KB** (387.4 KB minified, +0.28) | +| limit in `bundle.test.ts` for 0.3.0-beta.5, owner OK 2026-10-28 (`/auto`: CI's Node 22 gzip measures 108.83 KB where Node 25 measures 108.63 KB, so the limit follows CI plus 0.07 KB; the main entry keeps its limit, 0.02 KB under it) | 108.90 KB | 100.48 KB | none | Removing the vendor adapters changes no first load (they were lazy chunks of `/auto` and files the script tag loaded from the CDN). The one warning for the names `sentry`, `posthog` and `datadog` (`src/cdn/sinks.ts` `applySinks`: the diff --git a/docs/runtime/THREAT-MODEL.md b/docs/runtime/THREAT-MODEL.md index bd858b77..b035adbf 100644 --- a/docs/runtime/THREAT-MODEL.md +++ b/docs/runtime/THREAT-MODEL.md @@ -235,11 +235,14 @@ flows. **Threat.** Someone who can make a user open a link, or inject markup, changes GenClass's configuration. **Mitigations.** `?genclass-mode`, `?genclass-aggr` and `?genclass-sample=0` can only lower the mode, aggressiveness or -sampling unless the app sets `debug: true`; `?genclass=off` turns GenClass off for that visitor only -([runtime.ts](../../packages/runtime/src/runtime.ts) constructor, [index.ts](../../packages/runtime/src/index.ts) -`initUnsafe`). Since 0.2.2 `?genclass=` and `localStorage.genclass` also only demote, from the app's mode or from -`observe` when it sets none: a crafted link can no longer raise a production site to guard or heal. Raising from the -URL needs `debug: true` or a development host (`localhost`, `*.local`, `*.test`, private networks). With the zero-code +sampling, on every host, with or without `debug: true` (`debug` is console logging only); `?genclass=off` turns +GenClass off for that visitor only ([runtime.ts](../../packages/runtime/src/runtime.ts) constructor, +[index.ts](../../packages/runtime/src/index.ts) `initUnsafe`). Since 0.2.2 `?genclass=` and `localStorage.genclass` +also only demote, from the app's mode or from `observe` when it sets none: a crafted link can no longer raise a +production site to guard or heal. Raising from the URL needs a development host +([devhost.ts](../../packages/runtime/src/devhost.ts): http(s) on `localhost`, `127.0.0.1`, `[::1]` or `*.localhost` +with an explicit port, or `*.test`); before 0.3.0-beta.2 and 0.2.3, `debug: true` or a host that only looked local +(`*.local`, an intranet name, a private address, Capacitor, Tauri, Electron) let a link raise the mode. With the zero-code entries (`/auto`, the script tag), only `` tags inside `` are read, so markup injected into the body cannot configure GenClass, and the model, onnxruntime-web and webhook sink URLs a meta tag names must be same-origin (cross-origin ones are ignored with a warning; [cdn/config.ts](../../packages/runtime/src/cdn/config.ts) @@ -250,11 +253,26 @@ absolute once, at init ([cdn/sinks.ts](../../packages/runtime/src/cdn/sinks.ts)) move it. An injected `sink=webhook:` therefore cannot send the sink records (and their evidence text) to another origin. The other sink name, `console-json`, only prints to the page's own console. The runtime has no Sentry, PostHog or Datadog adapter, so an injected `sink=sentry` loads and sends nothing: it only prints one console warning. +Route rules (`routes[]`, OPTIONS-SPEC §4.3) apply to the normalised path as well as the raw one, so a link to a +look-alike path (`/se%61rch`, `/search/`, `//search`, `/search;x=1`) cannot step out of a rule that scopes `/search` +to `off` or `observe` (since 0.2.3 and 0.3.0-beta.5; a rule only lowers the mode, so the escape gave the visitor the +app's global mode, never more; [util/match.ts](../../packages/runtime/src/util/match.ts) `normalizeRoute`). An invalid +`mode` value (`"HEAL"`, an object, from `GenClass.init` or `window.GENCLASS_CONFIG`) runs as `observe` with a warning +instead of falling through the gate as heal (since 0.2.3 and 0.3.0-beta.5; app configuration only, never a URL). The +sampling bucket in `sessionStorage` is reused only under the `sample` it was drawn under, and `sample: 0` never reads +it, so a stale or injected `act` verdict cannot lift a sampled-out session (since 0.2.3 and 0.3.0-beta.5). **Residual.** Markup injected into `` can still set the mode and same-origin sinks (not a cross-origin model or webhook): pages that let untrusted HTML into `` should configure through `window.GENCLASS_CONFIG` (it wins) or -`GenClass.init(options)`. A same-origin endpoint that forwards elsewhere is the site's own. An app that sets -`debug: true` in production lets the URL raise its mode. +`GenClass.init(options)`. A same-origin endpoint that forwards elsewhere is the site's own. A production app served +from a loopback name with an explicit port (an Electron app with an embedded server, a self-hosted local tool on +`http://127.0.0.1:`) counts as a development host, so a crafted link can raise its mode with `?genclass=`; such +apps should not rely on the URL switch being demote-only. `devtools: "local"` (and `data-devtools="local"`) mounts the +overlay on every host [cdn/config.ts](../../packages/runtime/src/cdn/config.ts) `isLocalHost` counts as local +(`file:`, loopback with or without a port, `*.localhost`, `*.local`, `*.test`), which is wider than the development-host +test above: a Capacitor, Tauri or Electron build that ships it gets the overlay, and whoever is at the device can open +it (Alt+Shift+G) and raise the mode for that session, as any script in the origin can (T1). Not reachable from a link. +Set `devtools` per environment and do not ship `"local"` in such builds. ## T9. Cached and shared responses diff --git a/docs/security/2026-10-mode-raise.md b/docs/security/2026-10-mode-raise.md new file mode 100644 index 00000000..0ac43e63 --- /dev/null +++ b/docs/security/2026-10-mode-raise.md @@ -0,0 +1,429 @@ +DRAFT for the owner to publish through GitHub's security advisory form. Do not publish this file as is. + + + +# `@genclass/runtime`: a link could raise GenClass from observe to guard or heal on sites with `debug: true` and on hosts that only look local + +| field | value | +|---|---| +| Ecosystem | npm | +| Package | `@genclass/runtime` | +| Affected versions | `>= 0.1.0-alpha.0, <= 0.2.2` (every 0.1.x prerelease, 0.2.0, 0.2.1, 0.2.2, npm's `latest`); `>= 0.3.0-beta.0, <= 0.3.0-beta.1` | +| Patched versions | `0.3.0-beta.2` (npm dist-tag `beta`, 2026-10-25); `0.2.3` on dist-tag `latest` (pending, not published at the time of this draft) | +| Not affected | `0.0.0-stage` (a placeholder package with no code), `0.3.0-beta.2` and later (for the review findings F1 to F4 below: `0.2.3`, `0.3.0-beta.5` and later) | +| Review findings | F1 to F4 under "Further findings from the review of the fix" (2026-10-27): patched in `0.2.3` (tagged `v0.2.3`, npm pending) and `0.3.0-beta.5` (pending). F1 is reachable from a link and bounded by the app's own global mode; F2 and F3 need the app's configuration or a script in the origin; F4 is hardening | +| Severity | Medium, CVSS 3.1 5.4 (`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:L`), see "Severity" | +| CWE | CWE-15 (External Control of System or Configuration Setting); secondary CWE-489 (Active Debug Code) for the `debug` bypass | +| Credits | the GenClass maintainers (found while testing 0.3.0-beta.0 in a pilot app, 2026-10-17) | +| CVE | none requested yet (owner: tick "Request CVE" in the form if wanted) | + +## Summary + +`@genclass/runtime` runs inside a web app in one of three modes: `observe` (the default, report only), `guard` (opt-in: +holds and small reversible actions at the network boundary) and `heal` (experimental: blocking, cached and retried +requests, store rollbacks). The URL switch `?genclass=observe|guard|heal` and `localStorage.genclass` exist so a +developer can try another mode on one page load. In the affected versions that switch could also *raise* the mode on a +production site, so a crafted link opened by a visitor turned guard-tier or heal-tier actions on for that visitor's page +load, in two situations: + +1. the app had `debug: true` set (documented as console logging only; from `GenClass.init()`, `window.GENCLASS_CONFIG`, + the options file `init` writes, `` or `data-debug` on the script tag); then + `?genclass=guard|heal`, `localStorage.genclass`, `?genclass-mode=`, `?genclass-aggr=` and `?genclass-sample=1` + could raise the mode, the aggressiveness and the acting cohort on any host, and `?genclass-mode=` could even undo a + lowering `?genclass=observe`; +2. the page's host passed telemetry's broad "local host" test (0.2.2 to 0.3.0-beta.1): intranet bare names, private + addresses, `*.internal`, `*.local`, `*.home.arpa`, Capacitor (`https://localhost`), Tauri (`tauri.localhost`) and + Electron (`file:`); then `?genclass=guard|heal` raised the mode without `debug`. + +Versions before 0.2.2 were wider open: 0.1.0-alpha.0 to 0.1.0-beta.0 applied the switch on every host with no condition, +and 0.1.0-beta.2 to 0.2.1 let `?genclass=guard` raise a site that set no `mode` (the switch compared against a `guard` +baseline while the runtime defaulted to `observe`). + +Since 0.3.0-beta.2, `debug` is console logging only; `?genclass-mode`, `?genclass-aggr` and `?genclass-sample` only +lower, on every host; `?genclass=` and `localStorage.genclass` may raise the mode only on a narrow development host +(`src/devhost.ts`: http(s) on `localhost`, `127.0.0.1`, `[::1]` or `*.localhost` with an explicit port, or a `*.test` +name). Lowering (`?genclass=off`, `?genclass=observe`) keeps working everywhere, and telemetry's own local-host rule and +opt-outs are unchanged. No model-visible text changed. + +## Details + +### Mechanism + +`GenClass.init()` (also behind the zero-code entries `@genclass/runtime/auto` and the script tag) reads one value from +`location.search` (`genclass`) or, when the URL has none, from `localStorage.genclass` +(`packages/runtime/src/index.ts`, `killSwitch`). `off` installs nothing. `observe`, `guard` or `heal` becomes the mode +when a rule allows it. The rule, per version: + +- 0.1.0-alpha.0 to 0.1.0-beta.0: no rule; the value is the mode (`index.ts:128`, `:128`, `:142`). +- 0.1.0-beta.1 to 0.2.1: `options.debug || rank(value) <= rank(options.mode ?? "guard")` (`index.ts:251`, `:265`, + `:282`, `:292`, `:292`). The `?? "guard"` baseline was correct only in 0.1.0-beta.1, whose runtime also defaulted to + guard; from 0.1.0-beta.2 the runtime defaults to observe (`runtime.ts:310` and later), so for an app that set no + `mode` the comparison allowed `guard` (rank 1 is not above the baseline's rank 1) and the page went from observe to + guard on any host, no `debug` needed. `heal` still needed `debug`. +- 0.2.2 to 0.3.0-beta.1: `options.debug || isLocalHost(location.hostname) || rank(value) <= rank(options.mode ?? "observe")` + (`index.ts:310`, `:345`, `:348`). The baseline is right, but two bypasses stay: `debug`, and `isLocalHost`, which is + telemetry's rule for "do not send from here" (`packages/runtime/src/telemetry/config.ts:26-38`): it accepts an empty + hostname (`file:` pages), `localhost`, `0.0.0.0`, `::1`, `*.localhost`, `*.local`, `*.test`, `*.internal`, + `*.home.arpa`, the private and link-local IPv4 ranges (10/8, 127/8, 0/8, 192.168/16, 172.16/12, 169.254/16, + 100.64/10), IPv6 `fc`/`fd`/`fe80:` addresses and any bare name without a dot. A false "local" there only sends less; + used for the mode switch it let a link raise every app that ships its UI from such a host. + +Independently, `createRuntime()` (reached by `GenClass.init()` too) reads `?genclass-mode=`, `?genclass-aggr=` and +`?genclass-sample=` (`packages/runtime/src/runtime.ts`). From 0.1.0-beta.1 to 0.3.0-beta.1 each had a `debug` bypass: +`if (this.debug || MODE_RANK[um] <= MODE_RANK[this._mode])`, `if (this.debug || v <= this._aggr)` and +`if (urlSample === "1" && this.debug) return true` (0.2.2: `runtime.ts:370`, `:381`, `:574`). With `debug` on, +`?genclass-mode=heal` raised the mode after `index.ts` had applied the `?genclass=` switch, so +`?genclass=observe&genclass-mode=heal` on an app in heal mode ended in heal, not observe; `?genclass-aggr=eager` raised +the aggressiveness (lower gate thresholds); `?genclass-sample=1` put a session the app's `sample` had placed in the +observe cohort into the acting cohort. + +The fix (`12f4a5c` in PR #64, ported to main as `5a6b48f` / merge `c2379a7`, released in 0.3.0-beta.2): `index.ts` +drops the `debug` term and replaces `isLocalHost` with `devHost` from the new `src/devhost.ts` (http(s) on +`localhost`, `127.0.0.1`, `[::1]` or `*.localhost` with an explicit port, or `*.test`; never Capacitor, Tauri, Electron, +intranet names, private addresses, `*.internal` or `*.local`); `runtime.ts` makes the three other switches lower-only on +every host and `?genclass-sample=1` a no-op. `test/mode-switch.test.ts` pins every case above, including the +look-alike hosts. The published 0.3.0-beta.2 tarball carries the new predicate and neither `debug` pattern (checked in +`dist/`); the published 0.2.2 tarball carries both (`dist/chunk-7ODWDTWV.js`, `dist/genclass.global.js`, +`dist/genclass.global.min.js`). + +### Exact conditions + +All of the following must hold for a link to raise a visitor's page load: + +- the page runs an affected version through `GenClass.init()`, `@genclass/runtime/auto` (or `/auto/observe`), or the + script-tag build (`createRuntime()` alone is reached by the `?genclass-mode` / `-aggr` / `-sample` part only); +- the visitor opens a URL whose query string the attacker chose (`?genclass=guard`, `?genclass=heal`, or with `debug` + also `?genclass-mode=`, `?genclass-aggr=`, `?genclass-sample=1`), or the attacker can set `localStorage.genclass` in + the page's origin (which needs script execution there and is out of scope: such a script can call + `GenClass.runtime.setMode("heal")` directly, THREAT-MODEL T1); +- and one of: the app set `debug: true` by any route (0.1.0-beta.1 to 0.3.0-beta.1); the page's host passes + `isLocalHost` (0.2.2 to 0.3.0-beta.1); the app set no `mode` and the value is `guard` (0.1.0-beta.2 to 0.2.1); or the + version is 0.1.0-alpha.0 to 0.1.0-beta.0 (no condition); +- a model is loaded (the default; `model: false` means the runtime only observes whatever the mode says), the page is + visible, and the policy gate clears a model decision for a subject that is not protected, cross-origin or in an + observe-scoped route. + +The switch is read once, when GenClass starts (a page load). Nothing is written back: no version stores a +`?genclass=` value in `localStorage` or `sessionStorage` (`killSwitch` only reads; the only `genclass`-named writes are +`genclass:bucket`, the sampling bucket, which the `?genclass-sample=1` branch returns before reaching, `genclass:learn`, +the breaker's key and, since 0.3.0-beta.2, `genclass.analytics.`). So one click raises that page load only; a +later page load without the parameter is back at the app's configuration. In a single-page app the raised mode lasts +while the page load lasts, however many client-side routes it visits. + +### What the attacker controls, and what not + +The attacker controls the query string of a link and thereby which tier is enabled (guard or heal), the aggressiveness +profile and the sampling cohort for one visitor's page load. The attacker does not control what then happens: every +action is chosen by the local model from the fixed list the runtime offers for one subject, must pass the policy gate +(`src/decide/policy.ts`), and runs only when the top diagnosis is not `expected`. The attacker cannot name an action, +a request or a store, cannot see the page, and gets nothing back; telemetry sends the same redacted diagnostics it +sends in every mode (with a `decision` event per action, no page text), and the attacker does not receive it. + +### What guard and heal may do to the page (`packages/runtime/INTERCEPTION.md`, "What each mode may change" and "Actions") + +observe never holds, delays, drops, retries, replays or answers anything and never writes app state. A raised page +load may instead see: + +- **guard tier**: requests and responses held for the model's decision, each within the hold budget ("auto" = 1.5 x + the model's median latency, clamped to 150 to 800 ms; fail open when the model is late); `coalesce` (an identical + request in flight or finished up to 2 s ago is not sent again, the app gets that response, any method including + POST); `delay` (a request sent after 250 ms x 2^(recent failure streak), at most 8 s); `discard` and `defer` of held + store writes and of a response's writes over newer data (registered stores only; reversible). +- **heal tier** adds: `block` (the request is not sent and answered 503 with `x-genclass: blocked`); `serve_cached` + (a GET is answered from the last good response of this page's memory, up to 256 KB); `retry` (an idempotent or + idempotency-keyed request sent again, up to 4 attempts); `hedge` (a second identical GET); `rollback` and `resync` + of registered stores to the last consistent snapshot. With `visual: { on: true, fix: true }` configured, the three + measured visual fixes (one colour rule, removing a leaked inline `pointer-events: none` or `overflow: hidden`) also + need guard or heal and so become reachable. + +Bounds that hold in every mode and limit the impact: a request's URL, method, headers and body are never changed; +cookies are never read or written; a non-idempotent request is never sent twice without an idempotency key; a cached +response is served only to an identical GET; requests under `requests.protect` (the payments and auth presets, when +configured), cross-origin requests and observe-scoped routes are never acted on; discovered React, Redux or Zustand +state is never written; every action is recorded with an undo where one exists, two undos in 10 minutes or three errors +after actions trip the breaker back to observe for the session, and `rt.disable()` restores the page. Impact is +therefore limited to that visitor's page load: added latency, a request answered locally (coalesced, cached, blocked +with a 503) or retried, and reversible writes to stores the app registered. + +## Affected versions + +| npm version | published (npm) | affected | why | +|---|---|---|---| +| 0.0.0-stage | 2026-10-08 | no | a 367-byte placeholder tarball (`package.json` with `"stub": true` and a README); no code | +| 0.1.0-alpha.0, 0.1.0-alpha.1, 0.1.0-beta.0 | 2026-10-08 | yes, worst | `?genclass=` sets the mode on every host, no condition (unsupported line) | +| 0.1.0-beta.1 | 2026-10-08 | yes | raise needs `debug: true` (default mode was guard); `-mode` / `-aggr` / `-sample` debug bypasses | +| 0.1.0-beta.2, 0.1.0-beta.3, 0.2.0, 0.2.1 | 2026-10-08 to 2026-10-11 | yes | `?genclass=guard` raises an app that set no `mode` on every host; `heal` and the other switches need `debug` | +| 0.2.2 (`latest`) | 2026-10-15 | yes | raise needs `debug: true` or a host `isLocalHost` accepts; `-mode` / `-aggr` / `-sample` debug bypasses | +| 0.3.0-beta.0, 0.3.0-beta.1 | 2026-10-15, 2026-10-24 | yes | same as 0.2.2 | +| 0.3.0-beta.2, 0.3.0-beta.3, 0.3.0-beta.4 (`beta`) | 2026-10-25 to 2026-10-27 | no | fixed | + +GHSA ranges: `>= 0.1.0-alpha.0, <= 0.2.2` and `>= 0.3.0-beta.0, < 0.3.0-beta.2`. Every version on npm except +`0.0.0-stage` has a matching `v*` tag in this repository; there is no other npm-only version. + +## Patched versions + +- `0.3.0-beta.2` (npm dist-tag `beta`), published 2026-10-25; `0.3.0-beta.3` and `0.3.0-beta.4` carry the fix. + `npm install @genclass/runtime@beta`; script tags: pin `@0.3.0-beta.4` (or the newest beta). +- `0.2.3` (npm dist-tag `latest`): tagged `v0.2.3` (commit `8694211`, 2026-10-27, on origin), not on npm at the time + of this draft (`latest` is still `0.2.2`). Until it is published, a project on the stable line should either move to + `@beta` or apply the workarounds below. (owner: `release.yml` publishes from the `v0.2.3` tag; then fill the form's + patched version for the `<= 0.2.2` range; until then the form can state "no patched version on the 0.2 line yet".) + `@beta` (`0.3.0-beta.4`) carries the headline fix but not F1 to F4 until `0.3.0-beta.5`. + +Where the fix lives: `packages/runtime/src/devhost.ts` (new), `src/index.ts` (`initUnsafe`), `src/runtime.ts` +(constructor and `sampleBucket`), `src/visual/start.ts` (re-exports `devHost`), `test/mode-switch.test.ts` (new) and +three flipped tests (`test/default-mode.test.ts`, `test/gates.test.ts`, `test/report.test.ts`). + +## Version-by-version evidence + +Paths are under `packages/runtime/src/` at the named tag (`git show :packages/runtime/src/`). "public + +debug" is a public host with `debug: true`; "look-alike" is a host telemetry's `isLocalHost` accepts +(`telemetry/config.ts`) without `debug`; "no mode" is an app that set no `mode`. "persisted" asks whether a +`?genclass=` value is ever written to storage. + +| version (tag) | `?genclass=` / `localStorage.genclass` raise: public + debug | look-alike host, no debug | public, no debug, no mode | `?genclass-mode` / `-aggr` / `-sample=1` debug bypass | persisted | script tag `data-debug` / meta `debug` | evidence | +|---|---|---|---|---|---|---|---| +| 0.0.0-stage (no tag) | n/a | n/a | n/a | n/a | n/a | n/a | npm tarball: `package.json` (`"stub": true`), `README.md`; no `dist/` | +| 0.1.0-alpha.0 | yes (no condition at all) | yes | yes (any value, any host) | no such switches | no | no script-tag build, no `cdn/` | `index.ts:128` `const mode = ks && MODES.includes(ks) ? ks : options.mode`; `runtime.ts:186` default `guard`; `killSwitch` `index.ts:74-91` reads only | +| 0.1.0-alpha.1 | yes (no condition) | yes | yes | no such switches | no | no `cdn/` | `index.ts:128` (same line); `runtime.ts:205` default `observe` | +| 0.1.0-beta.0 | yes (no condition) | yes | yes | no such switches | no | `cdn/config.ts:100-101` `case "debug": c.debug = !isOff(lv)` (moot: no condition); every `` in head or body read (`cdn/config.ts:14`) | `index.ts:142`; `runtime.ts:216` default `observe` | +| 0.1.0-beta.1 | yes | yes (debug is the only gate) | no (`heal`); `guard` is the default mode here, not a raise | yes: `runtime.ts:308`, `:319`, `:496` | no | `cdn/config.ts:87-88`; meta anywhere | `index.ts:248-251` `options.debug \|\| rank(ks) <= rank(base)`, `base = options.mode ?? "guard"` (`:250`); `runtime.ts:306` default `guard` | +| 0.1.0-beta.2 | yes | yes (debug only) | yes: `guard` passes against the `guard` baseline while the runtime defaults to `observe` | yes: `runtime.ts:312`, `:323`, `:501` | no | `cdn/config.ts:100-101`; meta anywhere | `index.ts:262-265`, `base ... ?? "guard"` (`:264`); `runtime.ts:310` default `observe` | +| 0.1.0-beta.3 | yes | yes (debug only) | yes (`guard`) | yes: `runtime.ts:329`, `:340`, `:523` | no | `cdn/config.ts:100-101`; meta anywhere | `index.ts:279-282` (`:281` baseline `guard`); `runtime.ts:327` default `observe` | +| 0.2.0 | yes | yes (debug only) | yes (`guard`) | yes: `runtime.ts:367`, `:378`, `:566` | no | `cdn/config.ts:101-102`; meta anywhere | `index.ts:289-292` (`:291` baseline `guard`); `runtime.ts:365` default `observe` | +| 0.2.1 | yes | yes (debug only) | yes (`guard`) | yes: `runtime.ts:367`, `:378`, `:571` | no | `cdn/config.ts:101-102`; meta anywhere (`cdn/config.ts:15`) | `index.ts:289-292` (`:291` baseline `guard`); `runtime.ts:365` default `observe` | +| 0.2.2 | yes | yes: `isLocalHost` (`telemetry/config.ts:26-38`) | no (fixed: baseline `observe`, `index.ts:303`) | yes: `runtime.ts:370`, `:381`, `:574` | no (`sampleBucket` returns at `:574` before the `genclass:bucket` write at `:591`) | `cdn/config.ts:103-104` sets `debug` from `data-debug` / ``, which then feeds the bypass; meta only inside `` (`cdn/config.ts:15`, `:175-176`); script `dataset` at `cdn/global.ts:92` | `index.ts:299-310`: `options.debug \|\| devHost \|\| rank(ks) <= rank(base)` (`:310`), `devHost = isLocalHost(...)` (`:306`); npm tarball: `dist/chunk-7ODWDTWV.js`, `dist/genclass.global.js` and `dist/genclass.global.min.js` carry the `debug \|\|` raise and the `=== "1" && this.debug` branch | +| 0.3.0-beta.0 | yes | yes (`isLocalHost`) | no | yes: `runtime.ts:468`, `:481`, `:721` | no | `cdn/config.ts:112-113`; meta in `` only | `index.ts:334-345` (`:345`), `isLocalHost` at `:341` | +| 0.3.0-beta.1 | yes | yes (`isLocalHost`) | no | yes: `runtime.ts:471`, `:484`, `:724` | no | `cdn/config.ts:113-114`; meta in `` only | `index.ts:337-348` (`:348`), `isLocalHost` at `:344` | +| 0.3.0-beta.2 | no | no (`devHost`, `devhost.ts:24-25`: http(s) on a loopback name with an explicit port, or `*.test`) | no | no: `runtime.ts:474`, `:487`, `:728-729` (no `=1` branch) | no | `cdn/config.ts:115-116` still parses `debug`, now logging only (`cdn/config.ts:27-29`) | `index.ts:15` imports `devHost`, `:345` `dev = devHost(...)`, `:349` `(dev \|\| rank(ks) <= rank(base))`; `test/mode-switch.test.ts`; npm tarball: `dist/chunk-NO4UMDVS.js`, `dist/genclass.global.js` and `dist/genclass.global.min.js` carry the `\.localhost$` predicate and no `debug \|\|` raise | +| 0.3.0-beta.3 | no | no | no | no | no | as beta.2 | `index.ts:345`, `:349`; `runtime.ts:474`, `:729` (identical lines) | +| 0.3.0-beta.4 | no | no | no | no | no | as beta.2 | `index.ts:345`, `:349`; `runtime.ts:474`, `:729` (identical lines) | + +Notes on the table: + +- `?genclass=observe` and `?genclass=off` lower in every version listed; `off` installs nothing and is checked before + any other switch (`index.ts` `initUnsafe`, first statement after `killSwitch`), so a `debug` bypass cannot undo it. +- Script-tag build: `data-debug` on the tag and `debug` in `` go through the same parser + (`cdn/config.ts` `parsePairs` and the `case "debug"` above) into `GenClass.init()`'s options, so they enable the + bypass exactly like `debug: true` in code. Before 0.2.2 the meta tag was read anywhere in the document, so markup + injected into the body could set `debug` (and the mode itself, which is the larger issue the 0.2.2 entry fixed); + from 0.2.2 only tags inside `` count. +- The `?genclass-mode` / `-aggr` / `-sample` switches never raised anything without `debug` in any version + (`runtime.ts`, the rank and level comparisons), and `?genclass-sample=0` always lowered. + +## Further findings from the review of the fix (2026-10-27) + +An adversarial review of the 0.2.3 backport found four more ways page-controlled input widened GenClass's behaviour. +They are fixed in commit `8694211` on `release/0.2.3` (tag `v0.2.3`) and ported to `main` for `0.3.0-beta.5` (branch +`security/advisory-mode-raise`). None changes the headline vector; each is listed with its own conditions and affected +versions. Evidence is the named function at `git show :packages/runtime/src/`; the version ranges were +checked against every `v*` tag (`routes`, the sample bucket, `?genclass-mode` and the named aggressiveness levels all +arrived in `0.1.0-beta.1`; `window.GENCLASS_CONFIG` in `0.1.0-beta.0`; the `mode` option and `modeAllows` exist in +every version). + +### F1. A look-alike path stepped out of a route rule (`routes[]`) + +- **What.** `routes[]` lets an app lower the mode on some paths (`{ match: "/checkout", mode: "observe" }`). A rule was + matched against the raw `location.pathname`, which the browser never percent-decodes, while most servers route on + the decoded path and tolerate a trailing slash, repeated slashes, dot segments and `;` parameters. A link to + `/checkout/`, `//checkout`, `/che%63kout`, `/checkout;x=1` or `/x/../checkout` reached the checkout page with the + app's global mode instead of the rule's (`runtime.ts` `computeScope`, `util/match.ts` `routeMatches`). +- **Reachable from a link**: yes, the headline vector (AV:N, UI:R, one visitor's page load). **Bound**: a rule only + lowers the mode, so the visitor got the app's own global mode (`guard` or `heal` only where the app opted into it), + never more; `requests.protect`, the cross-origin rule and the other bounds above still hold. +- **Affected**: every version with `routes`: `0.1.0-beta.1` to `0.2.2` and `0.3.0-beta.0` to `0.3.0-beta.4` + (`0.1.0-alpha.0`, `0.1.0-alpha.1` and `0.1.0-beta.0` have no route rules). +- **Fix**: a rule matches the raw path or its normalised form (`util/match.ts` `normalizeRoute`: percent-decoded, `;` + parameters dropped, dot segments and repeated slashes resolved, no trailing slash); `status.scope.route` stays raw; + matching stays case-sensitive, and `/admin/*` still does not cover `/admin` itself (both now documented). Tests: + `test/status-scope.test.ts` (ten look-alike forms, globs, RegExp and predicate rules, and a scripted guard-tier action + that does not run under an observe rule on `/se%61rch`). + +### F2. An invalid `mode` ran as heal + +- **What.** `mode` was not validated: `"HEAL"`, `"Guard"`, `"heal "`, an object or a number from `GenClass.init()` / + `createRuntime()`, or from `window.GENCLASS_CONFIG` (copied unchecked by `cdn/config.ts` `readWindowConfig`), was + stored as the mode. `MODE_RANK[mode]` is then `undefined`, so no URL switch could lower it (`?genclass-mode=observe` + compares against `undefined` and does nothing), and the gate's `modeAllows` fell through its `observe` and `guard` + branches to `return true`, permitting guard- and heal-tier actions (`decide/policy.ts`). +- **Reachable from a link**: no. It needs the app's configuration (a typo in an env-driven value such as + `mode: process.env.GENCLASS_MODE`) or a script in the origin that sets `window.GENCLASS_CONFIG` before GenClass starts + (such a script can call `setMode("heal")` directly, T1). The meta tag and the script tag's `data-mode` were already + validated (`cdn/config.ts` `fromPairs`, `MODES.includes`), so injected markup could not use it. +- **Affected**: every version: `0.1.0-alpha.0` to `0.2.2` and `0.3.0-beta.0` to `0.3.0-beta.4` (`modeAllows` has the + same fall-through in `0.1.0-alpha.0`; `window.GENCLASS_CONFIG` since `0.1.0-beta.0`). +- **Fix**: the constructor and `readWindowConfig` accept only `observe`, `guard` and `heal`; anything else runs as + `observe` with one warning; `modeAllows` permits a non-passive tier only for `guard` and `heal`, so the gate fails + closed. Tests: `test/options.test.ts`, `test/policy.test.ts`. + +### F3. A stored sample verdict overrode `sample: 0` + +- **What.** The act/observe bucket (`sample`, OPTIONS-SPEC §4.2) is drawn once and frozen in + `sessionStorage["genclass:bucket"]` as `act` or `observe`. `sampleBucket` read the stored value before looking at + `sample`, so a tab that drew `act` under `sample: 0.5` kept acting after a deploy set `sample: 0` (until the tab + closed), and a same-origin script could write `act` to lift a session the app had sampled out (`runtime.ts` + `sampleBucket`). +- **Reachable from a link**: no (`?genclass-sample=0` always lowered, and no URL value writes storage). It needs a + deploy that lowers `sample` while tabs are open, or script access to the origin's `sessionStorage` (T1). +- **Affected**: every version with `sample`: `0.1.0-beta.1` to `0.2.2` and `0.3.0-beta.0` to `0.3.0-beta.4`. +- **Fix**: `sample: 0` (like `1`) never reads or writes the storage; a verdict is stored with the sample it was drawn + under (`act@0.5`) and reused only under that sample. Open tabs are re-bucketed once on upgrade. Tests: + `test/options.test.ts`. + +### F4. `aggressiveness` level names were looked up with `in` (hardening) + +- **What.** `aggressivenessLevel` checked a string with `a in LEVEL_AT` on a plain object, so an `Object.prototype` + member name (`"constructor"`, `"__proto__"`, `"valueOf"`) returned a function instead of a number. From the option or + a route rule it reached the gate as the level and produced `NaN` thresholds; from `?genclass-aggr=` the lower-only + comparison rejected it, so a link had no effect. +- **Impact**: nothing found that widens behaviour: a `NaN` threshold clears nothing (`decide/policy.ts`: `!(mass >= th)` + and `p >= g.report` both reject), so the page acted on and reported nothing at that gate. Listed as hardening. +- **Affected**: `0.1.0-beta.1` to `0.2.2` and `0.3.0-beta.0` to `0.3.0-beta.4` (the `in` lookup arrived with the named + levels in `0.1.0-beta.1`). +- **Fix**: only `cautious`, `balanced` and `eager` are names (`LEVEL_NAMES.includes`); anything else is `balanced`, as + documented. Test: `test/gates.test.ts`. + +### Patched versions for F1 to F4 + +- `0.2.3` (stable line): commit `8694211`, tag `v0.2.3` on origin (2026-10-27); not on npm at the time of this draft. +- `0.3.0-beta.5` (beta line): the port to `main` on branch `security/advisory-mode-raise`; not tagged or published at + the time of this draft. `0.3.0-beta.2` to `0.3.0-beta.4` carry the headline fix but not F1 to F4. + +GHSA ranges: F1, F3 and F4 `>= 0.1.0-beta.1, <= 0.2.2` and `>= 0.3.0-beta.0, <= 0.3.0-beta.4`; F2 +`>= 0.1.0-alpha.0, <= 0.2.2` and `>= 0.3.0-beta.0, <= 0.3.0-beta.4`. (owner: either widen this advisory's affected +range to `<= 0.3.0-beta.4` with `0.3.0-beta.5` as the patched version once it is published and name F1 to F4 in it, or +publish F1, the only one reachable from a link, as its own advisory and keep F2 to F4 in the CHANGELOG. The version +tables above are for the headline issue only.) + +## Severity + +`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:L`, base score 5.4 (Medium). + +| metric | value | why | +|---|---|---| +| Attack Vector | Network (N) | the attacker sends a link; the victim's browser does the rest over the web | +| Attack Complexity | Low (L) | once the vulnerable configuration exists (`debug: true` in production, or an app served from a host `isLocalHost` accepts, or no `mode` on 0.1.0-beta.2 to 0.2.1), one URL works every time, no race or guesswork. Base metrics are scored for the vulnerable configuration; if the owner prefers to count the configuration itself as a condition beyond the attacker's control, AC:H gives `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:L/A:L`, 4.2 (Medium) | +| Privileges Required | None (N) | no account on the site or on genclass.dev is involved | +| User Interaction | Required (R) | the victim must open the crafted link (or paste it); nothing happens otherwise | +| Scope | Unchanged (U) | the effect stays inside the page that loaded the library, for that visitor; no other origin, server or user is touched | +| Confidentiality | None (N) | nothing new is read or sent; telemetry is the same redacted diagnostics it sends in every mode, and the attacker does not receive it | +| Integrity | Low (L) | the page's behaviour can change (a request coalesced, answered from cache, retried, a store write discarded or rolled back), but only to actions the local model picks from a fixed, bounded, mostly reversible list; the attacker controls none of the content, and request bodies, headers, cookies and protected endpoints are never touched | +| Availability | Low (L) | a request can be held (at most the hold budget), delayed (at most 8 s) or answered 503 locally (`block`), for that visitor's page load only; the breaker and `?genclass=off` end it | + +Conservative reading: this is a per-visitor, per-page-load downgrade of a safety setting, with no data exposure and no +cross-user effect. The versions with no condition at all (0.1.0-alpha.0 to 0.1.0-beta.0) are unsupported and were on +npm for days; the same vector applies to them with the same impact. + +The review's findings (F1 to F4) do not change the score. F1 shares the headline vector (a link, one visitor's page +load, UI:R) with a smaller effect: the app's own global mode on a path it had scoped lower, never a mode the app did +not opt into, so I:L / A:L still bound it; published on its own it would carry the same vector and score (5.4, or 4.2 +with AC:H), with that bound noted. F2 and F3 need the app's configuration or script access to the origin (out of +scope: such a script can call `setMode("heal")` directly), so on their own they are AV:L with PR:H at most, below the +headline score; F4 widens nothing. + +## Workarounds + +For an affected version that cannot be upgraded yet (the stable line until `0.2.3` is out): + +1. **Do not set `debug: true` in production**, from any source: `GenClass.init({ debug: true })`, + `window.GENCLASS_CONFIG.debug`, the options file `npx @genclass/runtime init` writes, `` or `data-debug` on the script tag. With `debug` off, 0.2.2, 0.3.0-beta.0 and 0.3.0-beta.1 on a + public host (one `isLocalHost` rejects) cannot be raised by any switch. To check a deployed page: with `debug` on, + every decision is logged to the console as `[GenClass] decision ...`. +2. **Set `mode` explicitly** (`mode: "observe"` unless you opted into guard). On 0.1.0-beta.2 to 0.2.1 this closes the + `guard` raise for apps that set no mode (the baseline becomes the mode you set); on every version it keeps the + configuration visible in one place. It does not help against `debug` or the look-alike-host bypass. +3. **Apps served from a host that only looks local** (Capacitor, Tauri, Electron, intranet names, private addresses, + `*.internal`, `*.local`) on 0.2.2 to 0.3.0-beta.1: the only configuration-side mitigation short of upgrading is to + pin the mode again right after start, `GenClass.init(opts).setMode("observe")` (or `GenClass.runtime.setMode(...)` + after the auto import), and to make sure deep links or intranet links cannot carry a query string into the page's + URL. Upgrading is the real fix for these apps. +4. **A server-side or edge redirect that strips `genclass` query parameters** helps for the link vector on a site + served by a server or CDN you control: redirect requests whose query holds `genclass=guard`, `genclass=heal`, + `genclass-mode`, `genclass-aggr` or `genclass-sample` to the same URL without them, before the HTML is served (a + 303 to the cleaned URL). Keep `genclass=off` and `genclass=observe` working: they are the documented visitor-side + opt-out and lowering switch. Limits: it does nothing for `localStorage.genclass` (which needs same-origin script + access anyway), for apps served from the device (Capacitor, Tauri, Electron: there is no server in front), or for + pages whose query string the server never sees (a static host without a rules engine); and the runtime reads + `location.search` at start, so the parameter must be gone before the page's scripts run, not removed by the app's + router afterwards. +5. **`?genclass=off` still lowers in every version**: a visitor (or your support team) who sees GenClass acting can + append `?genclass=off` and nothing is installed for that page load; `?genclass=observe` keeps the reporting without + actions (on affected versions with `debug` on, prefer `off`, since `?genclass-mode=` could undo `observe`). +6. **Limit what a raised mode could touch**: keep payment, checkout and sign-in endpoints under `requests.protect` + (`protectPreset("payments", "auth")`), deny actions you never want with `policy.deny`, and leave `visual.fix` off. + These are the production recommendations in SECURITY.md and INTERCEPTION.md and they apply in every mode. +7. **For the review's findings on an affected version** (until `0.2.3` or `0.3.0-beta.5`): F1, use a predicate rule + that decodes the path and strips `;` parameters and trailing or repeated slashes before comparing, or keep the + app's global mode at the lowest level it needs rather than relying on a rule to lower it on one path; F2, set + `mode` from a checked value (`["observe", "guard", "heal"].includes(v) ? v : "observe"`); F3, a deploy that lowers + `sample` to 0 should also set `mode: "observe"` until the open tabs have closed. + +## Credits + +Found by the GenClass maintainers while testing `0.3.0-beta.0` in a pilot app on 2026-10-17. No external reporter. + +## Timeline + +| date | event | +|---|---| +| 2026-10-08 | `0.1.0-alpha.0` published; the URL switch sets the mode on every host with no condition | +| 2026-10-08 | `0.1.0-beta.1`: the switch becomes lower-only with a `debug` bypass; `?genclass-mode`, `?genclass-aggr` and `?genclass-sample` arrive with the same bypass | +| 2026-10-08 to 2026-10-11 | `0.1.0-beta.2` to `0.2.1`: the runtime's default mode returns to `observe` while the switch keeps a `guard` baseline, so `?genclass=guard` raises an app that set no `mode` | +| 2026-10-13 (tag), 2026-10-15 (npm) | `0.2.2`: the baseline is fixed (CHANGELOG "Security: a link or an injected `` tag can no longer raise GenClass's mode"); the `debug` bypass stays and the broad `isLocalHost` exception is added | +| 2026-10-15 | `0.3.0-beta.0` published with the same code | +| 2026-10-17 | found by the maintainers testing `0.3.0-beta.0` in a pilot app | +| 2026-10-20 | fix written: `12f4a5c` ("Stop debug and look-alike local hosts from letting a link raise the mode", 00:10 -0400) with `test/mode-switch.test.ts`; look-alike cases pinned in `544085c`; PR #64 opened the same day (public, with the issue described in its CHANGELOG entry) | +| 2026-10-22 (tag), 2026-10-24 (npm) | `0.3.0-beta.1` released without the fix | +| 2026-10-24 | the fix's code and tests ported onto `main` (`5a6b48f`, merge `c2379a7`) | +| 2026-10-25 | `0.3.0-beta.2` tagged and published with the fix (npm dist-tag `beta`) | +| 2026-10-26, 2026-10-27 | `0.3.0-beta.3`, `0.3.0-beta.4` carry it | +| 2026-10-27 | the adversarial review of the 0.2.3 backport finds F1 to F4; fixed in `8694211` on `release/0.2.3`, tagged `v0.2.3` (not yet on npm) | +| 2026-10-27 | F1 to F4 ported to `main` for `0.3.0-beta.5` (branch `security/advisory-mode-raise`), with the docs below | +| pending | `0.2.3` on npm's `latest`; `0.3.0-beta.5` tagged and published; this advisory; docs on `main` updated (see the notes below) | + +## References + +- Fix: commit `12f4a5c` (PR #64, https://github.com/genclass-dev/GenClass-lib/pull/64), tests `544085c`; on `main` + as `5a6b48f` (merge `c2379a7`); released in `v0.3.0-beta.2`. +- `packages/runtime/src/devhost.ts` (the development-host predicate and why it is narrow), + `packages/runtime/test/mode-switch.test.ts` (every case above). +- `packages/runtime/INTERCEPTION.md` ("What each mode may change", "What it never does", "Actions: preconditions and + reversibility"), `docs/runtime/THREAT-MODEL.md` T8, `SECURITY.md` ("Recommended for production"). +- `packages/runtime/CHANGELOG.md`, the `0.2.2` security entry (the earlier, partial fix). +- Review findings F1 to F4: commit `8694211` (`release/0.2.3`, tag `v0.2.3`); the `main` port on branch + `security/advisory-mode-raise`; `packages/runtime/CHANGELOG.md` "Unreleased (0.3.0-beta.5)" and "0.2.3"; + `packages/runtime/src/util/match.ts` `normalizeRoute`, `src/runtime.ts` (constructor, `sampleBucket`, + `computeScope`), `src/cdn/config.ts` `readWindowConfig`, `src/decide/policy.ts` (`aggressivenessLevel`, `modeAllows`). + +## Notes for the owner before publishing (not part of the advisory text) + +- The 0.3.0-beta.2 CHANGELOG section does not carry a Security entry for this fix: the port to `main` (`5a6b48f`) + took the code and tests only. The entry exists on PR #64's branch under "## Unreleased" (it already says "0.2.3 + carries the same fix"). Move it under `## 0.3.0-beta.2` (and `## 0.2.3` when that ships) in the same change that + publishes this advisory. +- The docs on `main` still describe the old behaviour: `docs/runtime/THREAT-MODEL.md` T8 ("unless the app sets + `debug: true`", "development host (`localhost`, `*.local`, `*.test`, private networks)"), `docs/runtime/CONTRACT.md` + line 540 (`?genclass=guard` "only with `debug: true` or on a development host since 0.2.2"), `docs/runtime/OPTIONS-SPEC.md` + §3 (URL overrides "with `debug:true` they may also raise"), `packages/runtime/README.md` lines 688-692, 1058 and 1132 + (the `?genclass-sample=1` with `debug: true` QA recipe) and `packages/runtime/INTERCEPTION.md` ("raises only with + `debug: true` or on a development host"). PR #64 holds the updated wording (CONTRACT §2 and §13 are binding and need + your OK). +- PR #64 is still open; the advisory can cite the commits on `main` instead if it is closed without merging. +- `0.2.3` is not tagged or published yet. The form's "patched versions" for the `<= 0.2.2` range depends on it; the + release goes through `.github/workflows/release.yml` on a `v0.2.3` tag whose version equals + `packages/runtime/package.json` on that branch. +- The CVSS Attack Complexity choice (L, 5.4, or H, 4.2) is yours; the table above explains both. +- F1 to F4 are on no published version yet: `0.2.3` is tagged, `0.3.0-beta.5` is not. Decide whether this advisory + covers them (then its affected range becomes `<= 0.3.0-beta.4` and the patched versions `0.2.3` / `0.3.0-beta.5`) or + F1 gets its own; F2 to F4 can stay CHANGELOG entries either way. +- The `main` port of F1 to F4 raises `packages/runtime/test/bundle.test.ts`'s `/auto` first-load limit from 108.6 to + 108.9 KB (the fixes add 0.27 KB to `/auto` and 0.17 KB to the main entry; RESULTS.md section 10), pending your OK as + the earlier limits were. It also brings OPTIONS-SPEC §3 and THREAT-MODEL T8 in line with `0.3.0-beta.2`'s code (the + stale `debug` wording listed above); CONTRACT.md line 540, the README's `?genclass-sample=1` recipe and + INTERCEPTION.md's "raises only with `debug: true`" sentence are still as listed and need your OK (CONTRACT) or a + separate docs change. diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index d918ed82..c3372da4 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## Unreleased (0.3.0-beta.5) + +Security fixes from the adversarial review of the mode-raise fix (`0.3.0-beta.2`); the same four fixes are in `0.2.3` +on the stable line (tag `v0.2.3`). Nothing else changed: no model-visible text (`situation-v2.3`), telemetry and its +opt-outs are unchanged, and the default model is unchanged. + +### Security + +- **A look-alike path no longer steps out of a route rule.** `routes[]` matched the raw `location.pathname`, which the + browser never percent-decodes, while most servers route on the decoded path: a link to `/se%61rch`, `/search/`, + `//search` or `/search;x=1` reached the `/search` page with the app's global mode although a rule scoped `/search` + to `off` or `observe` (a rule can only lower the mode, so the visitor got the global mode, never more). A rule now + matches the raw path or its normalised form (percent-decoded, `;` parameters dropped, dot segments and repeated + slashes resolved, no trailing slash; `util/match.ts normalizeRoute`); `status.scope.route` is still the raw path. + Matching is case-sensitive, and `/admin/*` does not cover `/admin` itself (use `/admin*` or a RegExp); both are now + documented. Affected: every version with `routes` (`0.1.0-beta.1` to `0.2.2`, `0.3.0-beta.0` to `0.3.0-beta.4`). + Tests: `test/status-scope.test.ts`; docs: OPTIONS-SPEC §4.3, API.md, THREAT-MODEL T8. +- **An invalid `mode` ran as heal.** `mode: "HEAL"`, `"Guard"`, `"heal "`, an object or a number (a typo in an + env-driven config, or `window.GENCLASS_CONFIG`, which was copied unchecked) fell through the mode ranking and the + gate's `modeAllows` and ran guard- and heal-tier actions, which `?genclass-mode=observe` could not lower. Now any + value but `observe`, `guard` or `heal` runs as `observe`, with one warning (`window.GENCLASS_CONFIG.mode` is + validated like the meta tag and the data attributes already were), and `modeAllows` permits a non-passive tier only + for `guard` and `heal`, so the gate fails closed. App configuration only, never reachable from a URL. Affected: every + version (`0.1.0-alpha.0` to `0.2.2`, `0.3.0-beta.0` to `0.3.0-beta.4`; `window.GENCLASS_CONFIG` since `0.1.0-beta.0`). + Tests: `test/options.test.ts`, `test/policy.test.ts`; docs: OPTIONS-SPEC §3, API.md, THREAT-MODEL T8. +- **A stored sample verdict overrode `sample: 0`.** The act/observe bucket is frozen in + `sessionStorage["genclass:bucket"]`, and a tab that drew `act` under `sample: 0.5` kept acting after a deploy set + `sample: 0`, until it closed (a same-origin script could write the value too). Now `sample: 0` never reads the + storage, and a verdict is stored with the sample it was drawn under (`act@0.5`) and reused only under that sample: a + change of `sample` draws again. Open tabs are re-bucketed once on upgrade. Affected: every version with `sample` + (`0.1.0-beta.1` to `0.2.2`, `0.3.0-beta.0` to `0.3.0-beta.4`). Tests: `test/options.test.ts`; docs: OPTIONS-SPEC + §4.2, INTERCEPTION.md. +- **Hardening: `aggressiveness` level names.** The three names were looked up with `in` on a plain object, so + `"constructor"`, `"__proto__"`, `"valueOf"` or another `Object.prototype` member name (from the option, a route rule + or `?genclass-aggr=`) gave a function instead of a number, and NaN gate thresholds downstream (which clear nothing, + so the page acted on and reported nothing at that gate; from the URL the value was never applied). Only `cautious`, + `balanced` and `eager` are names now; anything else is `balanced`, as documented. Test: `test/gates.test.ts`. +- **Docs: `devtools: "local"` is wider than the development-host test.** It mounts the overlay on every host + `isLocalHost` counts as local (`file:`, loopback with or without a port, `*.localhost`, `*.local`, `*.test`), so a + Capacitor, Tauri or Electron build that ships it gets the overlay, whose mode switch raises the mode for whoever is at + the device. Unchanged in code; documented in THREAT-MODEL T8 and the README. Set `devtools` per environment. +- **Docs: OPTIONS-SPEC §3 and THREAT-MODEL T8 now describe the URL switches as `0.3.0-beta.2` left them** (lower-only + on every host, `debug` as console logging only, raising only on a development host); they still carried the + pre-fix wording. + ## 0.3.0-beta.4 (2026-10-27) - **The devtools pill counts measured checks** (`src/devtools/`, VISUAL.md 9.3). The collapsed pill showed only diff --git a/packages/runtime/INTERCEPTION.md b/packages/runtime/INTERCEPTION.md index 51def310..d6e597c8 100644 --- a/packages/runtime/INTERCEPTION.md +++ b/packages/runtime/INTERCEPTION.md @@ -141,7 +141,7 @@ from ever being held. | Network: the model card, tokenizer, calibration, meta and the model variant (about 10 MB) from `model.baseUrl` (default jsDelivr `@genclass/runtime-model`), and onnxruntime-web's wasm from `model.ortWasmPaths` | with the page's fetch as it was when the module loaded (never through the wrapper) | at idle after load, `GenClass.init` in a browser with a model; not on devices reporting 2 GB or less nor on Save-Data (`model.loadIf` default) | [src/model/loader.ts](src/model/loader.ts), [src/model/backend.ts](src/model/backend.ts) | | Network: anonymous diagnostics to the GenClass collector (`DEFAULT_TELEMETRY_ENDPOINT`, a Cloudflare Worker): a summary a minute while active, records of at most 200 decisions per page load (routine ones 1 in 4) | native `fetch`/`sendBeacon` captured at module load, `text/plain`, `credentials: "omit"`, `referrerPolicy: "no-referrer"` | `GenClass.init` in a browser unless turned off ([TELEMETRY.md](TELEMETRY.md)); never in `createRuntime()` | [src/telemetry/](src/telemetry/) | | Cache Storage | `genclass-model` cache (`model.cacheName`): model files, each checked against the card's size and sha256 | model load | [src/model/loader.ts](src/model/loader.ts) `fetchFile` | -| `sessionStorage` | `genclass:bucket` (`sample`), the breaker's trip state | `sample` < 1; breaker trips | [src/runtime.ts](src/runtime.ts) `sampleBucket`, [src/decide/breaker.ts](src/decide/breaker.ts) | +| `sessionStorage` | `genclass:bucket` (`sample`: `act@` or `observe@`, reused only under the same `sample`; never read or written at `sample` 0 or 1), the breaker's trip state | `sample` < 1; breaker trips | [src/runtime.ts](src/runtime.ts) `sampleBucket`, [src/decide/breaker.ts](src/decide/breaker.ts) | | `localStorage` / `sessionStorage` | `genclass:learn` (learned flows by default; operation shapes too with `learn.persist`: signatures, routes and field names with id-like parts normalised, counts, hashed action labels; ≤ `learn.maxBytes`), `genclass:learn:probe` (set and removed once at init to check storage works), `genclass:learn:session` in sessionStorage (the tab's session ordinal) | flows by default, not in jsdom / happy-dom (`learn.persist: false` turns it all off); the runtime's own writes are not recorded as storage events | [src/runtime.ts](src/runtime.ts) `saveProfilesSoon`, `startSession`, [docs/runtime/EXPECTATIONS.md](../../docs/runtime/EXPECTATIONS.md) | | `localStorage` | `genclass.analytics.`: the visit id (16 random hex characters), the time of the visit's last page view or event, and a visited-before flag; written with the storage observer muted (`rt.selfStorage`), so it never becomes a `storage` event | interaction analytics on (`InitOptions.analytics`, `rt.analytics.start()`): each page view and each `interactions` event; removed by `rt.analytics.stop()` | [src/analytics/index.ts](src/analytics/index.ts) `startAnalytics` | | `localStorage` (read only) | `genclass` (kill switch), `genclass.telemetry` (opt-out) | init | [src/index.ts](src/index.ts) `killSwitch`, [src/telemetry/config.ts](src/telemetry/config.ts) | diff --git a/packages/runtime/README.md b/packages/runtime/README.md index afba33d3..742c457d 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -271,7 +271,8 @@ returns an inert runtime. It exposes `window.GenClass`, discovers the app's state like the import does, and loads the model worker, ONNX Runtime Web and the overlay on demand from the same version on the CDN. `data-mode` takes `observe` (the default), `guard` or `heal`; `data-devtools="local"` shows the -overlay only on localhost; `data-manual` skips the automatic `GenClass.init()`. Since `0.3.0`: `data-token="gc_..."` +overlay only on local hosts (loopback, `*.localhost`, `*.local`, `*.test` and `file:`, so also in a Capacitor, Tauri or +Electron build that ships it); `data-manual` skips the automatic `GenClass.init()`. Since `0.3.0`: `data-token="gc_..."` (your app's token), `data-scope="functions"` and `window.GenClass.protect`. Keep the version pinned, as above: the unversioned URL serves npm's `latest` (the older `0.2.x` until `0.3.0` is stable); the plain-HTML path of `init` writes a pinned jsDelivr URL with SRI. diff --git a/packages/runtime/src/cdn/auto-start.ts b/packages/runtime/src/cdn/auto-start.ts index 0750ed16..7434950d 100644 --- a/packages/runtime/src/cdn/auto-start.ts +++ b/packages/runtime/src/cdn/auto-start.ts @@ -23,7 +23,7 @@ export function startAuto(defaults: InitOptions = {}): Runtime { if (typeof g.window !== "object" || typeof g.document !== "object" || !g.document) return GenClass.init(defaults); const doc = g.document as Document; // automatic state discovery is on by default here (installed now, before the framework's modules run) - const { init, devtools, sinks } = splitConfig(mergeConfig(AUTO_DEFAULTS, defaults, readMetaConfig(doc, { location: g.location, warn: (m) => g.console?.warn?.(m) }), readWindowConfig(g.window))); + const { init, devtools, sinks } = splitConfig(mergeConfig(AUTO_DEFAULTS, defaults, readMetaConfig(doc, { location: g.location, warn: (m) => g.console?.warn?.(m) }), readWindowConfig(g.window, (m) => g.console?.warn?.(m)))); // sinks by name (meta tag, window.GENCLASS_CONFIG) become one lazy proxy; a webhook URL joins requests.ignore const rt = GenClass.init(applySinks(init, sinks, resolveSinks, (m) => g.console?.warn?.(`[GenClass] ${m}`), globalThis as unknown as Record)); const dt = devtoolsOptions(devtools, g.location); diff --git a/packages/runtime/src/cdn/config.ts b/packages/runtime/src/cdn/config.ts index dc8d0295..db5f6985 100644 --- a/packages/runtime/src/cdn/config.ts +++ b/packages/runtime/src/cdn/config.ts @@ -251,11 +251,18 @@ export function readMetaConfig( return c; } -/** `window.GENCLASS_CONFIG` (any InitOptions plus `devtools`). */ -export function readWindowConfig(win: unknown): PageConfig { +/** `window.GENCLASS_CONFIG` (any InitOptions plus `devtools`). A `mode` that is not observe, guard or heal is dropped, with one warning. */ +export function readWindowConfig(win: unknown, warn?: (m: string) => void): PageConfig { try { const c = (win as { GENCLASS_CONFIG?: unknown } | null | undefined)?.GENCLASS_CONFIG; - return c && typeof c === "object" ? ({ ...(c as PageConfig) } as PageConfig) : {}; + if (!c || typeof c !== "object") return {}; + const out = { ...(c as PageConfig) } as PageConfig; + const m = out.mode as unknown; + if (m !== undefined && !(typeof m === "string" && MODES.includes(m))) { + warn?.(`[GenClass] window.GENCLASS_CONFIG.mode must be "observe", "guard" or "heal" (got ${typeof m === "string" ? JSON.stringify(m) : typeof m}); ignored.`); + delete out.mode; + } + return out; } catch { return {}; } diff --git a/packages/runtime/src/cdn/global.ts b/packages/runtime/src/cdn/global.ts index 6121f3a3..e5fe006b 100644 --- a/packages/runtime/src/cdn/global.ts +++ b/packages/runtime/src/cdn/global.ts @@ -155,7 +155,7 @@ function install(): void { init(options: PageConfig = {}): Runtime { // the page's configuration (meta tag, the tag's data attributes, window.GENCLASS_CONFIG) is the default // automatic state discovery is on by default for the script tag (installed now, before the framework loads) - const { init, devtools, sinks } = splitConfig(mergeConfig({ autoState: true }, readMetaConfig(document, { location, warn: (m) => console.warn(m) }), fromDataset(ds), readWindowConfig(window), options)); + const { init, devtools, sinks } = splitConfig(mergeConfig({ autoState: true }, readMetaConfig(document, { location, warn: (m) => console.warn(m) }), fromDataset(ds), readWindowConfig(window, (m) => console.warn(m)), options)); const rt = GenClass.init(withCdnModel(applySinks(init, sinks, resolveSinks, (m) => console.warn(`[GenClass] ${m}`), window as unknown as Record))); const dt = devtoolsOptions(devtools, location); if (dt && !isKilled(window)) void mount(rt, dt); diff --git a/packages/runtime/src/decide/policy.ts b/packages/runtime/src/decide/policy.ts index 8fbdd2cd..5e5e0e63 100644 --- a/packages/runtime/src/decide/policy.ts +++ b/packages/runtime/src/decide/policy.ts @@ -112,7 +112,7 @@ const LEVEL_AT = { cautious: 0, balanced: 0.5, eager: 1 } as const; export function aggressivenessLevel(a: Aggressiveness | string | undefined | null): number { if (typeof a === "number" && Number.isFinite(a)) return Math.min(1, Math.max(0, a)); if (typeof a === "string") { - if (a in LEVEL_AT) return LEVEL_AT[a as keyof typeof LEVEL_AT]; + if ((LEVEL_NAMES as readonly string[]).includes(a)) return LEVEL_AT[a as keyof typeof LEVEL_AT]; const n = Number(a); if (a.trim() !== "" && Number.isFinite(n)) return Math.min(1, Math.max(0, n)); } @@ -230,11 +230,11 @@ export function possibleGate(raw: ModelGate | undefined, report: number): number return eager !== undefined && eager < report ? eager : undefined; } +/** Whether a mode permits a tier: observe nothing, guard the guard tier, heal every tier. Any other value fails closed. */ export function modeAllows(mode: Mode, tier: Tier): boolean { if (tier === "passive") return true; - if (mode === "observe") return false; if (mode === "guard") return tier === "guard"; - return true; + return mode === "heal"; } /** Why an action may not run under this mode and policy (null when it is permitted). */ diff --git a/packages/runtime/src/runtime.ts b/packages/runtime/src/runtime.ts index b997a32c..df1fe6b6 100644 --- a/packages/runtime/src/runtime.ts +++ b/packages/runtime/src/runtime.ts @@ -68,7 +68,7 @@ import { AuditLog, type AuditDraft } from "./decide/audit.js"; import { expandPresets } from "./presets.js"; import { Breaker, breakerConfig } from "./decide/breaker.js"; import { SinkDispatcher, SummaryTracker } from "./decide/summary.js"; -import { compileMatchers, compileValued, hash32, routeMatches, sanitizeLabel, type CompiledMatcher, type MatchInput } from "./util/match.js"; +import { compileMatchers, compileValued, hash32, normalizeRoute, routeMatches, sanitizeLabel, type CompiledMatcher, type MatchInput } from "./util/match.js"; import { browserClock } from "./clock.js"; import { GenClassUnavailableError } from "./errors.js"; import { EventLog } from "./trace/events.js"; @@ -469,7 +469,11 @@ export class RuntimeImpl implements Runtime { this.scope = o.scope === "functions" ? "functions" : "app"; // URL overrides only lower, on every host (OPTIONS-SPEC §3); `debug` is console logging only and never raises them const url = this.urlParams(); - this._mode = o.mode ?? "observe"; + // the mode option itself is validated: anything but the three modes runs as observe (an unknown value used to fall + // through minMode and modeAllows as heal) + const om = o.mode as unknown; + this._mode = om === "observe" || om === "guard" || om === "heal" ? om : "observe"; + if (om !== undefined && om !== this._mode) this.warn(`mode must be "observe", "guard" or "heal" (got ${typeof om === "string" ? JSON.stringify(om) : typeof om}); running in observe.`); const um = url.get("genclass-mode"); if (um === "observe" || um === "guard" || um === "heal") if (MODE_RANK[um] <= MODE_RANK[this._mode]) this._mode = um; if (o.policy?.maxActionsPerMinute !== undefined && o.policy?.actionLimits?.perMinute !== undefined) this.warn("policy.maxActionsPerMinute is deprecated and ignored when actionLimits.perMinute is set."); @@ -722,8 +726,10 @@ export class RuntimeImpl implements Runtime { } /** - * The session's act/observe bucket (OPTIONS-SPEC §4.2): hash(session id) < sample, frozen in sessionStorage. - * `?genclass-sample=0` puts it in the observe bucket; no URL value puts it in the act bucket. + * The session's act/observe bucket (OPTIONS-SPEC §4.2): hash(session id) < sample, frozen in sessionStorage as + * `act@` / `observe@`, so a verdict drawn under another `sample` (a deploy changed it) is drawn again. + * `?genclass-sample=0` and `sample: 0` put it in the observe bucket without reading storage; no URL value puts it in + * the act bucket. */ private sampleBucket(sample: number | undefined, urlSample: string | null): boolean { if (urlSample === "0") return false; @@ -734,16 +740,19 @@ export class RuntimeImpl implements Runtime { p = Number.isFinite(p) ? Math.min(1, Math.max(0, p)) : 1; } if (p >= 1) return true; + if (p <= 0) return false; const ss = this.storage("session"); + const actAt = `act@${p}`; + const observeAt = `observe@${p}`; try { const saved = ss?.getItem("genclass:bucket"); - if (saved === "act" || saved === "observe") return saved === "act"; + if (saved === actAt || saved === observeAt) return saved === actAt; } catch { /* deterministic within the page */ } const act = hash32(this.sessionId) / 4294967296 < p; try { - ss?.setItem("genclass:bucket", act ? "act" : "observe"); + ss?.setItem("genclass:bucket", act ? actAt : observeAt); } catch { /* ignore */ } @@ -755,15 +764,20 @@ export class RuntimeImpl implements Runtime { return typeof loc?.pathname === "string" ? loc.pathname : "/"; } - /** Route scope (OPTIONS-SPEC §4.3): the first matching rule, demote only. Recomputed on navigation. */ + /** + * Route scope (OPTIONS-SPEC §4.3): the first rule that matches the raw path or its normalised form (percent-decoded, + * no `;` parameters, dot segments and repeated slashes resolved, no trailing slash), demote only. Recomputed on + * navigation. `scope.route` stays the raw path. + */ computeScope(): void { const route = this.currentRoute(); + const norm = normalizeRoute(route); let mode: ModeOrOff = "heal"; let aggr = 1; let rule: number | undefined; for (let i = 0; i < this.routes.length; i++) { const r = this.routes[i]; - if (!r || !routeMatches(r.match, route)) continue; + if (!r || !(routeMatches(r.match, route) || (norm !== route && routeMatches(r.match, norm)))) continue; rule = i; if (r.mode === "off" || r.mode === "observe" || r.mode === "guard" || r.mode === "heal") { if (MODE_RANK[r.mode] > MODE_RANK[this._mode]) { diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index de1c5643..0acbae98 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -503,7 +503,12 @@ export type ModeOrOff = Mode | "off"; export type RequestMatcher = string | RegExp | ((r: { url: string; method: string; channel: "fetch" | "xhr" | "ws" | "sse" }) => boolean); export interface RouteRule { - /** Exact path or glob ("/admin/*"), RegExp, or predicate over the route. */ + /** + * Exact path or glob ("/admin/*"), RegExp, or predicate over the route. Matched against the raw `location.pathname` + * and against its normalised form (percent-decoded, `;` parameters dropped, dot segments and repeated slashes + * resolved, no trailing slash), so "/se%61rch", "/search/" and "//search" fall under a "/search" rule. Case-sensitive; + * "/admin/*" does not cover "/admin" itself (use "/admin*" or a RegExp). + */ match: string | RegExp | ((route: string) => boolean); /** Demote only. */ mode?: ModeOrOff; @@ -824,7 +829,7 @@ export interface InitOptions { * "guard" (opt-in) takes minimal guard-tier actions at very high confidence; "heal" is experimental. The URL * switches (`?genclass=`, `?genclass-mode=`) and `localStorage.genclass` may only lower it, with or without `debug`; * `?genclass=` and `localStorage.genclass` may raise it only on a development host (http(s) on localhost, 127.0.0.1, - * [::1] or *.localhost with an explicit port, or *.test). + * [::1] or *.localhost with an explicit port, or *.test). Any other value runs as "observe", with one warning. */ mode?: Mode; /** false: no model (observe-only). */ @@ -839,8 +844,9 @@ export interface InitOptions { /** Default true. A predicate / Promise / subscribable flag; while false nothing is decided or downloaded. */ enabled?: boolean | (() => boolean | Promise) | EnabledSource; /** - * Fraction of sessions allowed to act (default 1); the rest observe. `?genclass-sample=0` makes a session observe; no - * URL value makes it act. + * Fraction of sessions allowed to act (default 1); the rest observe. The bucket is drawn once per session and kept in + * sessionStorage with the `sample` it was drawn under; a change of `sample` draws it again, and 0 never reads it. + * `?genclass-sample=0` makes a session observe; no URL value makes it act. */ sample?: number; /** Per-route mode and aggressiveness (demote only); the first matching rule wins. */ diff --git a/packages/runtime/src/util/match.ts b/packages/runtime/src/util/match.ts index 9f3670c4..06b3f476 100644 --- a/packages/runtime/src/util/match.ts +++ b/packages/runtime/src/util/match.ts @@ -109,6 +109,30 @@ export function routeMatches(m: string | RegExp | ((route: string) => boolean), return false; } +/** + * A route's second form for rule matching (OPTIONS-SPEC §4.3): percent-decoded, `;` path parameters dropped, dot + * segments resolved, repeated slashes collapsed, no trailing slash (except "/"). The browser never decodes + * `location.pathname`, while most servers route on the decoded path, so "/se%61rch", "/search/", "//search" and + * "/search;x=1" are "/search" to them. A route rule applies when it matches the raw path or this form; rules only lower + * the mode, so the broader match is safe. Case is kept: matching is case-sensitive. A malformed escape keeps the raw path. + */ +export function normalizeRoute(path: string): string { + let p = path; + try { + p = decodeURIComponent(p); + } catch { + /* a malformed escape: keep the raw path */ + } + const out: string[] = []; + for (const raw of p.split("/")) { + const seg = raw.replace(/;.*$/, ""); + if (seg === "" || seg === ".") continue; + if (seg === "..") out.pop(); + else out.push(seg); + } + return "/" + out.join("/"); +} + /** Labels: names only. [A-Za-z0-9 _-], ≤ 5 words, ≤ 40 chars. */ export function sanitizeLabel(s: string): { label: string; changed: boolean } { const cleaned = String(s).replace(/[^A-Za-z0-9 _-]+/g, " ").replace(/\s+/g, " ").trim(); diff --git a/packages/runtime/test/bundle.test.ts b/packages/runtime/test/bundle.test.ts index 472da5ed..7d4b9e6c 100644 --- a/packages/runtime/test/bundle.test.ts +++ b/packages/runtime/test/bundle.test.ts @@ -89,7 +89,14 @@ describe.skipIf(!built)("dist as an app bundles it", () => { // 0.29 KB to /auto and 0.27 KB to the main entry over main (108.25 / 100.13 KB; 108.53 / 100.40 KB with it, measured // with test/first-load-size.mjs on the train VM, 2026-10-18); the recording itself is a lazy chunk (dist/analytics/index.js, asserted below). The // limits are the measured sizes plus 0.05 / 0.07 KB, as before. Both sizes are printed before either is asserted. - const LIMIT_KB: Record = { "auto.js": 108.6, "index.js": 100.48 }; + // Review of the mode-raise fix (branch security/advisory-mode-raise, 0.3.0-beta.5; the /auto limit below is pending + // the owner's OK): normalizeRoute, the mode validation and its warning, the sample bucket's act@ form and + // readWindowConfig's mode check add 0.27 KB to /auto and 0.17 KB to the main entry over main at 49c8c03 (108.63 / + // 100.46 KB, test/first-load-size.mjs against main's dist, 2026-10-27; docs/runtime/RESULTS.md section 10). /auto's + // limit is that size plus 0.07 KB; the main entry stays 0.02 KB under its limit, which is unchanged. + // CI (Node 22) gzips /auto to 108.83 KB where this Mac (Node 25) gets 108.63 KB, a zlib difference; the limit + // follows CI's number (owner OK on 2026-10-28, with the 0.1 KB raise first; the growth itself is 0.28 KB). + const LIMIT_KB: Record = { "auto.js": 108.9, "index.js": 100.48 }; it("first-load cost of /auto and the main entry, minified + gzip: under the limits", async () => { const { build } = await import("esbuild"); const uses: Record = { "auto.js": "", "index.js": "GenClass.init({});" }; diff --git a/packages/runtime/test/gates.test.ts b/packages/runtime/test/gates.test.ts index a20d6868..9c3c55ed 100644 --- a/packages/runtime/test/gates.test.ts +++ b/packages/runtime/test/gates.test.ts @@ -212,6 +212,23 @@ describe("aggressiveness (batch 11)", () => { expect(parseGate(P)?.profiles?.eager).toEqual({ report: 0.5, guard: { default: 0.6 }, heal: { default: 0.5 } }); expect([aggressivenessLevel("cautious"), aggressivenessLevel("eager"), aggressivenessLevel(0.3), aggressivenessLevel("0.8"), aggressivenessLevel("x"), aggressivenessLevel(7)]).toEqual([0, 1, 0.3, 0.8, 0.5, 1]); }); + // Review of the mode-raise fix (0.2.3, 0.3.0-beta.5): the level names were looked up with `in` on a plain object, so an + // Object.prototype member name gave a function instead of a number (and NaN gates downstream). Only the three names + // count; anything else is balanced. + it("only the three level names are levels: Object.prototype member names are balanced, from the option, a route rule or the URL", async () => { + const { aggressivenessLevel } = await import("../src/decide/policy.js"); + for (const n of ["constructor", "__proto__", "valueOf", "toString", "hasOwnProperty", "isPrototypeOf"]) { + expect(aggressivenessLevel(n), n).toBe(0.5); + expect(setup({ aggressiveness: n as never }).rt.aggressiveness, n).toBe(0.5); + expect(setup({ routes: [{ match: "/search", aggressiveness: n as never }] }).rt.status.scope?.aggressiveness, n).toBe(0.5); + // from the URL the name is an unknown value like any other: balanced, so it lowers eager and leaves cautious alone + const loc = { href: `http://app.test/search?genclass-aggr=${n}`, pathname: "/search", search: `?genclass-aggr=${n}` }; + const fromEager = setup({ aggressiveness: "eager", extraGlobal: { location: loc } }).rt; + expect(fromEager.aggressiveness, n).toBe(0.5); + expect(Number.isFinite(fromEager.gates("delivery").guard), n).toBe(true); + expect(setup({ aggressiveness: "cautious", extraGlobal: { location: loc } }).rt.aggressiveness, n).toBe(0); + } + }); it("named levels pick their profile; numbers interpolate between neighbours (report too)", () => { const c = policyConfig(undefined); expect(effectiveGates(c, P, "delivery", 0.5)).toMatchObject({ guard: 0.7, level: "balanced", levelSource: "profiles" }); diff --git a/packages/runtime/test/options.test.ts b/packages/runtime/test/options.test.ts index 1e38604b..4406e42e 100644 --- a/packages/runtime/test/options.test.ts +++ b/packages/runtime/test/options.test.ts @@ -1,7 +1,8 @@ // Batch 12: configuration options (docs/runtime/OPTIONS-SPEC.md). One block per option. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { defaultScript, FakeClock, FakeServer, makeGlobal, setup } from "./helpers.js"; import { createRuntime, DEFAULT_LOAD_IF, deviceEnv, evalLoadIf, IdleUnloadProvider } from "../src/index.js"; +import { hash32 } from "../src/util/match.js"; import type { DecisionProvider, ModelStatus, SinkRecord } from "../src/types.js"; const DELAY = defaultScript({ request: { diagnosis: "overload", action: "delay" } }); @@ -320,3 +321,98 @@ describe("model.unloadAfterIdleMs", () => { p.dispose(); }); }); + +// `mode` is validated (review of the mode-raise fix, 0.2.3 and 0.3.0-beta.5): before, a value that was not observe, guard +// or heal ("HEAL", "Guard", an object) fell through `minMode` and `modeAllows` and ran as heal, which +// `?genclass-mode=observe` could not lower either. +describe("mode: any value but observe, guard or heal runs as observe, with one warning", () => { + it.each(["HEAL", "Guard", "heal ", "", {}, 1, true, null])("%j", async (bad) => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const s = setup({ mode: bad as never, triage: "always", script: DELAY }); + expect(s.rt.mode).toBe("observe"); + expect(s.rt.status.effectiveMode).toBe("observe"); + expect(warn.mock.calls.filter((c) => /mode must be "observe", "guard" or "heal"/.test(String(c[0]))).length).toBe(1); + s.server.on("GET", "/api/x", { body: 1, latency: 5 }); + await hit(s); + expect(s.rt.interventions().length).toBe(0); + warn.mockRestore(); + }); + + it("the three modes pass unchanged, without a warning", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + for (const m of ["observe", "guard", "heal"] as const) expect(setup({ mode: m }).rt.mode).toBe(m); + expect(setup({ mode: undefined }).rt.mode).toBe("observe"); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("modeAllows fails closed for a value that is not a mode", async () => { + const { modeAllows } = await import("../src/decide/policy.js"); + for (const bad of ["HEAL", "Guard", "", "off", undefined, null]) { + expect(modeAllows(bad as never, "guard"), String(bad)).toBe(false); + expect(modeAllows(bad as never, "heal"), String(bad)).toBe(false); + expect(modeAllows(bad as never, "passive"), String(bad)).toBe(true); + } + }); + + it("window.GENCLASS_CONFIG.mode is validated like the meta tag and data attributes", async () => { + const { readWindowConfig } = await import("../src/cdn/config.js"); + const warns: string[] = []; + expect(readWindowConfig({ GENCLASS_CONFIG: { mode: "guard", debug: true } }, (m) => warns.push(m))).toEqual({ mode: "guard", debug: true }); + expect(readWindowConfig({ GENCLASS_CONFIG: { mode: "HEAL", debug: true } }, (m) => warns.push(m))).toEqual({ debug: true }); + expect(readWindowConfig({ GENCLASS_CONFIG: { mode: {} } }, (m) => warns.push(m))).toEqual({}); + expect(warns.length).toBe(2); + expect(warns[0]).toMatch(/GENCLASS_CONFIG\.mode must be "observe", "guard" or "heal" \(got "HEAL"\)/); + expect(readWindowConfig({ GENCLASS_CONFIG: { mode: "HEAL" } })).toEqual({}); // no sink: still dropped + expect(readWindowConfig({})).toEqual({}); + }); +}); + +// The sample bucket (OPTIONS-SPEC §4.2) is frozen in sessionStorage. Review of the mode-raise fix (0.2.3, 0.3.0-beta.5): +// a saved "act" verdict overrode `sample: 0` (a tab that drew act under 0.5 kept acting after a deploy set 0, or a +// same-origin script wrote it); now 0 never reads storage, and a verdict is kept with the sample it was drawn under and +// reused only under the same one. +describe("sample: the stored bucket is read only for the sample it was drawn under; 0 never reads it", () => { + const storage = (init: Record = {}) => { + const m = new Map(Object.entries(init)); + return { m, getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => void m.set(k, String(v)), removeItem: (k: string) => void m.delete(k) }; + }; + const ID = "session-1"; + const draw = (p: number) => hash32(ID) / 4294967296 < p; + + it("sample: 0 ignores a saved act verdict and writes nothing", async () => { + for (const saved of ["act", "act@0.5", "act@0"]) { + const ss = storage({ "genclass:bucket": saved }); + const s = setup({ sample: 0, session: { id: ID }, triage: "always", script: DELAY, extraGlobal: { sessionStorage: ss } }); + expect([s.rt.status.sampled, s.rt.status.effectiveMode], saved).toEqual([false, "observe"]); + expect(ss.m.get("genclass:bucket"), saved).toBe(saved); + s.server.on("GET", "/api/x", { body: 1, latency: 5 }); + await hit(s); + expect(s.rt.interventions().length, saved).toBe(0); + } + }); + + it("a verdict drawn under the same sample is reused; one from another sample, or the old bare format, is drawn again", () => { + const ss = storage({ "genclass:bucket": "observe@0.5" }); + expect(setup({ sample: 0.5, session: { id: ID }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled).toBe(false); + ss.m.set("genclass:bucket", "act@0.5"); + expect(setup({ sample: 0.5, session: { id: ID }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled).toBe(true); + for (const stale of ["act@0.9", "observe@0.9", "act", "observe", "act@", "nonsense"]) { + ss.m.set("genclass:bucket", stale); + expect(setup({ sample: 0.5, session: { id: ID }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled, stale).toBe(draw(0.5)); + expect(ss.m.get("genclass:bucket"), stale).toBe(draw(0.5) ? "act@0.5" : "observe@0.5"); + } + }); + + it("a fresh draw is stored with its sample, and reloading under the same sample keeps it", () => { + const ss = storage(); + const first = setup({ sample: 0.3, session: { id: ID }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled; + expect(first).toBe(draw(0.3)); + expect(ss.m.get("genclass:bucket")).toBe(first ? "act@0.3" : "observe@0.3"); + expect(setup({ sample: 0.3, session: { id: "another-id" }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled).toBe(first); + // sample: 1 never reads or writes it either + ss.m.set("genclass:bucket", "observe@1"); + expect(setup({ sample: 1, session: { id: ID }, extraGlobal: { sessionStorage: ss } }).rt.status.sampled).toBe(true); + expect(ss.m.get("genclass:bucket")).toBe("observe@1"); + }); +}); diff --git a/packages/runtime/test/policy.test.ts b/packages/runtime/test/policy.test.ts index c6b7bbcb..a458e401 100644 --- a/packages/runtime/test/policy.test.ts +++ b/packages/runtime/test/policy.test.ts @@ -43,6 +43,10 @@ describe("policy gate (CONTRACT §8)", () => { expect(modeAllows("guard", "heal")).toBe(false); expect(modeAllows("heal", "heal")).toBe(true); expect(modeAllows("observe", "passive")).toBe(true); + // a value that is not a mode fails closed (review of the mode-raise fix: it used to permit every tier) + expect(modeAllows("HEAL" as never, "heal")).toBe(false); + expect(modeAllows("" as never, "guard")).toBe(false); + expect(permittedActions(policyConfig({}), "Guard" as never, REQ).map((a) => a.name)).toEqual([]); const c = policyConfig({}); expect(permittedActions(c, "observe", REQ).map((a) => a.name)).toEqual([]); expect(permittedActions(c, "guard", REQ).map((a) => a.name)).toEqual(["coalesce", "delay"]); diff --git a/packages/runtime/test/status-scope.test.ts b/packages/runtime/test/status-scope.test.ts index 3f337183..35f38daf 100644 --- a/packages/runtime/test/status-scope.test.ts +++ b/packages/runtime/test/status-scope.test.ts @@ -30,3 +30,65 @@ describe("status.scope", () => { expect(rt.status.scope).toMatchObject({ mode: "observe", rule: 0, ceiling: "heal" }); }); }); + +// Route rules apply to the path the server sees. The browser never percent-decodes `location.pathname`, while most +// servers route on the decoded path and tolerate a trailing slash, repeated slashes, dot segments or `;` parameters, +// so a link to "/se%61rch" reached the same page as "/search" but escaped a rule scoped to "/search" (review of the +// mode-raise fix, 0.2.3 and 0.3.0-beta.5). A rule now matches the raw path or its normalised form; `scope.route` stays +// the raw path. Rules only lower the mode. +describe("route rules match look-alike paths (OPTIONS-SPEC §4.3)", () => { + const at = (pathname: string, routes: NonNullable[0]>["routes"], mode: "observe" | "guard" | "heal" = "guard") => + setup({ mode, routes, extraGlobal: { location: { href: `http://app.test${pathname}`, pathname, search: "" } } }).rt; + + it.each(["/se%61rch", "/search/", "//search", "/search;x=1", "/search;jsessionid=abc/", "/./search", "/x/../search", "/search%2F", "/%2Fsearch", "/search/."])( + "%s falls under an exact /search rule; scope.route stays raw", + (pathname) => { + const rt = at(pathname, [{ match: "/search", mode: "off" }]); + expect(rt.status.scope).toMatchObject({ route: pathname, rule: 0, mode: "off" }); + expect(rt.status.effectiveMode).toBe("off"); + }, + ); + + it("the raw path still matches as before, and a plain /search is unchanged", () => { + const rt = at("/search", [{ match: "/search", mode: "observe" }]); + expect(rt.status.scope).toMatchObject({ route: "/search", rule: 0, mode: "observe" }); + expect(at("/other", [{ match: "/search", mode: "off" }]).status.scope).toEqual({ route: "/other", mode: "guard", aggressiveness: 0.5 }); + }); + + it("matching is case-sensitive: /Search is not /search (use a RegExp with the i flag)", () => { + expect(at("/Search", [{ match: "/search", mode: "off" }]).status.scope).toEqual({ route: "/Search", mode: "guard", aggressiveness: 0.5 }); + expect(at("/Search/", [{ match: /^\/search$/i, mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + }); + + it("a glob /admin/* does not cover /admin itself; /admin* does, and the glob sees the normalised path too", () => { + expect(at("/admin", [{ match: "/admin/*", mode: "off" }]).status.scope.rule).toBeUndefined(); + expect(at("/admin", [{ match: "/admin*", mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + expect(at("/admin//users/", [{ match: "/admin/*", mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + expect(at("/%61dmin/users", [{ match: "/admin/*", mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + }); + + it("RegExp and predicate rules see both forms", () => { + expect(at("/search/", [{ match: /^\/search$/, mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + const seen: string[] = []; + expect(at("/se%61rch", [{ match: (r) => (seen.push(r), r === "/search"), mode: "off" }]).status.scope).toMatchObject({ rule: 0, mode: "off" }); + expect(seen).toEqual(["/se%61rch", "/search"]); + // a malformed escape keeps the raw path (and matches nothing else) + expect(at("/search%", [{ match: "/search", mode: "off" }]).status.scope.rule).toBeUndefined(); + }); + + it("a look-alike path runs no action under an observe rule (guard-tier delay scripted)", async () => { + const { defaultScript } = await import("./helpers.js"); + const s = setup({ + routes: [{ match: "/search", mode: "observe" }], + triage: "always", + script: defaultScript({ request: { diagnosis: "overload", action: "delay" } }), + extraGlobal: { location: { href: "http://app.test/se%61rch", pathname: "/se%61rch", search: "" } }, + }); + s.server.on("GET", "/api/x", { body: 1, latency: 5 }); + const p = s.fetch("/api/x").catch(() => undefined); + await s.clock.advance(2000); + await p; + expect(s.rt.status.effectiveMode).toBe("observe"); + expect(s.rt.interventions().length).toBe(0); + }); +});