How unlimited batch API calls happen in React JSX and how to fix it
The Problem in Plain Terms
The BatchModeRunner.jsx component handles one of the most sensitive user-facing operations in this application: sending items to an LLM provider using a real API key. But until this fix landed, there was nothing stopping a user from pasting 10,000 lines into the batch input and clicking Run Batch — triggering 10,000 sequential LLM API calls, all authenticated with a shared organizational key.
This is the kind of vulnerability that doesn't look dangerous at first glance. There's no SQL injection, no XSS, no memory corruption. It's a missing > check in a single if statement. But in an organizational deployment where one API key is shared across a team, it's a direct path to quota exhaustion, unexpected billing spikes, and denial of service for every other user on that key.
The Vulnerability Explained
What canRun() Was Supposed to Do
The canRun() function in BatchModeRunner.jsx acts as a gate: it returns false if the component isn't ready to run, disabling the Run Batch button. Before the fix, it looked like this:
const canRun = () => {
if (!apiKey || items.length === 0 || !batchFieldId) return false
return agent.inputs
.filter((i) => i.required && i.id !== batchFieldId)
.every((i) => {
// ... checks for required fields
})
}
Notice what it checks:
- ✅ Is there an API key?
- ✅ Is the items array non-empty?
- ✅ Is a batch field selected?
- ❌ Is the items array within a safe size? — Never checked.
The check items.length === 0 guards against an empty batch, but there was no corresponding upper-bound check. The runBatch function (imported from ../lib/batchRunner) processes items with a fixed concurrency of 3 — meaning it runs 3 LLM calls in parallel — but it never limits how many total items it processes. A batch of 5,000 items runs at concurrency 3 until all 5,000 are done.
The Attack Scenario
The exploitation path is straightforward and requires no technical sophistication:
- An attacker (or a careless user) with access to a shared API key opens the Batch Mode interface in
BatchModeRunner.jsx. - They paste thousands of items into the batch input textarea — the component parses these via
parsePastedLines()and populates theitemsstate. canRun()returnstruebecauseitems.length > 0, the API key is present, andbatchFieldIdis set.- They click Run Batch. The
runBatch()function begins processing all items withconcurrency: 3, making thousands of authenticated LLM API calls. - Every other user on the same API key hits rate limits or quota caps. Depending on the provider and plan, this may also incur significant financial charges.
Because API calls go directly from the browser to the LLM provider, there is no server-side layer to catch or throttle this. The only defense is the client-side canRun() gate — which is exactly why plugging this gap matters.
Why This Is Specific to LLM Workloads
Traditional API abuse is bad. LLM API abuse is expensive. Each call to a frontier model can consume thousands of tokens, with costs measured in cents per call. A batch of 1,000 items at $0.01/call is $10 — a batch of 100,000 items is $1,000, all potentially charged to a shared organizational account before anyone notices.
The Fix
What Changed
The fix is compact but precise. It introduces a named constant at the top of the file:
const MAX_BATCH_SIZE = 25
And then enforces it inside canRun():
// Before
if (!apiKey || items.length === 0 || !batchFieldId) return false
// After
if (!apiKey || items.length === 0 || items.length > MAX_BATCH_SIZE || !batchFieldId) return false
Before the fix — canRun() only checked that the batch was non-empty:
const canRun = () => {
if (!apiKey || items.length === 0 || !batchFieldId) return false
return agent.inputs
.filter((i) => i.required && i.id !== batchFieldId)
.every((i) => { /* required field checks */ })
}
After the fix — canRun() now enforces both a lower and upper bound on batch size:
const canRun = () => {
if (!apiKey || items.length === 0 || items.length > MAX_BATCH_SIZE || !batchFieldId) return false
return agent.inputs
.filter((i) => i.required && i.id !== batchFieldId)
.every((i) => { /* required field checks */ })
}
Why This Specific Change Works
The canRun() return value is used to disable the Run Batch button in the UI. By returning false whenever items.length > MAX_BATCH_SIZE, the button stays disabled and runBatch() is never called — the LLM API is never reached.
Using a named constant (MAX_BATCH_SIZE = 25) rather than a magic number (items.length > 25) is intentional good practice: it makes the limit self-documenting, easy to audit, and straightforward to adjust in one place if the limit needs to change.
The value of 25 is a deliberate, conservative choice. It allows legitimate batch use cases (running a prompt against a small dataset, testing across a set of inputs) while making quota exhaustion attacks impractical.
Prevention & Best Practices
Always Validate Both Bounds on Array Inputs
Whenever user-controlled data populates an array that drives API calls, validate both the minimum and maximum:
// Weak — only checks lower bound
if (items.length === 0) return false
// Strong — checks both bounds
if (items.length === 0 || items.length > MAX_BATCH_SIZE) return false
This pattern applies anywhere user input determines how many times an external service is called: batch processors, bulk importers, multi-recipient email senders, etc.
Define Limits as Named Constants
Magic numbers buried in conditions are easy to miss in code review and hard to adjust consistently:
// Hard to audit, hard to change
if (items.length > 25) return false
// Self-documenting, easy to find and update
const MAX_BATCH_SIZE = 25
if (items.length > MAX_BATCH_SIZE) return false
Don't Rely Solely on Concurrency Limits
The runBatch() function already used concurrency: 3 to prevent hammering the API in parallel. This is a throughput control, not a volume control. Concurrency limits are important, but they don't cap total consumption — pair them with batch size limits.
Add Server-Side Rate Limiting When Possible
Client-side guards like canRun() can be bypassed by a determined attacker who calls the underlying API directly. For production systems, server-side rate limiting (e.g., per-user, per-key, per-minute quotas) provides a defense-in-depth layer that cannot be circumvented from the browser.
Relevant Standards
- CWE-770: Allocation of Resources Without Limits or Throttling
- OWASP API Security Top 10 — API4:2023: Unrestricted Resource Consumption — covers exactly this pattern: missing or inadequate rate limiting on API operations.
Key Takeaways
items.length === 0is not the same asitems.length <= MAX_BATCH_SIZE— the originalcanRun()inBatchModeRunner.jsxguarded against empty batches but left the upper bound completely open.- Concurrency limiting is not rate limiting —
runBatch()'sconcurrency: 3controlled parallelism, not total call volume; a batch of 10,000 items would still make 10,000 API calls. - Client-side gates must check upper bounds on any array that drives external API calls, especially when those calls carry shared credentials.
- Named constants like
MAX_BATCH_SIZEmake security limits visible and maintainable — a reviewer can immediately see the intent and the value without decoding a magic number. - LLM API calls have direct financial cost — unlike many resource exhaustion issues, unbounded LLM batch calls can translate to real charges on a shared account within minutes.
How Orbis AppSec Detected This
- Source: User-pasted text in the batch input textarea, parsed by
parsePastedLines()into theitemsstate array inBatchModeRunner.jsx - Sink: The
runBatch()call triggered whencanRun()returnstrue, which dispatches LLM API calls for every item in theitemsarray - Missing control: The
canRun()function checkeditems.length === 0(lower bound) but never checkeditems.length > [limit](upper bound), leaving total batch size unbounded - CWE: CWE-770 — Allocation of Resources Without Limits or Throttling
- Fix: Added
const MAX_BATCH_SIZE = 25and enforceditems.length > MAX_BATCH_SIZEas a guard condition incanRun()atBatchModeRunner.jsx
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The vulnerability in BatchModeRunner.jsx is a reminder that resource exhaustion issues don't require sophisticated attack techniques — sometimes a missing > comparison in a guard function is all it takes. The canRun() function was doing its job of preventing invalid states, but it was never asked to prevent excessive states. Adding items.length > MAX_BATCH_SIZE to that check closes the gap with minimal code change and zero impact on legitimate use cases.
For developers building components that drive external API calls from user-controlled inputs: always think about both ends of the spectrum. Empty input is the obvious case. Unbounded input is the dangerous one.