Skip to content

fix(platform,ui,browser,data) extend core hardening invariants to pub… - #56

Merged
hexplus merged 8 commits into
mainfrom
chore/56-framework-hardening
Aug 27, 2026
Merged

fix(platform,ui,browser,data) extend core hardening invariants to pub…#56
hexplus merged 8 commits into
mainfrom
chore/56-framework-hardening

Conversation

@hexplus

@hexplus hexplus commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Description

Extends the four ownership invariants the reactive core already enforces to the rest of the public API surface (extras, browser, platform, ui, data helpers). The 17 audited findings were not unrelated bugs — they were four invariants that stopped at the core's edge, so the fix is four shared primitives plus migration, each pinned by regressions written and run against unmodified HEAD before any change.

Security — one attribute policy. bindAttrs(a, { href: url }) wrote javascript: straight to the DOM while the identical bindAttrs(a, { href: () => url }) blocked it; svgElement("svg", { onload: "…" }) installed a live handler the HTML factory had always refused. The divergence was the vulnerability — a routine refactor silently changed security posture. Every writer (tag factory, bindAttribute/bindDynamic, bindAttrs/bindBoolAttr/bindData, svgElement, enhance().attr()) now commits through one primitive. The policy is unchanged; what changed is that no writer can skip it.

Lifecycle — DOM removal implies disposal. createMicroApp cleared with a bare replaceChildren(), leaking a component tree per remount. defineRemoteComponent instantiated into containers disposed while its loader was in flight.

Async ownership — completion after disposal is a no-op. infiniteScroll dropped a floating promise (unhandled rejection on a failed page of data); clipboard/permissions wrote state after teardown.

Wrapper parity. wasm(url) could not express the origin policy its loader requires, so every URL load was refused. Keyed WASM loads instantiated twice under concurrency, breaking the documented singleton.

Also: Head/title and <base> moved to an owner stack (per-instance snapshots are provably wrong with three overlapping owners); scoped styles anchored at the component root so descendants created later are covered; scrollRestoration({ mode: "auto" }) now actually restores.

Two defects the repo-wide audits surfaced beyond the brief: enhance().attr() was a raw attribute sink, and a reactive null wrote the literal string "null".

Corrective pass (review round 2). Six follow-ups, each reproduced before being changed, and each an extension of an invariant above rather than a new project:

  • Security is a postcondition, not a promise about one write. These APIs attach to DOM that already exists. enhance().attr() compared its RAW value against the RAW attribute and skipped the write when they matched — so <a href="javascript:…"> re-bound to that same string never reached the sanitizer. And a refused on* value left an existing onclick in place: declining to add a handler while the page still had one. Write elision moved inside the primitive and compares the post-policy result; a binding that claims an on* slot now clears it.
  • One attribute policy, case-correctly. HTML attribute names are case-insensitive, but the IDL decisions were not — "VALUE" fell back to content-attribute semantics and left a dirtied control stale. Folding is HTML-only; SVG's viewBox/patternUnits must not be touched.
  • Public signatures must describe the runtime. loadWasmModule()'s second parameter accepted WebAssembly.Imports | LoadWasmOptions and guessed. Both are plain objects with caller-chosen keys, so no structural test can separate them: an options bag holding only imports/cacheKey was read as a namespace and cacheKey was dropped, defeating the documented keyed singleton. The union is removed — see Breaking below. Likewise bindAttrs/bindData excluded null/undefined while the runtime removes on them.
  • Parsing, not pattern-matching. Scoped-style selector lists were regex-split on commas, tearing apart :is(.a, .b) and commas inside attribute values — :is(.a, .b) became [s] :is(.a, :is(.a[s],[s] .b), .b)[s], which selects neither arm. One scanner now tracks paren/bracket/quote/comment/escape state for both list splitting and rule boundaries.
  • Coalesced owned async operations. The pending-registration path had two callers of the native unregister(), so a refused attempt was followed by a second against the registration just re-adopted. It is now one owned in-flight operation with a single call site.

