Receiver: fail closed on hostile streams; split the DOM driver out of mount - #14
Merged
Merged
Conversation
… mount
Two tracks, one purpose: a policy (docs/design.md "Policy") is only as
good as the receiver behind it, and a receiver that cannot be driven
without a component cannot sit behind a frame boundary at all.
Hardening (native.ts, receiver.ts, frames.ts):
- Mount root (id 0) is structurally inviolable: no create/re-register/
alias/move/remove, and not usable as an insert anchor — with `parent`
omitted, anchor 0 implied the embedder's container as parent and put a
producer node beside the mount; the fuzzer found this.
- Ids: re-registering a live id or binding a node that already has an id
throws. Reuse after `remove` is documented as undetectable.
- Type checks: set-text requires a text/comment node; set-attribute and
set-property require an element.
- ListenerRegistry.stringFor throws on an unknown ref instead of
returning "" (define-before-use per the proto; re-interning a live slot
stays legal per the proto's "Define (or overwrite)").
- FrameDecoder: MAX_FRAME_BYTES (16 MiB) ceiling on the length prefix, so
a hostile length no longer buffers forever. #forgetSubtree is iterative.
- Tests: native_test.ts (the native receiver's first unit tests, under
linkedom with a shim reproducing the browser's HierarchyRequestError
guarantee) and hostile_test.ts (seeded structured-op fuzzer, 3000 runs;
byte-mutation fuzzer over basic.pb, 20000 runs; root and sibling
sentinel invariants after every push or throw).
Driver split (driver.ts new, mount.ts 629 -> 208 lines):
- createDriver({ root, receiver, policy, onError, handleEvent }) owns
backend selection, policy compilation, decoding under the dispatch
gate, listener delegation/attach/detach, synthetic navigation, payload
encoding, and the policy-gated queries. Bytes in via push(), events out
via the callback. A move, not a rewrite; MountOptions/Mounted unchanged.
- mount.ts is the component glue: instantiate, wasi imports, the dom-event
resource, run/handle-event, the two read transports, stream.drop().
- driver_test.ts: bytes in / DOM out with no component, events out with
and without an event-field filter, declarative prevent_default, gated
queries, PolicyError and unknown-id aborts, teardown.
Design record: "Policy" now states what the native receiver guarantees
underneath a policy and what it cannot see; the driver makes the
Transports claim about non-component receivers true. linkedom added as a
test dependency.
Gates: deno check/test (92), fmt, lint, just e2e (both receivers x both
producers), just bench-wire (all wire shapes match baseline).
lannbot
pushed a commit
that referenced
this pull request
Sep 6, 2026
…laration; asset handles Replaces #13's declaration model (accept/events/queries name lists, SURFACE_V1, compilePolicy, bitmask strict decoding, event-field filter) with a smaller mechanism of the same fail-safe property; keeps #14's hardening and driver split. - Policy { version, check(op), query?(name) }. `version` pins the `PROTOCOL VERSION` from the proto header; createDriver refuses any other, so a receiver upgrade cannot silently widen what a policy reviewed. `check` sees createElement/setAttribute/setProperty/ addListener/bindMarker with interned strings resolved and the element's tag (tracked through clone-template/bind-path); templates are checked once at register-template, flattened into the same shapes. A rejection aborts the stream with a PolicyError. `query` gates the WIT queries; refusal answers none/false. - Strict decoding whenever a policy is present: one boolean per skip branch; unknown ops, fields and enum values reject. - `PROTOCOL VERSION: 1` in the proto header, mirrored as stream_dom_proto::PROTOCOL_VERSION (build.rs) and the receiver's PROTOCOL_VERSION (test re-reads the .proto). - SetAttribute.value / TemplateAttr.value gain an `asset` arm resolved by `resolveAsset`; wire-compatible, bench baseline unchanged. Batch::set_attribute_asset on the Rust side. - Backends and PolicySink pin template tags, attribute names and values at registration, so a later re-intern cannot make the applied DOM diverge from what the policy approved. remote.ts now rejects an un-interned template ref at registration, as native always has. - events.ts restored byte-for-byte to its pre-#13 form. - docs/design.md: Policy section rewritten for this model (records the declaration alternative and why it was removed); new "Assets are handles" decision; open question 10 updated.
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.
Follow-up to #13, the two items it named as open. Both are needed for a policy to mean anything: the receiver behind it must fail closed on its own, and it must be drivable without a component to sit behind a frame boundary.
Hardening (
native.ts,receiver.ts,frames.ts)parentomitted, anchor 0 implied the embedder's container as parent and placed a producer node beside the mount. The fuzzer found this one.bind-pathwith an empty path), throws. Reuse of an id freed byremoveis documented as undetectable and deliberately not enforced.set-textrequires a text/comment node (it used to install an expando on elements);set-attribute/set-propertyrequire an element.stringForthrows on an unknown ref rather than returning""(define-before-use per the proto). Re-interning a live slot remains legal per the proto's "Define (or overwrite)".MAX_FRAME_BYTES(16 MiB) on the length prefix so a hostile length can't make the decoder buffer forever; truncated sub-message inside a complete frame confirmed to abort (test pins it).#forgetSubtreeis iterative — a 50k-deep chain used to overflow the stack on removal.native_test.ts: the native receiver's first unit tests (18), under linkedom with a small shim reproducing the browser'sHierarchyRequestErroron ancestor inserts — the receiver relies on the real DOM for acyclicity and does not duplicate the walk.hostile_test.ts: a seeded structured-op fuzzer (3000 runs, random chunk splits, ≥10 distinct rejection kinds asserted) and a byte-mutation fuzzer overbasic.pb(20 000 runs), both asserting the mount root and a sibling sentinel are untouched after every push or throw.Not hardened: the remote-dom backend; template-arena recursion depth at registration (noted in the design record).
Driver split (
driver.tsnew;mount.ts629 → 208 lines)createDriver({ root, receiver, policy, onError, handleEvent })owns everything that never needed a component: backend selection, policy compilation and theFrameSinkwrap, decode under the dispatch gate, listener delegation / refcounting / passive upgrade, synthetic navigation, payload encoding with the policy's event filter, and the policy-gatedqueries. Bytes in throughpush(), events out through the callback.mount.tsis now the component glue only. A move, not a rewrite;MountOptions/Mountedare unchanged and every existing caller is untouched.driver_test.ts(9): bytes in / DOM out with no component, split mid-frame; events out with and withoutEventPayload.mousedeclared; declarativeprevent_default; gated queries;PolicyErrorand unknown-id aborts; teardown.Design record
"Policy" now states what the native receiver guarantees underneath a policy and the two things it cannot see; a paragraph on the driver makes the Transports claim ("a receiver outside any component is a first-class implementation") true rather than asserted.
linkedomadded as a test-only dependency.Gates
deno task check,deno task test(92 passed),deno fmt --check,deno lint,just e2e(both receivers × both producers, locally),just bench-wire(all wire shapes match baseline).Automerge is armed.