Skip to content

Receiver: fail closed on hostile streams; split the DOM driver out of mount - #14

Merged
lannbot merged 2 commits into
mainfrom
hardening-and-driver
Sep 6, 2026
Merged

lannbot merged 2 commits into
mainfrom
hardening-and-driver

Conversation

@lannbot

@lannbot lannbot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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)

  • Mount root is structurally inviolable. No create / re-register / alias / move / remove of id 0, and id 0 is not a legal insert anchor: with parent omitted, anchor 0 implied the embedder's container as parent and placed a producer node beside the mount. The fuzzer found this one.
  • Ids. Re-registering a live id, or binding a node that already carries an id (bind-path with an empty path), throws. Reuse of an id freed by remove is documented as undetectable and deliberately not enforced.
  • Type checks. set-text requires a text/comment node (it used to install an expando on elements); set-attribute / set-property require an element.
  • stringFor throws 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)".
  • Decoder. 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). #forgetSubtree is iterative — a 50k-deep chain used to overflow the stack on removal.
  • Tests. native_test.ts: the native receiver's first unit tests (18), under linkedom with a small shim reproducing the browser's HierarchyRequestError on 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 over basic.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.ts new; mount.ts 629 → 208 lines)

createDriver({ root, receiver, policy, onError, handleEvent }) owns everything that never needed a component: backend selection, policy compilation and the FrameSink wrap, decode under the dispatch gate, listener delegation / refcounting / passive upgrade, synthetic navigation, payload encoding with the policy's event filter, and the policy-gated queries. Bytes in through push(), events out through the callback. mount.ts is now the component glue only. A move, not a rewrite; MountOptions / Mounted are 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 without EventPayload.mouse declared; 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 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. linkedom added 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.

… 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
lannbot merged commit a7e5dce into main Sep 6, 2026
1 check passed
@lannbot
lannbot deleted the hardening-and-driver branch September 6, 2026 19:16
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.
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.

2 participants