6.8 KiB
Capability Spec Delta: serialize (serialize-proto-guard)
Package: @suaveplan/serialize
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: Prototype-pollution stripper
The package SHALL expose stripPrototypePolluters(value: unknown, options?: { allowDangerousKeys?: boolean }): void that recursively removes the __proto__, constructor, and prototype keys from every plain-object and array node it encounters in value. The function SHALL mutate the input tree in place. Class instances, Map, Set, Date, Uint8Array, and other typed objects MUST NOT be traversed (their internal slots are not addressable as object keys). When options.allowDangerousKeys === true, the function SHALL be a no-op.
Scenario: dangerous keys are stripped recursively
- WHEN a caller invokes
stripPrototypePolluters({ a: { __proto__: {}, b: { constructor: 1 } } }) - THEN the input mutates to
{ a: { b: {} } }(both__proto__andconstructorremoved)
Scenario: class instances are not traversed
- WHEN a caller invokes
stripPrototypePolluters({ d: new Date(0), m: new Map([["__proto__", 1]]) }) - THEN the
DateandMapare unchanged (their internal slots are not addressed as enumerable string keys)
Scenario: allowDangerousKeys is a no-op
- WHEN a caller invokes
stripPrototypePolluters({ __proto__: {} }, { allowDangerousKeys: true }) - THEN the input is unchanged
Verified by: src/security/proto-guard/proto-guard.test.ts::"strips __proto__ / constructor / prototype recursively" AND src/security/proto-guard/proto-guard.test.ts::"does not traverse Date / Map / Set / Uint8Array internals" AND src/security/proto-guard/proto-guard.spec-conformance.test.ts::"allowDangerousKeys true makes the function a no-op"
Requirement: Non-mutating clone-and-strip
The package SHALL expose cloneAndStrip<T>(value: T, options?: { allowDangerousKeys?: boolean }): T that returns a deep clone of value with stripPrototypePolluters applied to the clone. The original input MUST NOT be mutated.
Scenario: original input is preserved
- WHEN a caller invokes
cloneAndStrip(input)forinput = { __proto__: { polluted: true }, safe: 1 } - THEN the returned object equals
{ safe: 1 }ANDinput.__proto__still haspolluted: true
Verified by: src/security/proto-guard/proto-guard.test.ts::"cloneAndStrip preserves the original input"
Requirement: OTel observability for prototype-guard runs
stripPrototypePolluters and cloneAndStrip SHALL emit a passive OTel span event named serialize.proto-guard.strip carrying a numeric attribute keysRemoved representing the count of dangerous keys deleted by the call. The span event MUST be emitted from production paths regardless of whether the caller has configured a tracer.
Scenario: keysRemoved attribute reflects the deletion count
- WHEN a caller invokes
stripPrototypePolluters({ __proto__: 1, a: { constructor: 2 } })under an in-memory tracer - THEN the recorded span event carries
keysRemoved: 2
Verified by: src/security/proto-guard/proto-guard.spec-conformance.test.ts::"emits serialize.proto-guard.strip event with keysRemoved count"
Requirement: structuredClone wrapper with structured error
The package SHALL expose deepClone<T>(value: T): T from ./codec/structured-clone that delegates to globalThis.structuredClone. When globalThis.structuredClone is undefined or non-callable, the function SHALL throw SerializationError with code STRUCTURED_CLONE.UNAVAILABLE. deepClone SHALL be re-exported from the root index.ts.
Scenario: round-trip preserves nested structures
- WHEN a caller invokes
deepClone(input)for any input that platformstructuredCloneaccepts (objects, arrays,Map,Set,Date,Uint8Array, primitives) - THEN the result is structurally equal to
inputAND identity-distinct (!== inputfor non-primitive values)
Scenario: missing platform support throws structured error
- WHEN
globalThis.structuredCloneis replaced withundefinedAND a caller invokesdeepClone({}) - THEN the call throws
SerializationErrorwith codeSTRUCTURED_CLONE.UNAVAILABLE
Verified by: src/codec/structured-clone/structured-clone.test.ts::"round-trips nested objects / arrays / Map / Set / Date / Uint8Array" AND src/codec/structured-clone/structured-clone.spec-conformance.test.ts::"throws SerializationError(STRUCTURED_CLONE.UNAVAILABLE) when platform structuredClone is missing"
Requirement: JSON decoder opt-in post-strip
The JSON decoder (./codec/json/deserialize) SHALL accept an optional stripPrototypePolluters?: boolean field on DeserializeOptions. When true, the decoder SHALL run stripPrototypePolluters on the parsed value after the existing prototype-pollution reviver pass completes. The default value SHALL remain false so existing call sites are unaffected.
Scenario: opt-in flag runs the post-pass
- WHEN a caller invokes
deserialize(jsonText, { stripPrototypePolluters: true })against input that produces nested__proto__keys via reviver-bypass tricks (escaped strings, etc.) - THEN the returned value contains zero
__proto__/constructor/prototypekeys
Scenario: default behaviour is unchanged
- WHEN a caller invokes
deserialize(jsonText)without the flag - THEN the decoder behaves exactly as before this change shipped (the existing reviver-only guard is the only protection)
Verified by: src/codec/json/json-decode.spec-conformance.test.ts::"stripPrototypePolluters true runs post-pass over decoded value" AND src/codec/json/json-decode.spec-conformance.test.ts::"default decoder behaviour is unchanged when flag is omitted"
Requirement: Subpath exports
The package SHALL expose two new subpath exports in package.json::exports: ./security/proto-guard (resolves to ./dist/security/proto-guard/index.js plus types) and ./codec/structured-clone (resolves to ./dist/codec/structured-clone/index.js plus types). Importing only deepClone from the structured-clone subpath SHALL NOT pull in the JSON or MessagePack codecs.
Scenario: subpath imports resolve
- WHEN a caller writes
import { deepClone } from "@suaveplan/serialize/codec/structured-clone"ANDimport { stripPrototypePolluters } from "@suaveplan/serialize/security/proto-guard" - THEN TypeScript resolves both imports
Verified by: src/codec/structured-clone/structured-clone.spec-conformance.test.ts::"subpath ./codec/structured-clone resolves and tree-shakes" AND src/security/proto-guard/proto-guard.spec-conformance.test.ts::"subpath ./security/proto-guard resolves and tree-shakes"