Software Securityhome
Fix a findingSearch
Fix card · A06:2025 Insecure Design

Fix unbounded resource consumption in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Bound every resource a request can consume: cap the body size as it is read (express.json's limit), cap the work one request asks for (a maximum item count), and rate-limit callers keyed on the connection address, not a header the client sets. Answer 413, 400 and 429 (with Retry-After) at the three limits.

On this page

Steps

  1. List what a single request can make the server spend: bytes read, items processed, rows returned, time, connections. Set an explicit maximum for each.
  2. Cap the request body with express.json({ limit }) so an over-size body is rejected (413) while it is read, not after it is buffered. Do not raise the limit globally to silence a 413.
  3. Cap the work: validate that a batch endpoint received no more than N items and answer 400 beyond it. Do the same for page sizes and any caller-supplied count.
  4. Rate-limit by an identity the client cannot choose: the connection's address (behind a trusted proxy, the client IP it sets), not an X-Client-Id or API-key header an attacker can rotate. Return 429 with a Retry-After header.
  5. Give long or heavy work a timeout and run it with backpressure so one caller cannot occupy every worker.
  6. Add regression tests for an over-size body, an over-count batch, and a caller that rotates a request header to try to escape the limiter.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
export const MAX_ITEMS = Number.POSITIVE_INFINITY;

export function createBodyParser() {
  // Effectively no limit: Express's body-parser default is 100kb, but it was raised
  // to a gigabyte ("we were getting 413s"), which is the same as having none.
  return express.json({ limit: '1gb' });
}

export function createRateLimit() {
  // No throttling: every request is let through.
  return (_req, _res, next) => next();
}

With IMPL=vulnerable, 4 of 7 tests fail (3 exploit tests).

The fix

Fixedimpl/fixed.jsJavaScript
export const MAX_ITEMS = 100;

export function createBodyParser() {
  return express.json({ limit: '16kb' });
}

export function createRateLimit() {
  const WINDOW_MS = 10_000;
  const MAX = 5;
  const hits = new Map(); // key -> { count, resetAt }

  return (req, res, next) => {
    // Key on the peer address the server observed, not on anything the client sends.
    const key = req.ip ?? req.socket.remoteAddress ?? 'unknown';
    const now = Date.now();
    let entry = hits.get(key);
    if (!entry || now >= entry.resetAt) {
      entry = { count: 0, resetAt: now + WINDOW_MS };
      hits.set(key, entry);
    }
    entry.count += 1;
    if (entry.count > MAX) {
      res.set('Retry-After', String(Math.ceil((entry.resetAt - now) / 1000)));
      return res.status(429).json({ error: 'rate limit exceeded' });
    }
    next();
  };
}

With IMPL=fixed, 7 of 7 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
export const MAX_ITEMS = 100;

export function createBodyParser() {
  return express.json({ limit: '16kb' });
}

export function createRateLimit() {
  const WINDOW_MS = 10_000;
  const MAX = 5;
  const hits = new Map();

  return (req, res, next) => {
    // "Key on the caller's client id so each integration gets its own quota."
    // The client sets the header, so it also chooses its bucket.
    const key = req.get('x-client-id') || req.ip || 'unknown';
    const now = Date.now();
    let entry = hits.get(key);
    if (!entry || now >= entry.resetAt) {
      entry = { count: 0, resetAt: now + WINDOW_MS };
      hits.set(key, entry);
    }
    entry.count += 1;
    if (entry.count > MAX) {
      res.set('Retry-After', String(Math.ceil((entry.resetAt - now) / 1000)));
      return res.status(429).json({ error: 'rate limit exceeded' });
    }
    next();
  };
}

With IMPL=wrong, 1 of 7 tests fail (1 exploit test).

Route: the three limits applied

app.jsJavaScript
// impl.createRateLimit() throttles callers, impl.createBodyParser() caps the
// request body while it is read, and impl.MAX_ITEMS caps the work one request may
// ask for. The vulnerable build sets none of these.
app.use(impl.createRateLimit());

app.post('/quotes', impl.createBodyParser(), (req, res) => {
  const items = req.body?.items;
  if (!Array.isArray(items)) {
    return res.status(400).json({ error: 'items must be an array' });
  }
  if (items.length > impl.MAX_ITEMS) {
    return res.status(400).json({ error: `at most ${impl.MAX_ITEMS} items per request` });
  }
  res.json({ quotes: items.map(quoteFor) });
});

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/quotes.test.jsJavaScript
describe(`exploit (IMPL=${implName})`, () => {
  test('a body larger than the cap is rejected before it is processed', async () => {
    // ~57 KiB of JSON, well over a 16 KiB cap.
    const res = await postQuotes({ items: items(3000) });
    assert.equal(res.status, 413, `expected 413, got ${res.status} ${res.body.slice(0, 80)}`);
  });

  test('more items than the cap are rejected', async () => {
    // 500 compact items stay under the body cap but over the work cap.
    const res = await postQuotes({ items: items(500) });
    assert.equal(res.status, 400, `expected 400, got ${res.status} ${res.body.slice(0, 80)}`);
  });

  test('rotating a client header does not defeat the rate limit', async () => {
    const statuses = [];
    for (let i = 0; i < 8; i += 1) {
      statuses.push((await postQuotes({ items: items(1) }, { 'x-client-id': `client-${i}` })).status);
    }
    assert.ok(statuses.includes(429), `no request was throttled: ${statuses.join(',')}`);
  });
});

Pitfalls

  • Keying a rate limit on a request header (X-Client-Id, X-Forwarded-For without a trusted-proxy setup, an API key the client picks) lets an attacker rotate it and get a fresh bucket each time. That is this card's wrong implementation.
  • Raising a body-size limit to make a 413 go away removes the only cap on memory; size the limit to the feature instead.
  • Checking items.length after JSON.parse has already buffered a huge array: cap the body size too, so the array never fully materializes.
  • A per-process in-memory counter does not limit across instances; use a shared store (Redis) for a cluster, and keep the per-instance cap as a floor.
  • Validating only the count, not the per-item cost: ten items that each trigger an expensive join can be worse than a thousand cheap ones. Bound the work, not just the count.
  • Missing the slow paths: large query results, regex over user input, unbounded concurrency to a downstream service. Each needs its own limit.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs resource-limits/node-express does this for you).
  2. Against your own API, send a body over the cap (expect 413), a batch over the item cap (expect 400), and a burst that rotates X-Client-Id or your API-key header (expect a 429 regardless, with Retry-After).
  3. The vulnerable build accepts the over-size body, the over-count batch and the burst; the wrong build enforces the body and item caps but its header-keyed limiter is defeated by rotation. Do not rely on a scanner: none flags these, so the tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix unbounded resource consumption in Node.js and Express
