Skip to content

[Security] FilesystemObjectStorageBackend's CAS ETag is a 32-bit FNV-1a hash, so the durable-state optimistic-concurrency token is a forgeable 4-byte value #786

Description

@pathosDev

Component: src/persistence/object-storage/FilesystemObjectStorageBackend.ts
Severity (assessment): INFORMATIONAL
Related: #117

The value that ObjectStorageDurableStateStore sends as ifMatch to prove "the record has not changed since I read it" is a 32-bit FNV-1a over the body plus its length. FNV-1a is trivially invertible/collidable by construction, so a same-length body colliding with a held etag can be computed rather than searched.

Exploit walkthrough

Preconditions: filesystem backend, ObjectStorageDurableStateStore, and an adversary who can both (a) drive the plaintext content of a durable-state record and (b) write to the store — plus compression: 'none' and no client-side encryption, since gzip framing and a per-write random IV make the encoded bytes non-deterministic. Under those conditions a stale writer can construct a body whose FNV-1a-32 matches the etag they still hold and win a CAS check they should lose, silently clobbering an intervening update. Accidental collisions need ~65k distinct same-length bodies at a single key, which is unrealistic for a durable-state record. I am reporting this as INFORMATIONAL rather than higher precisely because the preconditions stack up and I did not construct the collision — the point is that a CAS token is a security-relevant value and a 32-bit non-cryptographic hash is the wrong primitive for it.

Evidence — src/persistence/object-storage/FilesystemObjectStorageBackend.ts

src/persistence/object-storage/FilesystemObjectStorageBackend.ts:408-422 —

/** Cheap content-derived ETag — FNV-1a hex + length suffix. */
function computeEtag(body: Uint8Array): string {
  let hash = 0x811c9dc5;
  for (let i = 0; i < body.length; i++) { hash ^= body[i]!; hash = (hash * 0x01000193) >>> 0; }
  hash ^= body.length;
  return `"fs-${(hash >>> 0).toString(16).padStart(8, '0')}-${body.length}"`;
}

It is the CAS token, not merely a cache validator — src/persistence/object-storage/FilesystemObjectStorageBackend.ts:162-167:

if (options.ifMatch !== undefined && currentEtag !== options.ifMatch) {
  throw new ObjectStorageConcurrencyError(key, `etag mismatch on ${key}: ...`);
}

and the durable-state store builds ifMatch from it — src/persistence/durable-state-stores/ObjectStorageDurableStateStore.ts:205-218. The code comment states the intended invariant: "same bytes → same etag, different bytes → different etag with very high probability" — which is true for accidental collisions of differing length (length is in the string) but not for a chosen-body adversary at a fixed length.

Why the existing guard does not cover it

The per-key advisory lock (acquireLock, :346-406) serializes the read-current/compare/write block against other writers on the same host and is genuinely atomic ({ flag: 'wx' } = O_EXCL). It removes the ordinary race but does not make the etag comparison itself sound — a colliding body passes the comparison whether or not it was taken under the lock. The S3 backend is unaffected: it forwards IfMatch to S3 and uses S3's own ETag (S3ObjectStorageBackend.ts:78-97), so this is a filesystem-backend property only. The backend is documented for "unit tests, local development", which further bounds the exposure.

Suggested fix

Replace computeEtag with a truncated SHA-256 over the body (WebCrypto is already a dependency of this subsystem via Integrity.ts/Encryption.ts), or, if a synchronous cheap hash must stay, add a monotonically increasing generation counter to the sidecar and include it in the etag so equality cannot be forged from content alone.

Relationship to existing issues

Adjacent to #117, but a distinct mechanism. Verified: computeEtag (FilesystemObjectStorageBackend.ts:409-422) is 32-bit FNV-1a over the body xor'd with the length, and its own comment scopes the invariant to 'different bytes → different etag with very high probability' — an accidental-collision claim, not an adversarial one. It is used as the CAS token, not a cache validator: put compares currentEtag !== options.ifMatch at :162-167 and ObjectStorageDurableStateStore.ts:206-218 builds ifMatch from it. Adjacent to #117 (CLOSED), which is the other defect in the same etag-CAS mechanism (the cache-refresh race leaving a stale etag wedged); #117 is about cache staleness, not about the strength of the token itself, and nothing in the corpus mentions computeEtag. Kept at INFORMATIONAL as the finder rated it and as its own UNCERTAIN confidence warrants: the preconditions stack (filesystem backend, compression 'none', no client-side encryption so the encoded bytes are deterministic, plus attacker control of the plaintext and bucket write access), no collision was constructed, the per-key O_EXCL advisory lock removes the ordinary race, the S3 backend is unaffected, and this backend is documented for local development. The substance is a hygiene point — a CAS token should not be a 32-bit non-cryptographic hash.

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

Confirmed. computeEtag (FilesystemObjectStorageBackend.ts:409-422) is a 32-bit FNV-1a over the body, XOR'd with the length, rendered as "fs-<hex8>-<len>". Its own comment scopes the invariant to "different bytes → different etag with very high probability" — an accidental-collision claim, not an adversarial one. It is unambiguously the CAS token and not merely a cache validator: put re-derives it from disk (:145-146) and compares currentEtag !== options.ifMatch (:162-167), and ObjectStorageDurableStateStore.upsert builds effectiveIfMatch from its etag cache and passes it to backend.put (:205-218). The S3 backend is unaffected — it forwards IfMatch and returns S3's own ETag (S3ObjectStorageBackend.ts:78-97).

I tested the reachability and it is thin, which is why INFORMATIONAL is correct. The attacker must be a legitimate writer of the same durable-state record holding a stale etag, must control enough of state to grind candidate bodies, and must produce a body matching both the 32-bit hash and the exact length (the length is baked into the etag string). The per-key O_EXCL advisory lock (:346-406) removes the ordinary race but does not make the comparison sound. No collision was constructed, and this backend is documented for "unit tests, local development".

I corrected the preconditions in both directions: compression: 'none' is not required (gzip is deterministic — it raises grinding cost, nothing more), while the length-match constraint the finding omits is a real additional requirement. Keeping INFORMATIONAL as the finder rated it: the defensible substance is the hygiene point that a CAS token should not be a 32-bit non-cryptographic hash.

Correction applied: Two precision fixes to the preconditions. (1) compression: 'none' is stricter than necessary — gzip is deterministic, so the encoded bytes remain a computable function of the plaintext; compression only raises the cost of grinding candidates, it does not close the vector. (2) Conversely, the finding understates one constraint: the forged body must match the 32-bit hash and the exact byte length, because the length is a literal component of the etag string ("fs-<hash8>-<len>"). Both belong in the preconditions rather than the current framing.

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