Skip to content

[Security] ObjectStorageSnapshotStore has no integrity support at all and silently drops PersistenceOptions.integrity, so snapshot state — which drives actor recovery — is unauthenticated with no opt-in available #613

Description

@pathosDev

Component: src/persistence/snapshot-stores/ObjectStorageSnapshotStore.ts
Severity (assessment): LOW
CWE: CWE-353

PersistenceOptions.integrity is declared on the shared per-call options bag that SnapshotStore.save/loadLatest/loadBefore all accept, and ObjectStorageDurableStateStore honours it. ObjectStorageSnapshotStore never reads options?.integrity, has no integrity field in its options, and ObjectStoragePluginOptions exposes none either — so for event-sourced actors the #116 protection does not exist and cannot be turned on.

Exploit walkthrough

Attacker = write access to the object store (same threat #116 was written for). Against an event-sourced PersistentActor using the object-storage snapshot plugin, they overwrite <prefix><pid>/00000000000000000042.json with a re-framed ATS1 body carrying an attacker-chosen state and the correct sequenceNr (42). On the next recovery, replayState calls loadLatest, assertTrustworthySnapshot passes because 42 is <= the journal's highest seq, and state = decodeState(snapshot.value.state) seeds the fold with the attacker's state; only events after seq 42 are then folded on top. The actor recovers into a state of the attacker's choosing. A developer who reads PersistenceOptions.integrity and passes { mode: 'hmac-sha256', integrityKey } into snapshotStore.save(...) gets no error and no tag — the option is silently discarded on write and never checked on read, so the mitigation appears configured while being a no-op.

Evidence — src/persistence/snapshot-stores/ObjectStorageSnapshotStore.ts:179

const decoded = await decodeBody(fetched.value.body, {
      ...(subKeyFor ? { encryption: { subKeyFor } } : {}),
      maxOutputBytes: this.maxDecompressedBytes,
    });

// write path, same file, lines 87-96 — no `integrity` key either:
//   body = await encodeBody(utf8.encode(json), {
//     compression: compression.algorithm,
//     compressionLevel: compression.level,
//     encryption: active ? { subKey: active.subKey, ... } : undefined,
//   });

// `grep -rn integrity src/persistence/snapshot-stores/ src/persistence/object-storage/ObjectStoragePlugin*.ts`
// returns nothing at all.

Why the existing guard does not cover it

I looked for the guard in three places and found none. (a) The snapshot store and ObjectStorageSnapshotStoreOptions have no integrity field — the grep above is empty. (b) registerObjectStoragePlugins (ObjectStoragePlugin.ts:103-113) forwards prefix/keepN/compression/encryption/maxDecompressedBytes and nothing else, so the one-call wiring cannot enable it. (c) PersistentActor.persistenceOptions() (PersistentActor.ts:329-334) only builds { compression, encryption }, so there is no actor-level hook either. The one real defence I did find and confirm is Replay.ts:110-137 assertTrustworthySnapshot, which blocks the sequenceNr-pumping variant (claimed seq ahead of the journal) — that attack is genuinely closed, which is why I scoped this finding to the state payload, which it does not cover. SnapshotStore.save's JSDoc does say "Stores that cannot honour them silently ignore the field", which is defensible for compression but means a security control is dropped without a signal.

Suggested fix

Mirror the DurableState wiring: add integrity?: IntegrityConfig | IntegrityResolver and requireIntegrity?: boolean to ObjectStorageSnapshotStoreOptions, resolve them in save/fetchSnapshot via the existing resolveIntegrity, and forward both from ObjectStoragePluginOptions in registerObjectStoragePlugins. Until that lands, make the silent drop loud: throw from ObjectStorageSnapshotStore.save/fetchSnapshot when options?.integrity?.mode === 'hmac-sha256' rather than ignoring it, so a developer who configures the control learns it is unsupported.

Verification status

Found in the whole-framework security audit of 2026-08-01 (v0.12.0), then adjudicated by an independent verifier instructed to refute it.

Verifier note

Independently reproduced. ObjectStorageSnapshotStore.ts:87-96 (write) and :179-182 (read) never reference options?.integrity; the class has no integrity field (constructor, lines 47-58); ObjectStorageSnapshotStoreOptions has none; ObjectStoragePluginOptions.ts declares only snapshotPluginId/backend/prefix/keepN/compression/encryption/maxDecompressedBytes, and ObjectStoragePlugin.ts:104-112 forwards exactly those. PersistenceOptions.integrity does exist on the shared bag (PersistenceOptions.ts:136) and is accepted by SnapshotStore.save, so it is type-legal to pass and is silently discarded. PersistentActor.ts:329-334 persistenceOptions() returns { compression, encryption } only. Replay.ts:110-137 bounds the claimed sequenceNr against the journal but does not authenticate the state payload, so the finding's scoping is correct.

Correction applied: Two adjustments. First, my initial counter-argument — that client-side AES-GCM already gives snapshot bodies tamper-evidence — does not hold, because BodyCodec.ts:250 branches on the attacker-controlled FLAG_ENCRYPTED bit with no way to require encryption, so an encrypted-snapshot deployment is equally forgeable; the finding's core claim survives that check. Second, severity should be low rather than medium: this is a feature-parity gap (a control never implemented for this store) rather than a control that fails, the silent-ignore contract is explicitly documented at SnapshotStore.ts:11-16 ("Stores that cannot honour them silently ignore the field"), the false-sense-of-security path requires calling snapshotStore.save directly since no actor-level hook can even emit integrity, and exploitation requires write access to the snapshot bucket — a position from which the default configuration is already fully forgeable for DurableState too.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: lowNice-to-have / niche / demand-drivenproduction-goalBlocks or defines the path to production readinesssecuritySecurity-relevant — see severity label for impact tierseverity: lowMinor / informational / mitigated-by-design

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions