Content-hash dedup is supposed to be free: same bytes, same row, no charge. If your delete is soft, “no charge” becomes a quota bypass.
The buggy pattern
async function upload(userId: string, buffer: Buffer) {
const key = sha256(buffer);
const existing = await db.image.findUnique({
where: { userId_storageKey: { userId, storageKey: key } },
});
if (existing) {
if (existing.deletedAt !== null) {
// BUG: restoring a soft-deleted row puts its byteSize back into the
// user's quota total without re-checking the limit.
return db.image.update({
where: { id: existing.id },
data: { deletedAt: null },
});
}
return existing;
}
// Quota check only runs on the create path.
if (await usedBytes(userId) + buffer.length > cap) throw new QuotaError();
return db.image.create({ data: { userId, storageKey: key, byteSize: buffer.length } });
}
The exploit
User at 99/100 MB cap, image A is 50MB soft-deleted, image B is 49MB live. Used bytes (excluding soft-deleted) is 49MB.
- Upload image C, 49MB. Quota check: 49 + 49 = 98. Passes. Used is now 98MB.
- Re-upload image A’s exact bytes. Hash matches the soft-deleted row. Restore. Quota never re-checked. Used jumps to 148MB.
The user is now over a cap the system never let them legitimately exceed.
The fix
Treat restore as equivalent to insert for limit purposes.
if (existing.deletedAt !== null) {
return db.$transaction(async (tx) => {
await withUserLock(tx, userId, async () => {
const used = await usedBytes(tx, userId);
if (used + existing.byteSize > cap) {
throw new QuotaExceededError(used, cap);
}
});
return tx.image.update({
where: { id: existing.id },
data: { deletedAt: null },
});
});
}
The check goes inside the same per-user lock the create path uses (see pg_advisory_xact_lock). Concurrent restore-plus-create races serialize per-user without blocking other users.
Why this is easy to miss
- The restore branch feels like a no-op: “the bytes are already on disk, the row already exists, the user already had this image once.” Each of those is true; none of them implies “no quota delta.”
- Quota math correctly excludes soft-deleted rows from
usedBytes. Soft-deleting genuinely frees a user’s quota. Restoring is the inverse — and the inverse needs the same gate. - Standard test coverage misses it: the create-path test asserts quota rejection, the dedup-path test asserts idempotent return of the existing row, the soft-delete test asserts the row is hidden. None of them touches the restore branch.
The class of bug
Anywhere these three conditions hold:
- A per-user gauge limit (storage MB, row count, token balance).
- A short-circuit branch that returns an existing row instead of running the limit check.
- Soft-delete with restore-on-re-add semantics.
The same pattern bites with active-document counts, per-user file slots, draft caps, and any “free up by deleting” UX backed by a soft delete.
Defenses
- Always re-check the limit on restore. Adopt “any path that increases the active-row count must pass the gate.”
- Hard-delete sidesteps the bug entirely at the cost of breaking embeds in notes/markdown/etc. Decide which cost is acceptable per resource.
- Audit the dedup branch for limit awareness whenever you add a new per-user limit. The dedup code likely shipped before the limit did.