fix(platform,ui,browser,data) extend core hardening invariants to pub… - #56
Merged
Conversation
…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.
…vigation() auto-only (#56)
…nts ownership generations (#56)
…eentrant boundary (#56)
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 })wrotejavascript:straight to the DOM while the identicalbindAttrs(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.
createMicroAppcleared with a barereplaceChildren(), leaking a component tree per remount.defineRemoteComponentinstantiated into containers disposed while its loader was in flight.Async ownership — completion after disposal is a no-op.
infiniteScrolldropped a floating promise (unhandled rejection on a failed page of data);clipboard/permissionswrote 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/titleand<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 reactivenullwrote 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:
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 refusedon*value left an existingonclickin 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 anon*slot now clears it."VALUE"fell back to content-attribute semantics and left a dirtied control stale. Folding is HTML-only; SVG'sviewBox/patternUnitsmust not be touched.loadWasmModule()'s second parameter acceptedWebAssembly.Imports | LoadWasmOptionsand guessed. Both are plain objects with caller-chosen keys, so no structural test can separate them: an options bag holding onlyimports/cacheKeywas read as a namespace andcacheKeywas dropped, defeating the documented keyed singleton. The union is removed — see Breaking below. LikewisebindAttrs/bindDataexcludednull/undefinedwhile the runtime removes on them.: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.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:
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.popstateautomatically 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, exposesonNavigation(key)as the deliberate hook for entries the app creates, and leaseshistory.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.falsepath. The rejection is preserved rather than folded intofalse— reportingfalsewould 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) andErrorcause(Chrome 93) are too, andObject.hasOwnsits inside the reactive core. PolyfillingreplaceChildrenalone 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
Contract changes (non-breaking, but observable):
scrollRestoration()gainsonNavigation(key); auto mode now tags the initial history entry and takes overhistory.scrollRestorationwhile 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-workerunregister()whose native call rejects now propagates that rejection and leaves the wrapper managing the still-live registration.Breaking:
loadWasmModule()'s second parameter is nowWebAssembly.Importsonly — the options form is the newloadWasmModuleWithOptions(source, options). Migration is mechanical and the compiler finds every site; positional(source, imports, cacheKey)calls are unchanged, andwasm()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.extendsremoved (declared, read nowhere, Safari never shipped customized built-ins); reactivenull/undefinednow removes an attribute instead of writing"null".Checklist
Two things a reviewer should not take on trust:
certify:rcdoes 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.0isNOT TESTEDbecause 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 runsnpm install+buildimmediately 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.mdandasync-ownership.mdextended;support-matrix.mdrecords the new floor and why.