Skip to content

Constancy Security Audit — Red-Team Bypass Report

Target: constancy v3.0.0 — zero-dependency immutability toolkit (7 defense layers). Scope: All src/*.ts, all 7 defense levels (freezeShallowtamperEvident), verification utilities, cross-cutting supply-chain vectors. Mode: Adversarial audit authorized by owner.

Status (2026-04-17)

All 12 HIGH and MEDIUM severity vectors have been fixed and shipped in v3.0.1 (issues #16–#26). See changelog and release notes for detailed changes. tests/security/*.attack.test.ts regression tests assert all fixes.

VectorIssueStatus
F1 prototype chain#24Shipped in v3.0.1 — deepFreeze(val, { freezePrototypeChain: true }) opt-in
F4/I1 accessor false-positive#25Shipped in v3.0.1 — isDeepFrozen returns false on accessor descriptors
V1 missing traps#20Shipped in v3.0.1 — apply/construct traps added
V3 subclass mutator bypass#18Shipped in v3.0.1 — deny-by-default for slotted type function props
V5 toJSON bypass#21Shipped in v3.0.1 — immutableView(val, { blockToJSON: true }) opt-in
S1 proto pollution#17Shipped in v3.0.1 — snapshot severs [[Prototype]] to null on plain objects
X1 accessor silent drop#19Shipped in v3.0.1 — secureSnapshot throws on accessor descriptors
T1/T7 djb2 + getter#26Shipped in v3.0.1 — 64-bit fingerprint; accessors emit structural marker
T2/T3/T4 hash collisions#16Shipped in v3.0.1 — stableStringify reaches Map/Set/Date/RegExp internal slots
P2/P3 cached builtins unused#22Shipped in v3.0.1 — swap to cached _structuredClone/_jsonStringify
V2/I2 raw Reflect + integrity gaps#23Shipped in v3.0.1 — cache Reflect methods; integrity check extended
C5 prototype pollution#27Shipped in v3.0.0 — Object.freeze(ImmutableMap/Set.prototype) at load

Historical Reference

Below sections document the original audit findings for historical context.



Executive Summary

The library successfully defends against the headline class of attack (Object.freeze override post-import) but 27 bypass vectors remain, grouped by exploitability:

BucketCountMost severe examples
HIGH — exploitable with no preload9S1 proto-pollution via snapshot(), T2/T3/T4 Map/Set/Date hash collisions, V3 Map/Set subclass mutator bypass, X1 silent accessor drop, F1 prototype-chain freeze gap, F4/I1 accessor false-positive
MEDIUM — requires preload / API coordination10P2 raw structuredClone, P3 raw JSON.stringify, V2 raw Reflect.* in view handler, I2..I5 integrity-check blind spots
LOW — documented / theoretical / DoS8F2/F3 (documented Map/TypedArray), F7/F8 Error/private fields, V7 mutable iterator, C3/T8/T9 partial-coverage quirks

Most actionable

  1. T2/T3/T4tamperEvident fingerprints of new Map([['k','v']]), new Map(), new Set(), and new Date() all collide. verify() cannot detect swapping one for another. Fix: special-case Map/Set/Date in stableStringify.
  2. S1snapshot() returns an object whose prototype chain still exposes polluted Object.prototype. Fix: copy to null-prototype object after structuredClone.
  3. V3 — Subclasses of Map/Set/Array with custom mutator methods bypass the proxy: view.customSet(k,v) binds to the real target and mutates. Fix: block calls on slotted targets unless method is explicitly allow-listed.
  4. X1secureSnapshot silently drops every accessor property. Data loss, no warning. Fix: either throw on encounter or invoke the getter once and secure the result.
  5. P2/P3 — Cached builtins _structuredClone / _jsonStringify exist but are never used — freeze-deep-internal.ts:18 and tamper-evident.ts:{37,54,57} call raw globals. Fix: swap to cached bindings, matching every other cached builtin.

Severity Legend

  • HIGH — works against an unmodified Node ≥20 process; attacker supplies data only.
  • MEDIUM — requires either pre-import poisoning (preload hook) or a specific API pattern.
  • LOW — documented limitation, theoretical future spec, or hostile-input DoS.

Layer 0 — freezeShallow / deepFreeze

Source: freeze-shallow.ts, deep-freeze.ts, freeze-deep-internal.ts

IDSeverityVectorFile:LinePoC
F1HIGHPrototype chain not traversed; post-freeze poison of ClassName.prototype.method applies to already-frozen instancesfreeze-deep-internal.ts:37tests/security/freeze-bypass.attack.test.ts
F2LOW (documented)Map/Set internal slots mutable — .set() / .add() still work after deepFreezefreeze-deep-internal.ts:30-40
F3LOW (documented)TypedArray byte data mutable after freezefreeze-deep-internal.ts:40
F4HIGHAccessor descriptors skipped — { get x() { return {mut:true} } }; each call returns fresh mutable object, isDeepFrozen still returns truefreeze-deep-internal.ts:44, verification.ts:20
F5MEDIUMWell-known symbol methods on prototypes (Array.prototype[Symbol.iterator]) subvert iteration of frozen objectsfreeze-deep-internal.ts:42
F6MEDIUMdeepClone uses raw structuredClone, not cached _structuredClonefreeze-deep-internal.ts:18see P2
F7LOWError.prepareStackTrace is a global hook; freezing an instance doesn't stop trace forging
F8LOWPrivate class fields (#prop) are outside freeze — mutations through instance methods succeedfreeze-deep-internal.ts:42
F9LOWRevocable Proxy passed to deepFreeze can be revoked afterward, turning reads into DoS

Suggested fixes

  • Walk the prototype chain and freeze owned methods if caller opts in (new flag: deepFreeze(obj, {chain:true})).
  • Add freezeDeep option to invoke getters and recurse into returned objects (document side-effect trade-off).
  • Cache _structuredClone usage; ban raw structuredClone via ESLint rule.

Layer 1 — immutableView

Source: immutable-view.ts, immutable-view-collection-wraps.ts

IDSeverityVectorFile:LinePoC
V1HIGHHandler lacks apply + construct traps — wrapped function can be .call()/.apply()/new'd, mutating caller-supplied thisimmutable-view.ts:66-122
V2MEDIUMHandler uses raw Reflect.get/getOwnPropertyDescriptor/getPrototypeOf/has/isExtensible/ownKeys — none cached (only Reflect.ownKeys cached as _ownKeys, but handler line 111 uses raw Reflect.ownKeys anyway)immutable-view.ts:58,70,107,110-113,121
V3HIGHCustom mutator methods on Map/Set subclasses are not in MUTATOR_MAP; view.customSet(k,v) returns a method bound to the raw target and the mutation succeedsimmutable-view.ts:47-54, 87-88
V4LOWgetBlockedMutator only inspects string props; future Symbol-named mutator methods would escape (no current exploit — forward-looking)immutable-view.ts:48
V5HIGHJSON.stringify(view) calls target's toJSON() — attacker-supplied toJSON replaces serialized output. Proxy trap never fires for toJSON invocation.
V6HIGHSame as V1 — no call-site receiver sandboxing
V7LOWwrapIterator returns a plain generator object whose .next is writable — attacker can overwrite next to forge valuesimmutable-view-collection-wraps.ts:11-29
V8LOW (documented)View is a VIEW — retained original reference remains mutableimmutable-view.ts:128

Suggested fixes

  • Add apply(target, thisArg, args) { if (hasInternalSlots(thisArg)) rejectMutation('apply with slotted receiver') … } and construct.
  • When target is Map/Set/Array, return a rejecting stub for ANY function-typed property not explicitly allow-listed (invert the deny list).
  • Cache every Reflect.* used by the handler in cached-builtins.ts.

Layer 1.5 — immutableMapView / immutableSetView

Source: immutable-collection-views.ts

IDSeverityVectorFile:LinePoC
C1LOWValues cloned at construction but frozen only on first read — there is a window where the wrapper holds an unfrozen cloneimmutable-collection-views.ts:37-43
C2LOW (documented)has() uses reference identity; originals passed by caller produce false-negativesimmutable-collection-views.ts:117
C3LOWGenerator iteration freezes lazily — aborted iteration leaves later items unfrozenimmutable-collection-views.ts:83-88
C4LOWstructuredClone throws on non-cloneable values at construction — DoS if caller receives attacker-supplied Map/Setimmutable-collection-views.ts:28
C5MEDIUMImmutableMap.prototype and ImmutableSet.prototype are not frozen — attacker can overwrite get, values, etc. for every wrapper in the process

Suggested fix for C5: Object.freeze(ImmutableMap.prototype); Object.freeze(ImmutableSet.prototype) at module load.


Layer 1.5 — snapshot / lock

Source: snapshot.ts

IDSeverityVectorFile:LinePoC
S1HIGHPrototype pollution survives snapshot()Object.prototype.x is visible through the clonesnapshot.ts:29-34
S2MEDIUMSame raw structuredClone as F6/P2freeze-deep-internal.ts:18
S3LOW (documented)Non-cloneable values (functions, Symbols, DOM) throw — DoS on hostile payload
S4MEDIUMsnapshot(new Date()) is still Date — poisoning Date.prototype.getTime affects the frozen snapshot

Suggested fix for S1: post-clone, walk the tree and Object.setPrototypeOf(node, null) for plain objects (preserve built-in types by Object.getPrototypeOf(node) === Object.prototype test).


Layer 2 — vault

Source: vault.ts

IDSeverityVectorFile:LinePoC
U1LOWNon-cloneable input throws → DoS at constructionvault.ts:17, freeze-deep-internal.ts:19
U2LOWRepeated .get() performs a full deep clone each time — CPU/memory amplification attackvault.ts:42
U3MEDIUMRaw structuredClone (same vector as P2)see P2
U4— regression.get.call(otherThis, …) cannot leak — arrow closure pins state (no fix needed, keep test)vault.ts:42

Layer 2.5 — secureSnapshot

Source: secure-snapshot.ts

IDSeverityVectorFile:LinePoC
X1HIGHAccessor-only properties silently dropped — input { get important() {…} } returns {}; caller gets no warning, cannot distinguish from legitimate empty objectsecure-snapshot.ts:68-69
X2LOW (documented)Any nested non-plain object (Date, Array, Map, class instance) aborts — DoS on hostile payloadsecure-snapshot.ts:15-24, 59-61
X3— regressionDescriptor .get() returns already-secured inner object — assignment still throwssecure-snapshot.ts:73-77
X4— regressionSymbol keys preserved with non-configurable getterssecure-snapshot.ts:66

Suggested fix for X1: either throw new TypeError('secureSnapshot: accessor property "' + key + '" not supported') or invoke the getter once and secure the returned value.


Layer 3 — tamperEvident

Source: tamper-evident.ts

IDSeverityVectorFile:LinePoC
T1MEDIUMdjb2 is 32-bit non-cryptographic — birthday attack ~2^16 payloads produces collisiontamper-evident.ts:22-28
T2HIGHstableStringify iterates only own enumerable keys; Map entries are in internal slot → every Map with no own props has the SAME fingerprinttamper-evident.ts:40-78
T3HIGHSame for Set
T4HIGHSame for Date — different timestamps produce identical fingerprints
T5MEDIUMSymbols with identical descriptions collide via .toString()tamper-evident.ts:51,57
T6MEDIUMRaw JSON.stringify used — cached _jsonStringify exists but unusedtamper-evident.ts:37,54,57see P3
T7HIGHstableStringify invokes getters; side-effectful getters (e.g., returning Date.now()) make every verify() call mismatch → integrity alarm fires without tamperingtamper-evident.ts:54,57
T8LOWSparse array holes and explicit undefined produce identical hashtamper-evident.ts:75
T9LOW"[Circular]" placeholder flattens structurally-different cycles into equal stringstamper-evident.ts:71

Suggested fixes

  • Switch hash to a cryptographic function (SHA-256 via node:crypto) — trade zero-dep purity for real integrity; OR add a clear non-security disclaimer in the API docs.
  • In stableStringify, special-case Map/Set (sort & serialize entries), Date (.getTime()), ArrayBuffer (byte dump), sparse arrays (preserve holes marker).
  • Detect accessor descriptors and either skip (matching freezeDeep) or document getter-trigger behavior as an explicit feature.

Layer 4 — Verification & checkRuntimeIntegrity

Source: verification.ts, check-runtime-integrity.ts

IDSeverityVectorFile:LinePoC
I1HIGHisDeepFrozen false positive when accessor returns mutable objectverification.ts:20
I2MEDIUMMissing Reflect.get/set/has/getOwnPropertyDescriptor/getPrototypeOf/isExtensible — all used by immutableView handlercheck-runtime-integrity.ts:24-44
I3MEDIUMMissing Map.prototype.*, Set.prototype.*, WeakMap/WeakSet.prototype.*
I4MEDIUMMissing Array.prototype.push/pop/splice/sort/reverse
I5MEDIUMMissing detection of Object.prototype pollution (injected accessors)
I6LOWPoison → use library → restore — subsequent checkRuntimeIntegrity() lies (false negative after the fact)

Suggested fixes

  • Extend cached-builtins.ts to snapshot every prototype method the library uses; extend checkRuntimeIntegrity identity check to match.
  • Add an Object.prototype own-key fingerprint captured at module load; compare current _ownKeys(Object.prototype) to it.

Cross-cutting — Preload / Supply-Chain

Source: cached-builtins.ts

IDSeverityVectorFile:LinePoC
P1MEDIUMSelf-test only exercises Object.freeze({}) — other cached builtins untested at loadcached-builtins.ts:25-28
P2MEDIUM_structuredClone captured but never used (freeze-deep-internal.ts:18 uses raw global)freeze-deep-internal.ts:18
P3MEDIUM_jsonStringify captured but never used (tamper-evident.ts uses raw JSON.stringify)tamper-evident.ts:37,54,57
P4MEDIUMReflect.* not cached (except _ownKeys); immutable-view handler depends on live Reflectcached-builtins.ts:9-21
P5— regressionES module namespace spec-frozen — cannot replace exported functions

Suggested fixes

  • Exercise every cached builtin at module load with a representative call; throw on divergence.
  • ESLint rule forbidding raw structuredClone / JSON.stringify / Reflect.* inside src/.

Running the PoC Suite

bash
npm test                                        # runs 228+ existing + security PoCs
npm test -- tests/security                      # run bypass tests only
npx vitest run tests/security/tamper-evident-bypass.attack.test.ts

A PASSING test under tests/security/ whose name begins with BYPASS: means the bypass currently works. After a fix lands, the test either flips to failing (bypass closed) or needs to be rewritten to assert the new post-fix behavior.

Quick End-to-End Confirmation

bash
node -e "
const { tamperEvident } = require('./dist/index.cjs');
const a = tamperEvident(new Map([['k','v']]));
const b = tamperEvident(new Map());
const c = tamperEvident(new Date(0));
const d = tamperEvident(new Date(9999999));
console.log('Map with entry:', a.fingerprint);
console.log('Empty Map:    ', b.fingerprint, a.fingerprint === b.fingerprint ? '[COLLISION]' : '');
console.log('Date(0):       ', c.fingerprint);
console.log('Date(big):     ', d.fingerprint, c.fingerprint === d.fingerprint ? '[COLLISION]' : '');
"

Expect [COLLISION] on both pairs (T2, T4).


Out of Scope

  • Production source changes — separate implementation task after review.
  • Cryptographic-primitive redesign (SHA-256 vs djb2) — see T1 note.
  • Threat modelling of downstream consumers.

Open Questions

None. Audit scope and format were aligned with the owner before work began.

Released under the MIT License.