Component: src/persistence/snapshot-stores/CachedSnapshotStore.ts
Severity (assessment): INFORMATIONAL
CWE: CWE-524 (Use of Cache Containing Sensitive Information) / CWE-311 (Missing Encryption of Sensitive Data)
The decorator caches the value returned by underlying.loadLatest, which for ObjectStorageSnapshotStore is the fully decoded, decompressed and decrypted domain state. The Cache it writes to is an injected, caller-owned instance that the class documentation expects to be shared with other subsystems, and no encryption, TTL-independent scoping or key namespacing is applied to the cached copy.
Exploit walkthrough
Preconditions: an operator follows two documented patterns at once — client-side snapshot encryption (encryption: {mode: 'client-aes256-gcm', masterKey}) to keep state confidential in S3, and CachedSnapshotStore over a Redis/Memcached instance to absorb cold-start storms. Every entity that wakes up writes its decrypted state into Redis under snap:<persistenceId> for ttlMs (default five minutes). An attacker who reaches the cache — an unauthenticated Redis on a shared network, a cache also used by HTTP middleware and therefore exposed to a different trust boundary, or simply a Redis backup/RDB snapshot — reads the plaintext of every recently active entity, without ever touching the encrypted bucket or the master key. The threat model that justified encrypting the bucket at all is defeated by a decorator whose documentation discusses only TTL and staleness. The same applies to a mode: 'sse-kms' bucket, where the KMS audit trail no longer reflects who read the data.
Evidence — src/persistence/snapshot-stores/CachedSnapshotStore.ts
src/persistence/snapshot-stores/CachedSnapshotStore.ts:76-84:
async loadLatest<S>(persistenceId: string, options?: PersistenceOptions): Promise<Option<Snapshot<S>>> {
const key = this.keyFor(persistenceId);
const hit = await this.cache.get<CachedSnapshot<S>>(key);
if (hit.isSome()) return some(hit.value as Snapshot<S>);
const fetched = await this.underlying.loadLatest<S>(persistenceId, options);
if (fetched.isNone()) return none;
await this.cache.set<CachedSnapshot<S>>(key, fetched.value, this.ttlMs);
return fetched;
}
The cached shape is the plaintext state — CachedSnapshotStore.ts:46-51:
type CachedSnapshot<S> = {
readonly persistenceId: string;
readonly sequenceNr: number;
readonly state: S;
readonly timestamp: number;
};
and the class explicitly anticipates a shared, external cache — CachedSnapshotStore.ts:13-14 ("A Redis cache in front cuts that to a single round-trip") and 97-100:
async close(): Promise<void> {
await this.underlying.close?.();
// We do NOT close the cache — it's owned by the caller (the same
// cache typically backs HTTP middleware, etc.).
}
The protection being bypassed is the per-pid HKDF + AES-256-GCM path in src/persistence/object-storage/Encryption.ts:53-79, whose stated goal is "a leaked subkey only compromises one pid's snapshots, not the entire bucket".
Why the existing guard does not cover it
The cache-consistency reasoning in the class is sound — write-through-with-invalidate plus a TTL safety net is the right shape, and loadBefore is deliberately uncached. But there is no security consideration anywhere in the file: no note that the cached value is plaintext, no option to encrypt or to refuse decoration of an encrypting store, and the key (snap:<pid>, line 102-104) is a bare concatenation with no namespace isolation from whatever else shares the cache.
Suggested fix
Document the exposure prominently on the class, and either refuse to cache (or cache only the encoded body) when the decorated store is configured with an encryption mode, or give CachedSnapshotStoreOptions an explicit allowPlaintextCache acknowledgement that an operator has to set. Caching the raw encoded body rather than the decoded state would preserve most of the cold-start win while keeping the at-rest guarantee intact.
Verification status
Found in the second, independent whole-framework security re-audit of 2026-08-02 (v0.12.0) — a fresh pass run without reference to the first wave's findings, then triaged against the existing tracker and adjudicated by verifiers instructed to refute it.
Verifier note
The code reads as described. CachedSnapshotStore.loadLatest (:76-84) writes fetched.value — the fully decoded Snapshot<S> returned by the underlying store — into the injected cache for ttlMs (default 5 min, :44/:65). For ObjectStorageSnapshotStore that value is post-decodeBody, i.e. decompressed and decrypted (ObjectStorageSnapshotStore.ts:179-194). The class does anticipate an external, shared cache: "A Redis cache in front cuts that to a single round-trip" (:14) and close() deliberately does not close it because "the same cache typically backs HTTP middleware, etc." (:96-100). RedisCache and MemcachedCache both ship in src/cache/, so the deployment shape is real, and neither offers value-level encryption.
But the finding overstates in three places and I corrected all of them. keyPrefix is a documented option that defaults to 'snap:' and whose own JSDoc says it "prevents collisions in shared caches" — the "no namespace isolation" claim is simply false. The headline fix ("cache the raw encoded body") cannot be implemented in this decorator: it programs against SnapshotStore, whose loadLatest hands back an already-decoded Snapshot<S>; the encoded bytes never cross that boundary. And no framework guard or documented claim is defeated here — I checked the docs pages for the decorator and for the object-storage snapshot backend and neither promises that state stays encrypted end-to-end.
What survives is a genuine but purely advisory point: the framework ships an at-rest-encryption feature and a cache decorator that, composed, put plaintext state in a third-party datastore, and neither the class nor the docs says so. That is a documentation / hardening note, not a defect in either component, so I downgraded LOW → INFORMATIONAL.
Correction applied: Three corrections. (1) "no ... key namespacing is applied" and "a bare concatenation with no namespace isolation" are wrong: keyPrefix is a first-class option (CachedSnapshotStoreOptions.ts:11, withKeyPrefix :40-42), defaults to 'snap:' (CachedSnapshotStore.ts:66), and its own JSDoc says it "prevents collisions in shared caches". Drop that clause. (2) The suggested fix "cache the raw encoded body" is not implementable at this layer — SnapshotStore.loadLatest returns Option<Snapshot<S>> with the state already decoded; the decorator never sees encoded bytes. The implementable options are documentation, a decorator-side encryption option, or an explicit acknowledgement flag. (3) The title's "undoing the at-rest encryption of the store it decorates" overstates: nothing at rest in the bucket changes; a second, plaintext copy lands in an operator-supplied cache. Downgraded LOW → INFORMATIONAL accordingly.
Component:
src/persistence/snapshot-stores/CachedSnapshotStore.tsSeverity (assessment): INFORMATIONAL
CWE: CWE-524 (Use of Cache Containing Sensitive Information) / CWE-311 (Missing Encryption of Sensitive Data)
The decorator caches the value returned by
underlying.loadLatest, which forObjectStorageSnapshotStoreis the fully decoded, decompressed and decrypted domain state. TheCacheit writes to is an injected, caller-owned instance that the class documentation expects to be shared with other subsystems, and no encryption, TTL-independent scoping or key namespacing is applied to the cached copy.Exploit walkthrough
Preconditions: an operator follows two documented patterns at once — client-side snapshot encryption (
encryption: {mode: 'client-aes256-gcm', masterKey}) to keep state confidential in S3, andCachedSnapshotStoreover a Redis/Memcached instance to absorb cold-start storms. Every entity that wakes up writes its decrypted state into Redis undersnap:<persistenceId>forttlMs(default five minutes). An attacker who reaches the cache — an unauthenticated Redis on a shared network, a cache also used by HTTP middleware and therefore exposed to a different trust boundary, or simply a Redis backup/RDB snapshot — reads the plaintext of every recently active entity, without ever touching the encrypted bucket or the master key. The threat model that justified encrypting the bucket at all is defeated by a decorator whose documentation discusses only TTL and staleness. The same applies to amode: 'sse-kms'bucket, where the KMS audit trail no longer reflects who read the data.Evidence —
src/persistence/snapshot-stores/CachedSnapshotStore.tssrc/persistence/snapshot-stores/CachedSnapshotStore.ts:76-84:
The cached shape is the plaintext state — CachedSnapshotStore.ts:46-51:
and the class explicitly anticipates a shared, external cache — CachedSnapshotStore.ts:13-14 ("A Redis cache in front cuts that to a single round-trip") and 97-100:
The protection being bypassed is the per-pid HKDF + AES-256-GCM path in src/persistence/object-storage/Encryption.ts:53-79, whose stated goal is "a leaked subkey only compromises one pid's snapshots, not the entire bucket".
Why the existing guard does not cover it
The cache-consistency reasoning in the class is sound — write-through-with-invalidate plus a TTL safety net is the right shape, and
loadBeforeis deliberately uncached. But there is no security consideration anywhere in the file: no note that the cached value is plaintext, no option to encrypt or to refuse decoration of an encrypting store, and the key (snap:<pid>, line 102-104) is a bare concatenation with no namespace isolation from whatever else shares the cache.Suggested fix
Document the exposure prominently on the class, and either refuse to cache (or cache only the encoded body) when the decorated store is configured with an encryption mode, or give
CachedSnapshotStoreOptionsan explicitallowPlaintextCacheacknowledgement that an operator has to set. Caching the raw encoded body rather than the decoded state would preserve most of the cold-start win while keeping the at-rest guarantee intact.Verification status
Found in the second, independent whole-framework security re-audit of 2026-08-02 (
v0.12.0) — a fresh pass run without reference to the first wave's findings, then triaged against the existing tracker and adjudicated by verifiers instructed to refute it.Verifier note
The code reads as described.
CachedSnapshotStore.loadLatest(:76-84) writesfetched.value— the fully decodedSnapshot<S>returned by the underlying store — into the injected cache forttlMs(default 5 min, :44/:65). ForObjectStorageSnapshotStorethat value is post-decodeBody, i.e. decompressed and decrypted (ObjectStorageSnapshotStore.ts:179-194). The class does anticipate an external, shared cache: "A Redis cache in front cuts that to a single round-trip" (:14) andclose()deliberately does not close it because "the same cache typically backs HTTP middleware, etc." (:96-100).RedisCacheandMemcachedCacheboth ship insrc/cache/, so the deployment shape is real, and neither offers value-level encryption.But the finding overstates in three places and I corrected all of them.
keyPrefixis a documented option that defaults to'snap:'and whose own JSDoc says it "prevents collisions in shared caches" — the "no namespace isolation" claim is simply false. The headline fix ("cache the raw encoded body") cannot be implemented in this decorator: it programs againstSnapshotStore, whoseloadLatesthands back an already-decodedSnapshot<S>; the encoded bytes never cross that boundary. And no framework guard or documented claim is defeated here — I checked the docs pages for the decorator and for the object-storage snapshot backend and neither promises that state stays encrypted end-to-end.What survives is a genuine but purely advisory point: the framework ships an at-rest-encryption feature and a cache decorator that, composed, put plaintext state in a third-party datastore, and neither the class nor the docs says so. That is a documentation / hardening note, not a defect in either component, so I downgraded LOW → INFORMATIONAL.
Correction applied: Three corrections. (1) "no ... key namespacing is applied" and "a bare concatenation with no namespace isolation" are wrong:
keyPrefixis a first-class option (CachedSnapshotStoreOptions.ts:11,withKeyPrefix:40-42), defaults to'snap:'(CachedSnapshotStore.ts:66), and its own JSDoc says it "prevents collisions in shared caches". Drop that clause. (2) The suggested fix "cache the raw encoded body" is not implementable at this layer —SnapshotStore.loadLatestreturnsOption<Snapshot<S>>with the state already decoded; the decorator never sees encoded bytes. The implementable options are documentation, a decorator-side encryption option, or an explicit acknowledgement flag. (3) The title's "undoing the at-rest encryption of the store it decorates" overstates: nothing at rest in the bucket changes; a second, plaintext copy lands in an operator-supplied cache. Downgraded LOW → INFORMATIONAL accordingly.