Files
2026-05-27 23:29:53 +01:00

11 KiB

Serialize proto-guard + structured-clone surface

Package: @suaveplan/serialize Status: PROPOSED Type: feature + spec-conformance

Why

Two pieces of upstream @suaveplan/serialize did not migrate cleanly into genesis: the security/proto-guard prototype-pollution stripper and the codec/structured-clone deep-clone wrapper. The 2026-05-02 audit found:

  1. The genesis JSON decoder applies prototype-pollution protection by raising on dangerous keys, but it does so via the JSON reviver only — there is no public sanitizer that downstream consumers can apply to already-decoded values. Consumers receiving an unknown from MessagePack, from a custom binary format, or from a pre-existing in-memory object cannot today say "strip __proto__ / constructor / prototype keys recursively." Upstream's stripProtoKeys(value, allow) (packages/universal/serialize/src/security/proto-guard.ts L1-L30) covers exactly this case and is referenced by slsa, audit-logging, etl, and the network plugin protocol packages — all genesis dependents already shipping.
  2. The genesis codec/ directory has no structured-clone module. Upstream ships deepClone<T>(value: T): T as a thin platform-structuredClone wrapper that throws a structured SerializationError when the runtime lacks support, and re-exports it from the root index.ts. Genesis lost both the helper and the re-export — the only structuredClone reference left is a benchmark file. Consumers that need to defensively copy decoded payloads are forced to call globalThis.structuredClone directly, losing the structured-error contract and runtime-availability guard.

Both gaps are dependent on the existing serialize public surface; both are small enough to ship together as one coordinated patch. Doing so closes the loop on "every decoded value can be hardened against prototype pollution AND defensively cloned through one package" without re-opening the eval-safe / /unsafe subpath split, which already covers the policy boundary.

What changes

@suaveplan/serialize

  • ADDED: src/security/proto-guard/proto-guard.ts — stripPrototypePolluters(value: unknown, options?: { allowDangerousKeys?: boolean }): void recursively removes __proto__, constructor, and prototype keys from any plain-object or array node in the tree (no traversal into class instances, Maps, Sets, Dates, Uint8Arrays, etc.). Mutates in place.
  • ADDED: src/security/proto-guard/proto-guard.ts exports cloneAndStrip<T>(value: T, options?: { allowDangerousKeys?: boolean }): T — non-mutating variant; deep-clones via deepClone then strips.
  • ADDED: src/codec/structured-clone/structured-clone.ts — deepClone<T>(value: T): T; throws SerializationError (STRUCTURED_CLONE.UNAVAILABLE) when globalThis.structuredClone is missing (older Node, restricted runtimes).
  • ADDED: src/codec/structured-clone/index.ts barrel re-exporting deepClone.
  • ADDED: subpath export ./security/proto-guard and ./codec/structured-clone in package.json::exports.
  • ADDED: error codes STRUCTURED_CLONE.UNAVAILABLE, PROTO_GUARD.FORBIDDEN_KEY registered in src/error-codes.ts.
  • MODIFIED: src/index.ts re-exports deepClone and stripPrototypePolluters from the root barrel for convenience (matches upstream root-export shape).
  • MODIFIED: src/codec/json/json-decode.ts — when options.stripPrototypePolluters === true, the decoder calls stripPrototypePolluters(parsed) after the existing reviver pass. Default: false (the reviver-only path remains the documented eval-safe default).
  • ADDED: OTel span event serialize.proto-guard.strip annotated with keysRemoved: number for observability.

Out of scope

  • Sandbox-policy enforcement. The eval-safe vs ./unsafe subpath split already covers the policy boundary for what code paths can call eval/Function. This change does not touch that split.
  • Cross-realm cloning. deepClone defers to globalThis.structuredClone and inherits whatever cross-realm behaviour the platform provides. No iframe / Worker / Cross-Origin Isolated handling.
  • Frozen-object preservation. Upstream's proto-guard mutates in place; the cloned variant via cloneAndStrip is the documented escape hatch. Adding "produce a Object.freeze'd output" is a separate proposal if ever requested.
  • MessagePack proto-guard hook. The MessagePack decoder does not currently expose a stripPrototypePolluters flag. Adding one is trivial but extends the public surface; deferred to a follow-up if a consumer asks. JSON gets the flag because that's where prototype pollution is most exploitable.
  • Replacing the JSON reviver guard. The reviver-based check stays — it's a stricter pre-build guard and rejects malformed input before an object is ever constructed. stripPrototypePolluters is a post-build sanitizer for decoded-elsewhere values.

Parity Checklist

Capability Upstream Migrated Notes
stripProtoKeys(value, allow) ✅ ⬆️ Renamed to stripPrototypePolluters (clearer name); takes { allowDangerousKeys?: boolean } options object instead of positional boolean.
cloneAndStrip 🚫 (not in upstream) ⬆️ Added: non-mutating variant for callers that must preserve the input.
deepClone(value) ✅ ✅ Verbatim port; same SerializationError on missing globalThis.structuredClone.
Root-barrel re-export of deepClone ✅ ✅ Restored.
Root-barrel re-export of stripPrototypePolluters 🚫 (not in upstream) ⬆️ Added: matches the discoverability of serialize / deserialize.
OTel span event for proto-guard 🚫 (not in upstream) ⬆️ Added per genesis-wide observability rule.
JSON decoder stripPrototypePolluters option 🚫 (not in upstream) ⬆️ Added: opt-in post-pass; default off keeps the existing eval-safe contract.

Source mapping

This change derives from ../suaveplan/packages/universal/serialize/src/security/proto-guard.ts and ../suaveplan/packages/universal/serialize/src/codec/structured-clone/structured-clone.ts.

Requirement Source file Source lines Notes
stripPrototypePolluters (recursive, in-place, plain-object/array only) packages/universal/serialize/src/security/proto-guard.ts L1-L30 Verbatim port; renamed function; boolean second arg → { allowDangerousKeys } options.
deepClone over globalThis.structuredClone with structured error fallback packages/universal/serialize/src/codec/structured-clone/structured-clone.ts L1-L18 Verbatim port; SerializationError instead of legacy class.
Root-barrel re-export of deepClone packages/universal/serialize/src/index.ts L17 (line listing deepClone in the export block) Restore the export.

Requirements with no source line

Requirement Justification
cloneAndStrip non-mutating variant Mutating decoded payloads breaks immutable-state consumers (@suaveplan/event-sourcing). Upstream sidestepped this by always recommending callers clone first, but bundling clone+strip into one helper is a clearer ergonomic.
OTel span event serialize.proto-guard.strip with keysRemoved count Genesis-wide observability rule — every public production code path emits passive OTel data. Upstream had no tracing here.
Error codes STRUCTURED_CLONE.UNAVAILABLE, PROTO_GUARD.FORBIDDEN_KEY Genesis-wide policy that every thrown error route through @suaveplan/error with a registered stable code. Upstream throws ad-hoc SerializationError instances; we register codes so consumers can instanceof + code-check.
Subpath exports ./security/proto-guard and ./codec/structured-clone Genesis-wide rule (AGENTS.md §6) — every feature module gets a tree-shakeable subpath. Upstream re-exported only via the root barrel.
JSON decoder stripPrototypePolluters option Genesis-wide eval-safe contract — the option must be explicit, opt-in, and observable; default behaviour does not change.

Verification approach

  • src/security/proto-guard/proto-guard.test.ts — recursion across plain objects and arrays; non-traversal into class instances / Map / Set / Date / Uint8Array; in-place mutation; cloneAndStrip preserves the original.
  • src/security/proto-guard/proto-guard.spec-conformance.test.ts — allowDangerousKeys: true no-ops; OTel span event emitted with keysRemoved count via real in-memory MeterProvider / TracerProvider.
  • src/codec/structured-clone/structured-clone.test.ts — round-trip for primitives, objects, arrays, nested structures, Map, Set, Date, Uint8Array; deepClone(undefined) returns undefined.
  • src/codec/structured-clone/structured-clone.spec-conformance.test.ts — SerializationError with STRUCTURED_CLONE.UNAVAILABLE thrown when globalThis.structuredClone is replaced with undefined; subpath import resolves.
  • src/codec/json/json-decode.spec-conformance.test.ts — new scenario asserting stripPrototypePolluters: true runs the post-pass (count of removed keys observed via OTel span event).

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/serialize to enumerate:

  • @suaveplan/network-plugin-protocol — [ ] tests run / [ ] docs reviewed / [x] no action needed (consumes only serialize / deserialize / decodeBinary / encodeBinary; new exports are additive)
  • @suaveplan/network-websockets — [ ] tests run / [ ] docs reviewed / [x] no action needed (same as above)
  • @suaveplan/network-plugin-webrtc — [ ] tests run / [ ] docs reviewed / [x] no action needed (same as above)
  • @suaveplan/slsa — [x] tests run / [x] docs reviewed / [ ] no action needed (decoded provenance values benefit from stripPrototypePolluters; doc recommends it but does not require it)
  • @suaveplan/audit-logging — [x] tests run / [x] docs reviewed / [ ] no action needed (same as slsa — recommend stripPrototypePolluters on imported audit log batches)
  • @suaveplan/etl — [x] tests run / [x] docs reviewed / [ ] no action needed (decoded source records benefit from stripPrototypePolluters before transformation; doc recommends it)

@suaveplan/serialize is not on the foundation-package list (error, types, events, logger, validation), but it is depended on by ≥4 downstream packages so the dependent sweep (§13 step 10) is run anyway:

bunx turbo test --filter=...@suaveplan/serialize

Migration impact

None — purely additive. The JSON decoder gains an opt-in option whose default is false, so existing call sites are unaffected. The root barrel gains two new exports (deepClone, stripPrototypePolluters) — name collisions with existing consumer code would have surfaced already and none have been reported.