Files
suaveplan/openspec/changes/serialize-proto-guard/specs/serialize/spec.md
2026-05-27 23:29:53 +01:00

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. (See openspec/AGENTS.md Section 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__ and constructor removed)

Scenario: class instances are not traversed

  • WHEN a caller invokes stripPrototypePolluters({ d: new Date(0), m: new Map([["__proto__", 1]]) })
  • THEN the Date and Map are 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) for input = { __proto__: { polluted: true }, safe: 1 }
  • THEN the returned object equals { safe: 1 } AND input.__proto__ still has polluted: 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 platform structuredClone accepts (objects, arrays, Map, Set, Date, Uint8Array, primitives)
  • THEN the result is structurally equal to input AND identity-distinct (!== input for non-primitive values)

Scenario: missing platform support throws structured error

  • WHEN globalThis.structuredClone is replaced with undefined AND a caller invokes deepClone({})
  • THEN the call throws SerializationError with code STRUCTURED_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 / prototype keys

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" AND import { 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"