Back to Blog
critical SEVERITY8 min read

How unlimited batch API calls happen in React JSX and how to fix it

A missing batch size limit in `BatchModeRunner.jsx` allowed users to trigger unlimited LLM API calls by pasting thousands of items into the batch input field. This could exhaust shared API quotas in organizational settings where a single API key is distributed across multiple users. The fix introduces a hard cap of 25 items (`MAX_BATCH_SIZE = 25`) enforced directly in the `canRun()` validation function.

O
By Orbis AppSec
Published July 24, 2026Reviewed July 24, 2026

Answer Summary

This vulnerability is an unbounded API consumption issue (CWE-770: Allocation of Resources Without Limits or Throttling) in a React JSX component (`BatchModeRunner.jsx`). Because the `canRun()` function in `BatchModeRunner.jsx` never checked how many items were in the batch queue, an attacker or careless user could submit thousands of LLM API calls in a single batch run, exhausting shared API quotas. The fix adds a `MAX_BATCH_SIZE` constant of 25 and enforces it as a guard condition inside `canRun()`, blocking the Run Batch button whenever the item count exceeds the limit.

Vulnerability at a Glance

cweCWE-770
fixAdded `MAX_BATCH_SIZE = 25` constant and enforced `items.length > MAX_BATCH_SIZE` check inside `canRun()`
riskExhaustion of shared API quotas; financial and availability impact in organizational deployments
languageJavaScript (React JSX)
root causeThe `canRun()` function in `BatchModeRunner.jsx` never validated `items.length` against a maximum, allowing arbitrarily large batches
vulnerabilityUnbounded LLM API Batch Consumption

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:

  1. An attacker (or a careless user) with access to a shared API key opens the Batch Mode interface in BatchModeRunner.jsx.
  2. They paste thousands of items into the batch input textarea — the component parses these via parsePastedLines() and populates the items state.
  3. canRun() returns true because items.length > 0, the API key is present, and batchFieldId is set.
  4. They click Run Batch. The runBatch() function begins processing all items with concurrency: 3, making thousands of authenticated LLM API calls.
  5. 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 fixcanRun() 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 fixcanRun() 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.


Key Takeaways

  • items.length === 0 is not the same as items.length <= MAX_BATCH_SIZE — the original canRun() in BatchModeRunner.jsx guarded against empty batches but left the upper bound completely open.
  • Concurrency limiting is not rate limitingrunBatch()'s concurrency: 3 controlled 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_SIZE make 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 the items state array in BatchModeRunner.jsx
  • Sink: The runBatch() call triggered when canRun() returns true, which dispatches LLM API calls for every item in the items array
  • Missing control: The canRun() function checked items.length === 0 (lower bound) but never checked items.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 = 25 and enforced items.length > MAX_BATCH_SIZE as a guard condition in canRun() at BatchModeRunner.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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #815

Related Articles

critical

deleteNestedProperty Prototype Pollution via Dot-Notation Path

The `deleteNestedProperty` function in propertyUtils.ts allowed attackers to manipulate JavaScript object prototypes by passing specially crafted dot-notation paths like `__proto__.polluted`. A fix now blocks dangerous keys before processing, preventing prototype pollution attacks that could affect all objects in the application.

high

How Denial of Service via Infinite Loop Happens in JavaScript Dependencies and How to Fix It

CVE-2026-67213 is a high-severity denial of service vulnerability in nanoid before version 5.1.6 that triggers an infinite loop during random ID generation when processing specially crafted input. We upgraded nanoid across the entire dependency tree to patch this flaw and prevent attackers from freezing application threads. This fix ensures that ID generation remains resilient even when handling adversarial input patterns.

high

How Sensitive Data Exposure happens in Zotero plugins and how to fix it

A high-severity data exposure vulnerability in `Zotero.ts` automatically transmitted complete document metadata—including private notes, attachment paths, and tags—to external LLM services without user consent. The fix replaces broad `item.toJSON()` serialization with explicit field selection, sending only essential bibliographic data.

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

critical

How Unbounded WebSocket Message Handling Causes Resource Exhaustion in Node.js and How to Fix It

The WebSocketCrossServerAdapter class in a popular Node.js WebSocket library lacked any rate limiting on inbound messages, allowing attackers to flood Redis nodes and WebSocket servers with high-volume traffic. The fix introduces a configurable `rateLimit` option that caps messages per connection per second, preventing resource exhaustion while preserving legitimate functionality.