How Unbounded JSON Body Parsing Happens in Cloudflare Workers and How to Fix It
Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | Uncontrolled Resource Consumption (DoS via oversized JSON body) |
| CWE | CWE-400 |
| Language | JavaScript (Cloudflare Workers) |
| Risk | Unauthenticated attacker can exhaust worker CPU/memory |
| Root Cause | await request.json() called without a body-size guard |
| Fix | Reject requests with Content-Length > 10240 before parsing |
Summary
A critical denial-of-service vulnerability in _workers.js allowed attackers to send arbitrarily large or deeply nested JSON payloads to the /api/log-speed POST endpoint, causing the Cloudflare Worker to exhaust CPU and memory during parsing. The fix adds a Content-Length header check before calling await request.json(), rejecting payloads over 10 KB with an HTTP 413 response. This prevents resource exhaustion attacks while preserving all legitimate functionality.
Introduction
The _workers.js file serves as the main request router for a Cloudflare Worker deployment, handling everything from CORS preflight to speed-test history logging. One of its POST endpoints—/api/log-speed—accepts JSON payloads describing network speed measurements. At line 568, the handler jumped straight into parsing the request body:
const body = await request.json();
No size check. No header validation. No limit of any kind.
This single line meant that anyone who could reach the endpoint could force the worker to allocate memory and burn CPU cycles proportional to whatever they sent—whether that was a 50 MB blob of random bytes or a JSON object nested ten thousand levels deep. For developers building similar Worker-based APIs, this is an easy pattern to miss because the Fetch API's request.json() looks deceptively safe—it's a standard method, it handles errors gracefully, and it returns a clean JavaScript object. What it does not do is protect you from what's inside the request before it starts parsing.
The Vulnerability Explained
What the vulnerable code looked like
Before the fix, the /api/log-speed handler in _workers.js looked like this (starting at line 569):
// ==================== 测速历史记录 (POST) ====================
if (url.pathname === '/api/log-speed' && request.method === 'POST') {
try {
const body = await request.json(); // ← vulnerable line
const record = {
timestamp: Date.now(),
// ... rest of record construction
};
}
}
The problem is that request.json() in the Fetch API (used natively in Cloudflare Workers) will happily consume the entire request body stream before attempting to parse it. There is no built-in size cap. If the body is 100 MB, the runtime will buffer 100 MB. If the JSON is valid but nested 100,000 levels deep, the recursive parser will happily walk every level—consuming stack and heap as it goes.
How an attacker exploits this
An attacker doesn't need credentials, a session cookie, or any prior knowledge of the application. They only need the endpoint URL. A simple attack looks like this:
# Send a 50 MB payload of valid JSON
python3 -c "
import json, sys
payload = {'data': 'A' * (50 * 1024 * 1024)}
sys.stdout.write(json.dumps(payload))
" | curl -s -X POST https://your-worker.workers.dev/api/log-speed \
-H 'Content-Type: application/json' \
--data-binary @-
Or, more insidiously, a deeply nested object that is small in bytes but catastrophic to parse:
# Deeply nested JSON: small payload, huge parse cost
nested = "x"
for _ in range(100_000):
nested = f'{{"a":{nested}}}'
# Result: ~700 KB of text but requires 100,000 recursive parse steps
In Cloudflare Workers, CPU time is metered per request. A single such request can consume the worker's entire CPU budget, causing it to return a 503 to all concurrent legitimate users. Repeated at scale, this becomes a sustained denial-of-service without ever needing to send high-bandwidth traffic.
Real-world impact for this application
The /api/log-speed endpoint is designed to log speed-test results—likely from an end-user browser or mobile client. Because it's a public-facing POST endpoint (no authentication check is visible in the diff context), it is reachable by anyone. An attacker who discovers this endpoint can:
- Exhaust the worker's CPU quota, causing 503 errors for all users of the application.
- Trigger Cloudflare's resource limits, potentially resulting in the worker being suspended.
- Amplify the attack by running concurrent requests from multiple IPs, since each request independently triggers the expensive parse.
The Fix
What changed
The fix adds seven lines immediately before the await request.json() call, at line 569 of _workers.js:
Before:
if (url.pathname === '/api/log-speed' && request.method === 'POST') {
try {
const body = await request.json();
const record = {
timestamp: Date.now(),
After:
if (url.pathname === '/api/log-speed' && request.method === 'POST') {
try {
const contentLength = parseInt(request.headers.get('content-length') || '0');
if (contentLength > 10240) {
return new Response(JSON.stringify({ ok: false, error: 'payload too large' }), {
status: 413,
headers: { 'content-type': 'application/json', ...CORS_HEADERS, ...SECURITY_HEADERS }
});
}
const body = await request.json();
const record = {
timestamp: Date.now(),
Why this fix works
-
Read before consume:
request.headers.get('content-length')reads a single HTTP header—a near-zero-cost operation—before the body stream is ever touched. -
Hard numeric limit: The 10,240-byte (10 KB) threshold is generous for a speed-test log record (which typically contains a few numeric fields and a timestamp) while being tiny compared to any meaningful attack payload.
-
Early return with proper status: Returning HTTP
413 Payload Too Largeis the semantically correct response. It tells well-behaved clients they sent too much data, without burning any additional CPU on parsing. -
Consistent headers: The response spreads
CORS_HEADERSandSECURITY_HEADERS—the same header sets used elsewhere in the worker—so the error response is indistinguishable in structure from normal responses, avoiding information leakage about the internal routing logic. -
parseIntwith fallback: UsingparseInt(...|| '0')gracefully handles missing or malformedContent-Lengthheaders. A missing header evaluates to'0', which is ≤ 10240 and allows the request through—appropriate because some legitimate HTTP clients omitContent-Lengthwhen using chunked transfer encoding. For production hardening, you may also want to add a streaming byte-counter for those cases (see Best Practices below).
Prevention & Best Practices
1. Always validate Content-Length before parsing bodies
Make this a standard pattern for every POST/PUT/PATCH endpoint in your Workers:
function checkBodySize(request, maxBytes = 10240) {
const contentLength = parseInt(request.headers.get('content-length') || '0');
if (contentLength > maxBytes) {
return new Response(JSON.stringify({ ok: false, error: 'payload too large' }), {
status: 413,
headers: { 'content-type': 'application/json' }
});
}
return null; // ok to proceed
}
// Usage
const sizeError = checkBodySize(request);
if (sizeError) return sizeError;
const body = await request.json();
2. Add a streaming byte counter for chunked requests
Content-Length can be absent (chunked transfer encoding) or spoofed by a malicious client. For defense in depth, count bytes as you stream:
async function readBodyWithLimit(request, maxBytes = 10240) {
const reader = request.body.getReader();
let received = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.length;
if (received > maxBytes) {
reader.cancel();
throw new Error('payload too large');
}
chunks.push(value);
}
const combined = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return JSON.parse(new TextDecoder().decode(combined));
}
3. Validate JSON schema after parsing
Even a small, well-formed JSON payload can contain unexpected fields. Use a schema validator like zod or ajv to reject structurally invalid bodies early:
import { z } from 'zod';
const SpeedRecordSchema = z.object({
download: z.number().min(0).max(10_000),
upload: z.number().min(0).max(10_000),
ping: z.number().min(0).max(10_000),
});
const parsed = SpeedRecordSchema.safeParse(body);
if (!parsed.success) {
return new Response(JSON.stringify({ ok: false, error: 'invalid payload' }), { status: 400 });
}
4. Apply rate limiting at the Cloudflare level
Cloudflare Workers support Rate Limiting rules and the Rate Limiter API. Even with a body-size check in place, rate limiting provides a second layer of defense against high-frequency attacks.
5. Security standards
- OWASP API Security Top 10 – API4:2023: Unrestricted Resource Consumption — directly describes this class of vulnerability.
- CWE-400: Uncontrolled Resource Consumption.
- CWE-770: Allocation of Resources Without Limits or Throttling.
Key Takeaways
await request.json()in Cloudflare Workers has no built-in size limit. Any endpoint that calls it without a preceding size check is potentially vulnerable to resource exhaustion.- The
Content-Lengthheader check must come before the body is consumed, not after. Reading the header is essentially free; parsing a 50 MB JSON body is not. - A 10 KB limit is appropriate for the
/api/log-speedendpoint because a legitimate speed-test record contains only a handful of numeric fields. Matching your limit to your actual data model makes the guard both effective and non-disruptive. - HTTP 413 is the correct status code for oversized payloads—using it correctly communicates intent to clients and monitoring systems alike.
Content-Lengthalone is not sufficient for a complete defense; pairing it with a streaming byte counter handles chunked-encoding edge cases that a header-only check misses.
How Orbis AppSec Detected This
- Source: Inbound HTTP POST request body to the
/api/log-speedendpoint in_workers.js - Sink:
await request.json()at line 569, which unconditionally consumes and parses the full request body stream - Missing control: No
Content-Lengthheader validation and no maximum body size enforcement before the expensive parse operation - CWE: CWE-400 – Uncontrolled Resource Consumption
- Fix: Read
Content-Lengthbefore parsing and return HTTP 413 if it exceeds 10,240 bytes, preventing the JSON parser from ever running on oversized input
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
Unbounded request body parsing is one of those vulnerabilities that looks harmless in isolation—request.json() is a standard, well-documented API call—but becomes a serious availability risk the moment an endpoint is exposed to the public internet. In _workers.js, a single missing size check on the /api/log-speed endpoint was enough to give any unauthenticated attacker the ability to exhaust the Cloudflare Worker's CPU budget on demand.
The fix is elegant in its simplicity: seven lines that read one header and return one response. The key lesson for anyone building Worker-based APIs is to treat body parsing as a resource-consuming operation that must be gated, not a free utility call. Pair Content-Length checks with streaming byte counters, schema validation, and platform-level rate limiting, and you'll have a robust defense against this entire class of attack.
References
- CWE-400: Uncontrolled Resource Consumption
- CWE-770: Allocation of Resources Without Limits or Throttling
- OWASP API Security Top 10 – API4:2023 Unrestricted Resource Consumption
- Cloudflare Workers: Request body reading
- Cloudflare Workers: Rate Limiter API
- Semgrep rules: request body size
- fix: the /api/log-speed post endpoint calls `await r... in _workers.js