AZ.dev

Content-hash dedup that restores a soft-deleted row must re-run quota and limit checks

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.

  1. Upload image C, 49MB. Quota check: 49 + 49 = 98. Passes. Used is now 98MB.
  2. 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 class of bug

Anywhere these three conditions hold:

  1. A per-user gauge limit (storage MB, row count, token balance).
  2. A short-circuit branch that returns an existing row instead of running the limit check.
  3. 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

dedup soft-delete quota race-condition postgres prisma

Was this helpful?

Related Entries

Reproduce Railway's npm ci locally with the exact npm versionSerialize per-user check-then-write flows with pg_advisory_xact_lock(hashtext(id))A storage URL builder and its static file handler must share one prefix constantFix "Cannot find module" with ES modules in Node.js