fix(security): Credit Deduction TOCTOU Race Condition#1103
fix(security): Credit Deduction TOCTOU Race Condition#1103
Conversation
📝 WalkthroughWalkthroughA database constraint is added to prevent negative generation credits, while the media generation API is refactored to atomically decrement credits before starting external generation, with automatic refunding if the operation fails. Changes
Sequence DiagramsequenceDiagram
actor Client
participant API as API Route
participant DB as Database
participant Gen as Generation Service
Client->>API: POST /media-generate
API->>DB: Atomic updateMany:<br/>Find user (not flagged,<br/>credits > 0) & decrement
alt Atomic update succeeds
DB-->>API: ✓ Updated, credits decremented
API->>Gen: startGeneration()
alt Generation succeeds
Gen-->>API: ✓ Started
API-->>Client: 200 Response
else Generation fails
Gen-->>API: ✗ Error
API->>DB: Refund (increment credits)
DB-->>API: ✓ Refunded
API-->>Client: 500 Error
end
else Atomic update fails (0 rows)
DB-->>API: No rows affected
API->>DB: Fetch user to determine reason
DB-->>API: User data
alt User not found
API-->>Client: 404 Not Found
else User flagged or insufficient credits
API-->>Client: 403 Forbidden
end
end
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/app/api/media-generate/route.ts (1)
99-110: Localize the new API error strings.These new response messages are hardcoded in the route. Please source them through the existing
next-intlflow instead of adding more literal user-facing text here.As per coding guidelines, "Use
next-intlfor all user-facing strings; never hardcode text."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/media-generate/route.ts` around lines 99 - 110, Replace the hardcoded user-facing strings returned in route.ts (the responses around the user existence check, user.flagged check, and credits check that currently call NextResponse.json) with localized messages from the project's next-intl flow: import and instantiate the server translator (e.g., createTranslator/getTranslator from next-intl/server) using the request locale, replace the three literal messages with translator keys (e.g., errors.userNotFound, errors.accountFlagged, errors.noGenerationCredits), and return NextResponse.json with the translated strings; ensure the new keys are added to the locale JSON files.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@prisma/migrations/20260325164300_add_credits_non_negative_check/migration.sql`:
- Around line 1-2: The migration adds a CHECK constraint on ALTER TABLE "users"
named "credits_non_negative" that will be validated immediately and will fail if
any existing "generationCreditsRemaining" rows are negative; fix by first
normalizing/backfilling any negative "generationCreditsRemaining" values to >= 0
(e.g., an UPDATE that sets negative balances to 0) or else create the constraint
as NOT VALID (ALTER TABLE "users" ADD CONSTRAINT "credits_non_negative" CHECK
("generationCreditsRemaining" >= 0) NOT VALID) and then run a separate
validation step (ALTER TABLE "users" VALIDATE CONSTRAINT "credits_non_negative")
after cleanup so the migration does not abort on existing bad rows.
In `@src/app/api/media-generate/route.ts`:
- Around line 125-131: The current catch in route.ts blindly refunds on any
generation error; change this to only refund when the failure is provably
pre-dispatch. Update plugin.startGeneration (and/or its callers in wiro.ts and
fal.ts, e.g., submitToFalQueue) to return a dispatch status (e.g., { dispatched:
boolean } or throw a specific PreDispatchError) so the caller can tell if the
provider request was actually sent; then in the catch/use result in route.ts
only increment generationCreditsRemaining when dispatched === false or the error
is the specific pre-dispatch error, otherwise keep the debit and let
reconciliation handle ambiguous/remote executions.
---
Nitpick comments:
In `@src/app/api/media-generate/route.ts`:
- Around line 99-110: Replace the hardcoded user-facing strings returned in
route.ts (the responses around the user existence check, user.flagged check, and
credits check that currently call NextResponse.json) with localized messages
from the project's next-intl flow: import and instantiate the server translator
(e.g., createTranslator/getTranslator from next-intl/server) using the request
locale, replace the three literal messages with translator keys (e.g.,
errors.userNotFound, errors.accountFlagged, errors.noGenerationCredits), and
return NextResponse.json with the translated strings; ensure the new keys are
added to the locale JSON files.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 53ee740a-21e3-4c5c-b4e3-d91164f05e09
📒 Files selected for processing (2)
prisma/migrations/20260325164300_add_credits_non_negative_check/migration.sqlsrc/app/api/media-generate/route.ts
| -- AlterTable: Add CHECK constraint to prevent negative generation credits | ||
| ALTER TABLE "users" ADD CONSTRAINT "credits_non_negative" CHECK ("generationCreditsRemaining" >= 0); |
There was a problem hiding this comment.
Backfill existing negative balances before validating this constraint.
This CHECK is validated immediately, so any row already pushed below zero by the old race will make the migration fail and block the security fix from deploying. Normalize existing negatives first, or add the constraint NOT VALID and validate after cleanup.
🛠 Suggested migration sequence
+UPDATE "users"
+SET "generationCreditsRemaining" = 0
+WHERE "generationCreditsRemaining" < 0;
+
ALTER TABLE "users" ADD CONSTRAINT "credits_non_negative" CHECK ("generationCreditsRemaining" >= 0);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| -- AlterTable: Add CHECK constraint to prevent negative generation credits | |
| ALTER TABLE "users" ADD CONSTRAINT "credits_non_negative" CHECK ("generationCreditsRemaining" >= 0); | |
| UPDATE "users" | |
| SET "generationCreditsRemaining" = 0 | |
| WHERE "generationCreditsRemaining" < 0; | |
| -- AlterTable: Add CHECK constraint to prevent negative generation credits | |
| ALTER TABLE "users" ADD CONSTRAINT "credits_non_negative" CHECK ("generationCreditsRemaining" >= 0); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@prisma/migrations/20260325164300_add_credits_non_negative_check/migration.sql`
around lines 1 - 2, The migration adds a CHECK constraint on ALTER TABLE "users"
named "credits_non_negative" that will be validated immediately and will fail if
any existing "generationCreditsRemaining" rows are negative; fix by first
normalizing/backfilling any negative "generationCreditsRemaining" values to >= 0
(e.g., an UPDATE that sets negative balances to 0) or else create the constraint
as NOT VALID (ALTER TABLE "users" ADD CONSTRAINT "credits_non_negative" CHECK
("generationCreditsRemaining" >= 0) NOT VALID) and then run a separate
validation step (ALTER TABLE "users" VALIDATE CONSTRAINT "credits_non_negative")
after cleanup so the migration does not abort on existing bad rows.
| } catch (generationError) { | ||
| // Refund the credit on generation failure | ||
| await db.user.update({ | ||
| where: { id: session.user.id }, | ||
| data: { generationCreditsRemaining: { increment: 1 } }, | ||
| }); | ||
| throw generationError; |
There was a problem hiding this comment.
Only refund credits for provably pre-dispatch failures.
plugin.startGeneration() can throw after the provider has already received the request. src/lib/plugins/media-generators/wiro.ts:153-225 throws after the POST returns, and src/lib/plugins/media-generators/fal.ts:244-286 throws after submitToFalQueue(...). Refunding on every exception can therefore re-credit a generation that may still execute remotely, which reintroduces a credit-bypass path. If dispatch status is ambiguous, keep the debit and reconcile instead of immediately incrementing.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/api/media-generate/route.ts` around lines 125 - 131, The current
catch in route.ts blindly refunds on any generation error; change this to only
refund when the failure is provably pre-dispatch. Update plugin.startGeneration
(and/or its callers in wiro.ts and fal.ts, e.g., submitToFalQueue) to return a
dispatch status (e.g., { dispatched: boolean } or throw a specific
PreDispatchError) so the caller can tell if the provider request was actually
sent; then in the catch/use result in route.ts only increment
generationCreditsRemaining when dispatched === false or the error is the
specific pre-dispatch error, otherwise keep the debit and let reconciliation
handle ambiguous/remote executions.
Security Report: Credit Deduction TOCTOU Race Condition
Severity: High
Category: CWE-367 — Time-of-Check Time-of-Use Race Condition
Affected Endpoint:
POST /api/media-generateFile:
src/app/api/media-generate/route.tsCVSS v3.1 Score: 6.5 (Medium)
Vector:
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:NExecutive Summary
The media generation endpoint contains a Time-of-Check Time-of-Use (TOCTOU) race condition in its credit deduction logic. The credit balance is read and checked in one database query, then decremented in a separate query after a potentially slow external API call. An attacker with a single remaining credit can fire multiple concurrent requests that all pass the balance check before any decrement occurs, resulting in unlimited free media generations.
Vulnerable Code Flow
Step 1 — CHECK (Lines 54–75)
Step 2 — RACE WINDOW (Lines 82–115)
Step 3 — USE / DECREMENT (Lines 118–125)
Race Window Analysis
The gap between CHECK and DECREMENT spans:
The external API call (
plugin.startGeneration()) is the critical amplifier — it creates a multi-second window during which the credit balance in the database remains unchanged.Exploitation Scenario
Precondition: Attacker has exactly 1 credit remaining.
Result: 2 generations consumed with 1 credit. Scale to N concurrent requests for N generations with 1 credit.
Practical Attack
Missing Safeguards
$transactionusageSELECT ... FOR UPDATE)WHERE credits > 0)CHECKconstraint on columnmiddleware.tsexistsDatabase Schema Context
Prisma's
{ decrement: 1 }compiles to:This is atomically correct (no lost updates) but logically insufficient — PostgreSQL will happily set the value to
-49when 50 concurrent requests each decrement from 1.All Access Patterns for
generationCreditsRemainingmedia-generate/route.tsL57findUnique(read)media-generate/route.tsL26findUnique(read)media-generate/route.tsL121updatedecrementcron/reset-credits/route.tsL40admin/users/[id]/route.tsL60updatedirect SETRecommended Fix
Primary Fix — Atomic Conditional Decrement (Before Generation)
Replace the three-step read → check → decrement with a single atomic conditional update before calling the external API:
Why this works: The SQL becomes:
When two concurrent requests race, PostgreSQL's row-level locking ensures only one
UPDATEmatches theWHERE credits > 0condition whencredits = 1. The second request seescount = 0and is rejected.Defense in Depth — Database CHECK Constraint
Add a migration as a safety net:
This makes any update that would drive credits negative fail at the database level, providing a backstop against any future code path that bypasses the application-level check.
Recommendation
Apply both fixes:
Summary by CodeRabbit
Bug Fixes