Corrective pass (review round 3). Three follow-ups, each reproduced first, each an extension of an invariant already above:

  • A cache clear must be an invalidation barrier. clearWasmCache() emptied three Maps, which only erases what is already written — work already in flight held those same maps and wrote into them on settle, so a caller clearing the cache to force a fresh load could have the invalidated module reinstated by the very work it invalidated. Clearing is now generational: the generation advances before the maps empty, and all four producers (instantiateStreaming, compile, the instance publish, preloadWasm) may publish only into the generation they began in. A pre-clear load still resolves to its own caller; it simply cannot repopulate.
  • Auto scroll restoration must own entry identity, or stop claiming automation. It reacted to popstate automatically but nothing ever tagged the entry the page started on, so the first Back — the one users actually press — restored nothing. Auto mode now tags the initial entry, exposes onNavigation(key) as the deliberate hook for entries the app creates, and leases history.scrollRestoration = "manual" while active so the browser is not restoring in parallel. The lease reuses the owner-stack primitive, so overlapping controllers cannot hand native restoration back early.
  • A failed unregister must restore ownership before propagating. A native rejection is not evidence of removal, but a pending registration withheld for removal was dropped when the call threw: the caller got an error and SibuJS had forgotten the worker the browser still had. Ownership now reverts first, via a helper shared with the returns-false path. The rejection is preserved rather than folded into false — reporting false would claim the browser answered when it never did.

Finding #1 was worse than audited. replaceChildren() is not the only API above the declared floor — Object.hasOwn() (Chrome 93) and Error cause (Chrome 93) are too, and Object.hasOwn sits inside the reactive core. Polyfilling replaceChildren alone would have left the bundle equally broken on Chrome 80–92, so the floor now states what the source actually requires, enforced by a static gate.

Related Issue

Closes #56

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Contract changes (non-breaking, but observable): scrollRestoration() gains onNavigation(key); auto mode now tags the initial history entry and takes over history.scrollRestoration while active (restored on dispose). clearWasmCache() is now specified as an invalidation barrier — work in flight across it resolves to its caller but never repopulates. A service-worker unregister() whose native call rejects now propagates that rejection and leaves the wrapper managing the still-live registration.

Breaking: loadWasmModule()'s second parameter is now WebAssembly.Imports only — the options form is the new loadWasmModuleWithOptions(source, options). Migration is mechanical and the compiler finds every site; positional (source, imports, cacheKey) calls are unchanged, and wasm() is unaffected. Also: browser floor raised to Chrome/Edge 93, Firefox 92, Safari 15.4 (the old floor was already false — the shipped bundle threw on Chrome 80–92); CustomElementOptions.extends removed (declared, read nowhere, Safari never shipped customized built-ins); reactive null/undefined now removes an attribute instead of writing "null".

Checklist

  • I have read CONTRIBUTING.md
  • My code builds without errors
  • I have tested my changes
  • I have updated documentation if needed
Full suite      417 files / 5383 passed / 1 skipped / 0 failed  (was 395 / 5074)
New suites       22 files / 319 tests + 1 browser spec (30 runs)
Browser matrix  246/246 — Chromium, Firefox, WebKit
Soak             25 passed / 1 skipped
Typecheck        src 0 errors · tests+entries 0 errors
Lint             617 files, 0 errors
certify:rc       14 PASS / 1 FAIL

Two things a reviewer should not take on trust:

certify:rc does not report a clean pass on the author's host (CI is the authority here — see the checks on this PR). The one failing gate is Node support matrix, and the captured failure under load is [vitest-worker]: Timeout calling "onTaskUpdate" — vitest's worker RPC starving, not an assertion. Standalone, both interpreters pass (Node 22 → 5290, Node 24 → 5290, RESULT: PASS), and across three certify runs the failing version alternated. 22.3.0 is NOT TESTED because that interpreter isn't installed here. This gate needs to go green in CI before stable.

One test-infrastructure change, not a product fix: hardening.test.ts's 10,000-item stress cases sat on a 15s watchdog while taking 13–18s cold (certify runs npm install + build immediately before). Raised to 45s. I verified by stashing the entire change set that this fails identically on unmodified HEAD, and separately that the warmed timing is unchanged (HEAD 3.8s vs 3.9–4.0s).

