Skip to content

fix(security): Credit Deduction TOCTOU Race Condition#1103

Open
mdisec wants to merge 1 commit intof:mainfrom
mdisec:toctou-vulnerability-media-generation
Open

fix(security): Credit Deduction TOCTOU Race Condition#1103
mdisec wants to merge 1 commit intof:mainfrom
mdisec:toctou-vulnerability-media-generation

Conversation

@mdisec
Copy link
Copy Markdown
Contributor

@mdisec mdisec commented Mar 25, 2026

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-generate
File: src/app/api/media-generate/route.ts

CVSS v3.1 Score: 6.5 (Medium)

Vector:AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N


Executive 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)

// L54-60: Read the credit balance
const user = await db.user.findUnique({
  where: { id: session.user.id },
  select: { generationCreditsRemaining: true, flagged: true },
});

// L75: Authorize based on stale snapshot
if (user.generationCreditsRemaining <= 0) {
  return NextResponse.json({ error: "..." }, { status: 403 });
}

Step 2 — RACE WINDOW (Lines 82–115)

// L82-90:  Request body parsing
// L92-106: Plugin resolution and validation
// L108-115: External network call to Fal.ai / Wiro.ai (seconds of latency)
const task = await plugin.startGeneration({ ... });

Step 3 — USE / DECREMENT (Lines 118–125)

// L118-125: Decrement credit — far too late
await db.user.update({
  where: { id: session.user.id },
  data: { generationCreditsRemaining: { decrement: 1 } },
});

Race Window Analysis

The gap between CHECK and DECREMENT spans:

Phase Duration What Happens
Body parsing + validation ~1–5 ms JSON parse, field presence checks
Plugin resolution ~1 ms Map lookup
External API call 200 ms – 10+ sec Network round-trip to Fal.ai/Wiro.ai queue API
Total window ~200 ms – 10+ sec All concurrent requests read stale balance

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.

Time   Request A                  Request B                  DB State
─────  ────────────────────────   ────────────────────────   ────────
T+0    READ credits = 1 ✓         -                          credits = 1
T+1    -                          READ credits = 1 ✓         credits = 1
T+2    startGeneration() →        startGeneration() →        credits = 1
       (waiting for Fal.ai)       (waiting for Fal.ai)
T+3s   ← response                 ← response                credits = 1
T+3s   DECREMENT → credits = 0   DECREMENT → credits = -1   credits = -1

Result: 2 generations consumed with 1 credit. Scale to N concurrent requests for N generations with 1 credit.

Practical Attack

# Fire 50 concurrent requests with a single credit
for i in $(seq 1 50); do
  curl -s -X POST https://target/api/media-generate \
    -H "Cookie: session=..." \
    -H "Content-Type: application/json" \
    -d '{"prompt":"test","model":"...","provider":"fal","type":"image"}' &
done
wait

Missing Safeguards

Safeguard Present? Details
Database transaction wrapping read+decrement No $transaction usage
Row-level lock (SELECT ... FOR UPDATE) Not used anywhere in codebase
Atomic conditional update (WHERE credits > 0) Read and write are separate
CHECK constraint on column No CHECK constraints in any migration
Rate limiting on endpoint Rate limiters only exist for MCP tools
Middleware-level throttle No middleware.ts exists

Database Schema Context

// prisma/schema.prisma
model User {
  generationCreditsRemaining Int @default(3)
  dailyGenerationLimit       Int @default(3)
  // No @db.Check constraint, no trigger, no guard
}

Prisma's { decrement: 1 } compiles to:

UPDATE "users" 
SET "generationCreditsRemaining" = "generationCreditsRemaining" - 1 
WHERE "id" = $1;

This is atomically correct (no lost updates) but logically insufficient — PostgreSQL will happily set the value to -49 when 50 concurrent requests each decrement from 1.


All Access Patterns for generationCreditsRemaining

Location Operation Context
media-generate/route.ts L57 findUnique (read) POST — credit check
media-generate/route.ts L26 findUnique (read) GET — UI display
media-generate/route.ts L121 update decrement POST — deduction
cron/reset-credits/route.ts L40 Raw SQL bulk SET Daily reset cron
admin/users/[id]/route.ts L60 update direct SET Admin manual override

Recommended 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:

// BEFORE calling startGeneration — atomic check + decrement
const result = await db.user.updateMany({
  where: {
    id: session.user.id,
    generationCreditsRemaining: { gt: 0 },
    flagged: false,
  },
  data: {
    generationCreditsRemaining: { decrement: 1 },
  },
});

if (result.count === 0) {
  return NextResponse.json(
    { error: "Insufficient credits or account flagged" },
    { status: 403 }
  );
}

// Credit is already deducted — now safe to call external API
try {
  const task = await plugin.startGeneration({ ... });
  // return success
} catch (error) {
  // Refund on failure
  await db.user.update({
    where: { id: session.user.id },
    data: { generationCreditsRemaining: { increment: 1 } },
  });
  throw error;
}

Why this works: The SQL becomes:

UPDATE "users" 
SET "generationCreditsRemaining" = "generationCreditsRemaining" - 1 
WHERE "id" = $1 
  AND "generationCreditsRemaining" > 0 
  AND "flagged" = false;

When two concurrent requests race, PostgreSQL's row-level locking ensures only one UPDATE matches the WHERE credits > 0 condition when credits = 1. The second request sees count = 0 and is rejected.

Defense in Depth — Database CHECK Constraint

Add a migration as a safety net:

ALTER TABLE "users" 
ADD CONSTRAINT "credits_non_negative" 
CHECK ("generationCreditsRemaining" >= 0);

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:

  1. Atomic conditional decrement with refund-on-failure (eliminates the TOCTOU)
  2. Database CHECK constraint (defense in depth)

Summary by CodeRabbit

Bug Fixes

  • Implemented database constraint preventing generation credits from becoming negative
  • Generation credit deduction now occurs atomically for improved reliability
  • Failed generation attempts automatically refund deducted credits to users

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

📝 Walkthrough

Walkthrough

A 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

Cohort / File(s) Summary
Database Constraint
prisma/migrations/20260325164300_add_credits_non_negative_check/migration.sql
Adds CHECK constraint credits_non_negative to ensure generationCreditsRemaining >= 0 on the users table.
Media Generation API
src/app/api/media-generate/route.ts
Refactors credit validation to use atomic updateMany that conditionally decrements credits only when user is not flagged and has sufficient credits. Moves credit deduction before external generation call. Adds try/catch around startGeneration to refund credits on failure and re-throw errors for 500 response handling.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Credits flow like clover in spring,
Now guarded by constraints, a safety ring!
Atoms dance, refunds race back,
No more negatives on the ledger's track. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main security fix: resolving a TOCTOU race condition in the credit deduction logic, which aligns with the primary objective and changes made.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-intl flow instead of adding more literal user-facing text here.

As per coding guidelines, "Use next-intl for 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

📥 Commits

Reviewing files that changed from the base of the PR and between 30a8f04 and aab8302.

📒 Files selected for processing (2)
  • prisma/migrations/20260325164300_add_credits_non_negative_check/migration.sql
  • src/app/api/media-generate/route.ts

Comment on lines +1 to +2
-- AlterTable: Add CHECK constraint to prevent negative generation credits
ALTER TABLE "users" ADD CONSTRAINT "credits_non_negative" CHECK ("generationCreditsRemaining" >= 0);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
-- 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.

Comment on lines +125 to +131
} catch (generationError) {
// Refund the credit on generation failure
await db.user.update({
where: { id: session.user.id },
data: { generationCreditsRemaining: { increment: 1 } },
});
throw generationError;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants