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:
- The genesis JSON decoder applies prototype-pollution protection by raising on dangerous keys, but it does so via the JSON
reviveronly — there is no public sanitizer that downstream consumers can apply to already-decoded values. Consumers receiving anunknownfrom MessagePack, from a custom binary format, or from a pre-existing in-memory object cannot today say "strip__proto__/constructor/prototypekeys recursively." Upstream'sstripProtoKeys(value, allow)(packages/universal/serialize/src/security/proto-guard.tsL1-L30) covers exactly this case and is referenced byslsa,audit-logging,etl, and the network plugin protocol packages — all genesis dependents already shipping. - The genesis
codec/directory has nostructured-clonemodule. Upstream shipsdeepClone<T>(value: T): Tas a thin platform-structuredClonewrapper that throws a structuredSerializationErrorwhen the runtime lacks support, and re-exports it from the rootindex.ts. Genesis lost both the helper and the re-export — the onlystructuredClonereference left is a benchmark file. Consumers that need to defensively copy decoded payloads are forced to callglobalThis.structuredClonedirectly, 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 }): voidrecursively removes__proto__,constructor, andprototypekeys 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.tsexportscloneAndStrip<T>(value: T, options?: { allowDangerousKeys?: boolean }): T— non-mutating variant; deep-clones viadeepClonethen strips. - ADDED:
src/codec/structured-clone/structured-clone.ts—deepClone<T>(value: T): T; throwsSerializationError(STRUCTURED_CLONE.UNAVAILABLE) whenglobalThis.structuredCloneis missing (older Node, restricted runtimes). - ADDED:
src/codec/structured-clone/index.tsbarrel re-exportingdeepClone. - ADDED: subpath export
./security/proto-guardand./codec/structured-cloneinpackage.json::exports. - ADDED: error codes
STRUCTURED_CLONE.UNAVAILABLE,PROTO_GUARD.FORBIDDEN_KEYregistered insrc/error-codes.ts. - MODIFIED:
src/index.tsre-exportsdeepCloneandstripPrototypePollutersfrom the root barrel for convenience (matches upstream root-export shape). - MODIFIED:
src/codec/json/json-decode.ts— whenoptions.stripPrototypePolluters === true, the decoder callsstripPrototypePolluters(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.stripannotated withkeysRemoved: numberfor observability.
Out of scope
- Sandbox-policy enforcement. The eval-safe vs
./unsafesubpath split already covers the policy boundary for what code paths can calleval/Function. This change does not touch that split. - Cross-realm cloning.
deepClonedefers toglobalThis.structuredCloneand inherits whatever cross-realm behaviour the platform provides. No iframe / Worker / Cross-Origin Isolated handling. - Frozen-object preservation. Upstream's
proto-guardmutates in place; the cloned variant viacloneAndStripis the documented escape hatch. Adding "produce aObject.freeze'd output" is a separate proposal if ever requested. - MessagePack proto-guard hook. The MessagePack decoder does not currently expose a
stripPrototypePollutersflag. 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.
stripPrototypePollutersis 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;cloneAndStrippreserves the original.src/security/proto-guard/proto-guard.spec-conformance.test.ts—allowDangerousKeys: trueno-ops; OTel span event emitted withkeysRemovedcount via real in-memoryMeterProvider/TracerProvider.src/codec/structured-clone/structured-clone.test.ts— round-trip for primitives, objects, arrays, nested structures, Map, Set, Date, Uint8Array;deepClone(undefined)returnsundefined.src/codec/structured-clone/structured-clone.spec-conformance.test.ts—SerializationErrorwithSTRUCTURED_CLONE.UNAVAILABLEthrown whenglobalThis.structuredCloneis replaced withundefined; subpath import resolves.src/codec/json/json-decode.spec-conformance.test.ts— new scenario assertingstripPrototypePolluters: trueruns 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 onlyserialize/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 fromstripPrototypePolluters; doc recommends it but does not require it)@suaveplan/audit-logging— [x] tests run / [x] docs reviewed / [ ] no action needed (same as slsa — recommendstripPrototypePolluterson imported audit log batches)@suaveplan/etl— [x] tests run / [x] docs reviewed / [ ] no action needed (decoded source records benefit fromstripPrototypePollutersbefore 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.