ToolRuleWhat it found here
Semgrepp/nodejs0 findings on app.js and impl/*.js. No registry rule flags a missing body-size limit, a missing work cap or a rate limiter keyed on a request header.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0

Static analysis cannot tell whether a limit is present and correct: a rate limiter keyed on a spoofable header looks the same as one keyed on the connection. The regression tests, which drive the three limits, are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0
body-parser (bundled with Express 5)2.3.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the resource-limits/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs resource-limits/node-express runs all three modes and checks the expected results.

Download the project ZIP, 23 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
Matrix
fixed: 7 of 7 tests pass; vulnerable: 4 of 7 tests fail (3 exploit tests); wrong: 1 of 7 tests fail (1 exploit test)
Test results
IMPL=fixed 7/7 pass; IMPL=vulnerable 4/7 fail (3 exploit: the over-size body, the over-count batch and the rotating-header burst, plus the Retry-After test because it never throttles); IMPL=wrong 1/7 fail (1 exploit: a caller rotating X-Client-Id is never throttled; the body and item caps hold).

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Top 10:2025 A06 Insecure Design
  2. OWASP API Security Top 10 2023: API4 Unrestricted Resource Consumption
  3. CWE-770: Allocation of Resources Without Limits or Throttling
  4. CWE-400: Uncontrolled Resource Consumption
  5. OWASP Denial of Service Cheat Sheet
  6. express.json options (limit)
  7. body-parser: limit option

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.