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

100 lines
11 KiB
Markdown

# 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:
```bash
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.