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

109 lines
6.8 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->