Benchmarks were not re-recorded. The suite reported 34–40 "regressions", but pure-signal benchmarks untouched by this PR appeared at both +212% and −64% with CPU at 99%. Same-window A/B vs HEAD: median −3.6%. Interleaved measurement of the one real hot-path change (tagFactory's per-attribute commit): +6.1% / −4.0% / +6.1%, within this host's documented ±20% drift.

Docs: new docs/architecture/attribute-security.md; dom-ownership.md and async-ownership.md extended; support-matrix.md records the new floor and why.

…nd remove ambiguous APIs (#56)

Corrective pass on the public-API hardening in #56. Each item was reproduced
against d1ef972 before being changed.

- wasm: loadWasmModule()'s second parameter accepted
  `WebAssembly.Imports | LoadWasmOptions` and guessed between them by probing
  for allowedOrigins/unsafelyAllowAnyOrigin. Both shapes are plain objects with
  caller-chosen keys, so no structural test can separate them: an options bag
  holding only imports/cacheKey was read as an import namespace and cacheKey was
  silently dropped, defeating the documented keyed singleton. The union is gone;
  loadWasmModuleWithOptions() is the options form. BREAKING.

- scopedStyle: selector preludes were split with a regex over commas, tearing
  apart :is()/:where()/:not() argument lists and commas inside attribute values
  or strings. Splitting and rule-boundary detection now use one scanner that
  tracks paren, bracket, quote, comment and escape state, so @media/@supports/
  @layer recurse while @Keyframes bodies are left alone without special-casing
  from/to/percentage stops.

- serviceWorker: the pending-registration path had two callers of the native
  unregister, so a refused attempt was followed by a second one against the
  registration just re-adopted. Unregistration is now a single owned in-flight
  operation with one call site; concurrent callers join it, and a registration
  arriving during a removal is withheld rather than transiently published.

- attributes: security is now a postcondition on the managed attribute. Write
  elision moved into the shared primitive and compares the post-policy result,
  so a caller can no longer skip the sanitizer by pre-comparing raw values
  against DOM that already holds them; a refused on* value clears the slot the
  binding claimed instead of leaving an existing handler in place.

- attributes: IDL synchronisation folds case for HTML only, so "VALUE" reaches
  the live property as "value" does. SVG names stay case-sensitive.

- types: bindAttrs()/bindData() accept null and undefined, matching the runtime
  removal semantics documented in #56, via shared AttributeValue/AttributeSource.
…ister truthful (#56)

Final corrective pass. Each issue was reproduced against f1c0dfc first.

- wasm: clearWasmCache() emptied three Maps, which only erases what is already
  written. A compile or instantiation already in flight held those same global
  maps and wrote into them on settle, so a caller clearing the cache to force a
  fresh load could have the invalidated module reinstated by the very work it
  invalidated. Clearing is now a generational barrier: the generation advances
  before the maps are emptied, and every async producer captures its generation
  and may publish only while that is still live. All four producers are covered
  (instantiateStreaming, compile, instance publish, preloadWasm). A pre-clear
  load still resolves normally to its own caller; it just cannot repopulate.

- scrollRestoration: "auto" reacted to popstate automatically but never owned
  entry identity, so the entry the page started on had no key and the first Back
  restored nothing. Auto mode now tags the initial entry, exposes onNavigation()
  as the deliberate hook for entries the application creates, and leases
  history.scrollRestoration = "manual" while active so the browser does not
  restore in parallel. The lease uses the existing owner-stack primitive, so
  overlapping controllers cannot hand native restoration back early. The
  documented contract now matches the mechanism: auto mode restores entries it
  was given identity for and never guesses about ones it was not told about —
  a library cannot observe arbitrary pushState without patching history
  globally, and that trade is not worth making.

- serviceWorker: a native unregister REJECTION is not evidence of removal, but
  a pending registration withheld for removal was dropped on the floor when the
  call threw — the caller got an error and SibuJS had forgotten the worker the
  browser still had. Ownership now reverts before the rejection propagates, via
  a helper shared with the returns-false path since both mean "the worker is
  still there". The rejection is preserved rather than folded into false;
  reporting false would claim the browser answered when it never did.
#56)

CI caught an unhandled rejection escaping the new rejection suite: every
assertion passed (417 files / 5383 tests) but vitest exited 1 on "Errors 2
errors". Reproduced on Linux CI across all four unit jobs; the Windows dev host
happened to schedule it differently and stayed green.

The defect was in the test, not the source. `const p = sw.unregister()` followed
by `await flush()` before `await expect(p).rejects` leaves the promise rejecting
with no handler attached for a full macrotask, which is exactly when the runtime
reports it as unhandled. A `settle()` helper now attaches handlers at creation,
so the rejection is observed rather than escaping.

Worth stating plainly: an unhandled rejection escaping into the process is the
same defect class this PR exists to remove from infiniteScroll and the remote
component loader. The test suite is held to the discipline it is testing for.
@hexplus
hexplus merged commit cad1c10 into main Aug 27, 2026
5 checks passed
@hexplus
hexplus deleted the chore/56-framework-hardening branch August 28, 2026 10:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant