Severity / Size
- Severity: MEDIUM — sweep operation trusts every key returned by
backend.list() without shape-validation. A buggy backend (or adversarial response that bypasses S3's normal key constraints) could feed malformed keys into the pid-extractor, leading to wrong HKDF subkey derivation, decrypt failures, or — in a worst-case backend bug — accidental key/pid confusion that produces a wrong-but-valid subkey.
- Size: S (~1d).
- Threat model: backend bug (custom
ObjectStorageBackend impl with a bug; or a real S3 with consistency edge cases returning incomplete keys); or an attacker who controls the backend (admin-level S3 access). Companion to the filesystem path-traversal fix (a290b06) but on the read side of the sweep.
Affected files
src/persistence/object-storage/reEncryptionSweep.ts:142-220 — the sweep loop trusts items[i].key straight from backend.list().
src/persistence/object-storage/reEncryptionSweep.ts:180-181 — pidFromKey(item.key, opts.keyPrefix) uses the key shape directly; a malformed key produces a malformed pid.
src/persistence/object-storage/reEncryptionSweep.ts:223-240 — defaultPidFromKey extracts the pid by splitting on /. If item.key doesn't have the expected layout (e.g. starts with .., contains NUL, or has zero /-separators), the extracted pid is garbage.
Background
The sweep walks the corpus by listing keys under a prefix:
const items = await backend.list({ prefix: opts.keyPrefix });
for (const item of items) {
const pid = pidFromKey(item.key, opts.keyPrefix);
// derive subkey from pid; decrypt with retired key; re-encrypt with active
}
The hardening landed in a290b06 covers the write path (FS backend rejects malformed keys on put/get/delete/list). But the sweep is on the read path: it accepts whatever list() returns. If a backend's response includes:
- Keys with
.. segments (path-traversal-like; should never happen on S3 but might on a custom backend).
- Keys with embedded NUL bytes.
- Keys outside the expected
<keyPrefix><pid>/<rest> shape.
- Keys longer than reasonable (memory pressure).
…then defaultPidFromKey produces a garbage pid string. Downstream:
deriveSubkey(masterKey, garbage-pid, info) produces a subkey.
aesGcmDecrypt(subkey, iv, ciphertext) fails — wrong subkey, auth-tag mismatch.
The sweep aborts on that error (per the design: "Per-object failures … bubble up immediately and stop the sweep"). Operator sees a confusing error.
The worse case: if the backend returns a key whose pid-extractor output happens to collide with a legitimate pid (e.g. via NUL truncation), the sweep re-encrypts the legitimate pid's data with a key derived from the wrong pid → encrypted under a key that nobody else will use → data effectively lost.
Exploit walkthrough
Setup: deployment uses a custom ObjectStorageBackend (e.g. an HTTP-backed minio-like service). Backend has a bug that occasionally returns keys with trailing NUL bytes: 'users/42/snap.json\0\0\0'.
Step 1 — sweep starts: backend.list({ prefix: 'users/' }) returns a list including the NUL-poisoned key.
Step 2 — pid extraction: defaultPidFromKey('users/42/snap.json\0\0\0', 'users/') returns '42' (the slash-separator is at the right position; NUL bytes are kept).
Wait — that's actually fine for this specific case. Let me redo with a worse shape:
Step 1 — backend returns '/etc/passwd' (a S3-impl bug returning a malformed key that S3 itself wouldn't produce).
Step 2 — defaultPidFromKey('/etc/passwd', 'users/'): the key doesn't start with the prefix, so the helper's logic falls through to key.indexOf('/', 0) → returns '' (empty string before the first slash).
Step 3 — deriveSubkey(masterKey, '', info): HKDF with an empty salt. Different from the legitimate pid's subkey.
Step 4 — decrypt: aesGcmDecrypt(wrongSubkey, iv, ciphertext) → auth-tag mismatch → throws.
Step 5 — sweep aborts mid-corpus. Operator sees decrypt failure. Reruns; same failure on the same offending key.
The operator's workaround: configure opts.skip to filter the bad key. But before they figure out which key is the issue, they may run the sweep many times, slowing down rotation.
The actually-worse scenario: a custom backend returns the same legitimate key twice but the second time with a trailing space. pidFromKey('users/42/snap.json ', ...) extracts '42' correctly (trailing space is in the rest, not the pid). But: deriveSubkey salt is '42' — wait, that's the same. OK actually this case is fine.
The genuine worry is NUL-truncation behavior in the underlying crypto: WebCrypto / Bun's HKDF treat the salt as bytes, not as a C-string, so NUL-terminator confusion doesn't happen at the crypto layer. Safe.
So the realistic damage is: sweep aborts, operator confused. Not catastrophic — but the fix (validate at the read boundary) costs nothing.
How the 8 already-landed security fixes inform this
- Path-traversal guard (
a290b06): closed the write-side key-traversal door. This issue is the read-side complement: don't trust keys we read from the backend either.
- Memcached CRLF block (
65a2bc5): used a assertSafe... validator at the API boundary. Same pattern here: validate every key the sweep iterates over.
Fix design
Two complementary checks.
Track 1 — validate keys from list().
Reuse assertSafeKey() from FilesystemObjectStorageBackend.ts (or extract to a shared helper). In the sweep loop:
for (const item of items) {
try {
assertSafeKey(item.key);
} catch (e) {
log.warn(`reEncryptObjectStorage: skipping malformed key ${JSON.stringify(item.key)}: ${e.message}`);
result.skippedMalformed += 1;
opts.onProgress?.({ key: item.key, idx, total, action: 'skipped-malformed' });
continue;
}
// ... existing handling
}
assertSafeKey rejects: empty strings, NUL bytes, absolute paths (POSIX + Windows), .. segments.
Track 2 — validate pid-extraction output.
After pidFromKey(...), verify the result is non-empty and shape-sane:
const pid = pidFromKey(item.key, opts.keyPrefix);
if (!pid || pid.length === 0 || pid.includes('\0') || pid.includes('/')) {
log.warn(`reEncryptObjectStorage: skipping key ${item.key} — pid-extractor returned ${JSON.stringify(pid)}`);
result.skippedMalformed += 1;
continue;
}
Defends against pid-extractor edge cases not caught by assertSafeKey.
Track 3 — extract to shared helper.
assertSafeKey currently lives in FilesystemObjectStorageBackend.ts. Extract to a shared module so both the FS backend and the sweep use the same validator:
src/persistence/object-storage/KeyValidator.ts (new file)
export function assertSafeKey(key: string): void;
export function assertWithinRoot(pathMod, root, fullPath): void;
Updates FilesystemObjectStorageBackend to import from there.
Track 4 — counter + result field.
ReEncryptResult.skippedMalformed: number field; obj_storage_sweep_malformed_keys_total metric.
API surface
// src/persistence/object-storage/reEncryptionSweep.ts
export interface ReEncryptResult {
// ... existing
readonly skippedMalformed: number; // new
}
// src/persistence/object-storage/KeyValidator.ts (new file)
export function assertSafeKey(key: string): void;
Backward compatibility
The sweep's behaviour changes from "abort on first malformed key" to "skip + log + count, continue with the rest". This is a behaviour change: previously, a malformed key was a hard failure (forcing the operator to investigate); now it's a counted skip. Operators relying on the abort-on-malformed signal will need to inspect result.skippedMalformed after the run.
Trade-off: aborting on malformed keys gives faster feedback but blocks completion for what may be a non-fatal backend hiccup. Skip+log lets the sweep complete and surface the issue in the result. Tunable via opts.malformedKeyMode: 'skip' | 'abort' (default: skip).
Test plan
-
Exploit test: mock backend that returns a key with .. segment in its list; sweep skips it + counter increments; sweep completes for all other keys.
-
NUL-key test: key containing \0; rejected by assertSafeKey; skipped.
-
Empty-pid test: key that doesn't match the prefix; defaultPidFromKey returns empty; skipped via Track 2.
-
Mode test: with malformedKeyMode: 'abort', sweep throws on first malformed key (preserves old behaviour for users who want it).
-
Regression: existing reEncryptionSweep.test.ts tests pass.
Acceptance criteria
Severity / Size
backend.list()without shape-validation. A buggy backend (or adversarial response that bypasses S3's normal key constraints) could feed malformed keys into the pid-extractor, leading to wrong HKDF subkey derivation, decrypt failures, or — in a worst-case backend bug — accidental key/pid confusion that produces a wrong-but-valid subkey.ObjectStorageBackendimpl with a bug; or a real S3 with consistency edge cases returning incomplete keys); or an attacker who controls the backend (admin-level S3 access). Companion to the filesystem path-traversal fix (a290b06) but on the read side of the sweep.Affected files
src/persistence/object-storage/reEncryptionSweep.ts:142-220— the sweep loop trustsitems[i].keystraight frombackend.list().src/persistence/object-storage/reEncryptionSweep.ts:180-181—pidFromKey(item.key, opts.keyPrefix)uses the key shape directly; a malformed key produces a malformed pid.src/persistence/object-storage/reEncryptionSweep.ts:223-240—defaultPidFromKeyextracts the pid by splitting on/. Ifitem.keydoesn't have the expected layout (e.g. starts with.., contains NUL, or has zero/-separators), the extracted pid is garbage.Background
The sweep walks the corpus by listing keys under a prefix:
The hardening landed in
a290b06covers the write path (FS backend rejects malformed keys onput/get/delete/list). But the sweep is on the read path: it accepts whateverlist()returns. If a backend's response includes:..segments (path-traversal-like; should never happen on S3 but might on a custom backend).<keyPrefix><pid>/<rest>shape.…then
defaultPidFromKeyproduces a garbage pid string. Downstream:deriveSubkey(masterKey, garbage-pid, info)produces a subkey.aesGcmDecrypt(subkey, iv, ciphertext)fails — wrong subkey, auth-tag mismatch.The sweep aborts on that error (per the design: "Per-object failures … bubble up immediately and stop the sweep"). Operator sees a confusing error.
The worse case: if the backend returns a key whose pid-extractor output happens to collide with a legitimate pid (e.g. via NUL truncation), the sweep re-encrypts the legitimate pid's data with a key derived from the wrong pid → encrypted under a key that nobody else will use → data effectively lost.
Exploit walkthrough
Setup: deployment uses a custom
ObjectStorageBackend(e.g. an HTTP-backed minio-like service). Backend has a bug that occasionally returns keys with trailing NUL bytes:'users/42/snap.json\0\0\0'.Step 1 — sweep starts:
backend.list({ prefix: 'users/' })returns a list including the NUL-poisoned key.Step 2 — pid extraction:
defaultPidFromKey('users/42/snap.json\0\0\0', 'users/')returns'42'(the slash-separator is at the right position; NUL bytes are kept).Wait — that's actually fine for this specific case. Let me redo with a worse shape:
Step 1 — backend returns
'/etc/passwd'(a S3-impl bug returning a malformed key that S3 itself wouldn't produce).Step 2 —
defaultPidFromKey('/etc/passwd', 'users/'): the key doesn't start with the prefix, so the helper's logic falls through tokey.indexOf('/', 0)→ returns''(empty string before the first slash).Step 3 —
deriveSubkey(masterKey, '', info): HKDF with an empty salt. Different from the legitimate pid's subkey.Step 4 — decrypt:
aesGcmDecrypt(wrongSubkey, iv, ciphertext)→ auth-tag mismatch → throws.Step 5 — sweep aborts mid-corpus. Operator sees decrypt failure. Reruns; same failure on the same offending key.
The operator's workaround: configure
opts.skipto filter the bad key. But before they figure out which key is the issue, they may run the sweep many times, slowing down rotation.The actually-worse scenario: a custom backend returns the same legitimate key twice but the second time with a trailing space.
pidFromKey('users/42/snap.json ', ...)extracts'42'correctly (trailing space is in the rest, not the pid). But:deriveSubkeysalt is'42'— wait, that's the same. OK actually this case is fine.The genuine worry is NUL-truncation behavior in the underlying crypto: WebCrypto / Bun's HKDF treat the salt as bytes, not as a C-string, so NUL-terminator confusion doesn't happen at the crypto layer. Safe.
So the realistic damage is: sweep aborts, operator confused. Not catastrophic — but the fix (validate at the read boundary) costs nothing.
How the 8 already-landed security fixes inform this
a290b06): closed the write-side key-traversal door. This issue is the read-side complement: don't trust keys we read from the backend either.65a2bc5): used aassertSafe...validator at the API boundary. Same pattern here: validate every key the sweep iterates over.Fix design
Two complementary checks.
Track 1 — validate keys from
list().Reuse
assertSafeKey()fromFilesystemObjectStorageBackend.ts(or extract to a shared helper). In the sweep loop:assertSafeKeyrejects: empty strings, NUL bytes, absolute paths (POSIX + Windows),..segments.Track 2 — validate pid-extraction output.
After
pidFromKey(...), verify the result is non-empty and shape-sane:Defends against pid-extractor edge cases not caught by
assertSafeKey.Track 3 — extract to shared helper.
assertSafeKeycurrently lives inFilesystemObjectStorageBackend.ts. Extract to a shared module so both the FS backend and the sweep use the same validator:Updates
FilesystemObjectStorageBackendto import from there.Track 4 — counter + result field.
ReEncryptResult.skippedMalformed: numberfield;obj_storage_sweep_malformed_keys_totalmetric.API surface
Backward compatibility
The sweep's behaviour changes from "abort on first malformed key" to "skip + log + count, continue with the rest". This is a behaviour change: previously, a malformed key was a hard failure (forcing the operator to investigate); now it's a counted skip. Operators relying on the abort-on-malformed signal will need to inspect
result.skippedMalformedafter the run.Trade-off: aborting on malformed keys gives faster feedback but blocks completion for what may be a non-fatal backend hiccup. Skip+log lets the sweep complete and surface the issue in the result. Tunable via
opts.malformedKeyMode: 'skip' | 'abort'(default:skip).Test plan
Exploit test: mock backend that returns a key with
..segment in its list; sweep skips it + counter increments; sweep completes for all other keys.NUL-key test: key containing
\0; rejected byassertSafeKey; skipped.Empty-pid test: key that doesn't match the prefix;
defaultPidFromKeyreturns empty; skipped via Track 2.Mode test: with
malformedKeyMode: 'abort', sweep throws on first malformed key (preserves old behaviour for users who want it).Regression: existing
reEncryptionSweep.test.tstests pass.Acceptance criteria
KeyValidator.tsextracted as a shared module; both FS backend and sweep use it.item.key(viaassertSafeKey) +pid(post-extraction shape check).ReEncryptResult.skippedMalformedfield added;onProgressemits'skipped-malformed'.opts.malformedKeyMode: 'skip' | 'abort'(default'skip').obj_storage_sweep_malformed_keys_totalmetric exposed.