Track 2 / Guide 09
Preventing JSON Injection and Denial of Service
Defend JSON endpoints against unsafe object merging, injection at downstream boundaries, decompression expansion, and parser resource exhaustion.
On this page
Locate the interpretation boundary
JSON injection is not one single parser vulnerability. A value can be valid JSON and become dangerous later when it is concatenated into SQL, interpreted as a query operator, embedded in HTML, or merged into privileged configuration. Map each point where data becomes instructions or affects control state. Defenses must match that destination rather than rely on a generic sanitization function.
Construct JSON with serializers and parse it with a JSON parser. Never evaluate JSON-looking text as JavaScript. After parsing, select allowed fields and validate their types and bounds. Then enforce authorization and use destination-specific safe APIs such as parameterized database statements or text-node rendering. These are distinct controls with distinct failure modes.
Keep the trust model explicit for internal services too. A queue producer can be compromised or buggy, and a signed message may still contain an oversized or invalid application object. The JWT guide illustrates the same separation: cryptographic authenticity does not establish that all claims are appropriate for every operation. Treat trusted transport as one property of a message, not permission to skip all resource and contract checks.
Prototype pollution happens through object handling
In JavaScript, parsing an object containing a special property name does not by itself prove that a prototype has been polluted. The dangerous step is often a later merge or assignment into another object, especially when a generic recursive helper follows attacker-controlled property paths. Review that transformation rather than blaming JSON syntax alone.
Prefer explicit field projection into a known structure. For dictionary-like data, a map or null-prototype object can reduce inherited-property surprises, but it does not make every merge algorithm safe. Check ownership explicitly when reading properties from untrusted objects. Do not use the presence of a truthy inherited property as evidence of a validated field.
Block dangerous names where they are outside the contract, while recognizing that a denylist is weaker than an allowlist of expected fields. Nested constructor and prototype paths can matter in vulnerable algorithms, so filtering one spelling at the root is insufficient. Review dependency behavior and add regression tests for the actual merge path. A library upgrade that changes object assignment semantics can affect security even if every sample payload remains syntactically valid.
JSON has resource bombs, not XML entity declarations
The classic XML billion-laughs attack relies on entity expansion. Standard JSON has no equivalent entity declaration mechanism, so calling every nested JSON payload the same attack obscures the mechanism. JSON can still exhaust resources through extreme depth, huge strings, many small members, repeated allocations, or expensive downstream validation. Describe the actual cost being amplified.
Compression adds a separate expansion layer. A small compressed request can become a large decoded body before parsing. Limit compressed bytes where appropriate and decoded bytes before materializing the full document. If the application does not need compressed request bodies, rejecting them is simpler than supporting them with incomplete limits. Do not assume the Content-Length header describes decompressed memory use.
Depth checks performed after parsing protect later traversals but cannot undo the parser's initial allocation and CPU cost. Use conservative byte caps and a streaming or depth-aware parser when the threat model requires earlier structural limits. Also bound schema complexity, output size, and queued work. The schema guide explains why a validator is another consumer of attacker-controlled structure rather than a cost-free protective wrapper.
A bounded ingress example with explicit limitations
The local server below accepts a small JSON object, rejects compressed bodies, counts actual received bytes, and copies only a known display-name field. It uses a read timeout and returns concise errors. Save it as ingress.cjs and run it with Node.js. The example intentionally rejects unknown properties, preventing a generic merge from becoming part of the request path.
The cap bounds the complete buffered input but is not a streaming structural parser. JSON.parse remains synchronous after the body is collected. For endpoints requiring larger inputs, move to a parser and workload design that can enforce structural budgets appropriately. Merely raising the constant in this example does not make it suitable for arbitrarily large imports.
Production deployments also need connection limits, aggregate admission control, and a reverse-proxy policy aligned with the application. Per-request limits do not prevent many concurrent requests from exhausting memory together. The example demonstrates one layer that can be tested locally, not a complete denial-of-service defense. Keep authentication and tenant authorization outside the projection function and perform them before any protected state change.
const http = require("node:http");
const MAX_BYTES = 16 * 1024;
const server = http.createServer(async (req, res) => {
const reply = (status, error) => {
res.writeHead(status, { "Content-Type": "application/json",
"Connection": "close" });
res.end(JSON.stringify({ error }));
};
if (req.method !== "POST") return reply(405, "method_not_allowed");
if (req.headers["content-encoding"] &&
req.headers["content-encoding"] !== "identity") {
return reply(415, "unsupported_encoding");
}
if ((req.headers["content-type"] || "").split(";")[0].trim()
!== "application/json") return reply(415, "expected_json");
let size = 0;
const chunks = [];
try {
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BYTES) {
reply(413, "body_too_large");
return;
}
chunks.push(chunk);
}
const text = new TextDecoder("utf-8", { fatal: true })
.decode(Buffer.concat(chunks));
const input = JSON.parse(text);
if (!input || Array.isArray(input) || typeof input !== "object" ||
Object.keys(input).some(k => k !== "displayName") ||
typeof input.displayName !== "string" ||
input.displayName.length > 100) {
return reply(400, "invalid_profile");
}
const profile = { displayName: input.displayName };
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(profile));
} catch {
if (!res.headersSent) reply(400, "invalid_json");
}
});
server.requestTimeout = 5000;
server.headersTimeout = 5000;
server.listen(8081, "127.0.0.1");Rate limiting and admission control solve different problems
A rate limit constrains activity over time, while admission control constrains work currently in flight. Both matter. A client sending within its per-minute allowance can still create a burst of large simultaneous requests. Bound concurrent parsing or import jobs and the length of the waiting queue. Reject excess work predictably rather than retaining unbounded bodies while waiting for capacity.
Use multiple dimensions where justified: account, tenant, route, and a network-level fallback for unauthenticated traffic. Avoid relying exclusively on source addresses behind shared networks or trusting an arbitrary forwarded-address header. Establish which proxy is authorized to supply client identity. Keep rate-limit storage and failure behavior consistent with the service's availability goals.
Charge expensive operations according to their actual resource profile when a simple request count is inadequate. A small lookup and a large import should not necessarily consume identical budgets. Still enforce hard per-request limits because cost estimation can be wrong. Observe rejected work and active memory alongside successful latency. A system that remains responsive by rejecting overload is behaving differently from one that silently queues until every caller times out.
Protect downstream interpreters and output channels
Database safety requires parameter binding for values and an allowlist for dynamic identifiers or operators. An object accepted as a filter should not be passed directly into an unrestricted database query API if clients are only meant to filter a few fields. Validate operator structure and attach tenant restrictions independently so the request cannot override them.
HTML output should use text rendering for untrusted values unless rich content is intentionally supported through a reviewed sanitizer. JSON string escaping alone does not protect an HTML script boundary or attribute context. Shell commands should avoid string concatenation and use APIs that separate executable arguments, with strict control over the executable and permitted options.
Logs are another interpreter boundary. Bound and escape previews, avoid control characters that create misleading records, and exclude secrets. A malformed payload should not cause a diagnostic response containing the full request or stack. Keep error categories stable enough to investigate attacks without collecting attack bodies by default. Store raw evidence only under a deliberate incident process with access and retention controls.
Test defenses with bounded adversarial fixtures
Security regression tests should target the actual application path: parsing, validation, projection, and persistence. Include unexpected property names, nested objects where strings are expected, arrays at the root, invalid UTF-8, and a body just above the configured cap. Verify that no protected action occurs after rejection. Do not generate uncontrolled resource-exhaustion payloads on a shared production system.
For depth and size testing, use a dedicated environment and gradually increase within a known memory budget. Measure controlled rejection and event-loop responsiveness rather than trying to crash the process. The Node memory guide explains how synchronous parsing affects unrelated requests and why worker queues must also be bounded.
Test dependency upgrades with the same corpus. A parser, validation library, or merge helper can change behavior at edge cases. Verify error handling too: an exception should become a bounded failure, not an authentication bypass or process restart loop. Keep the fixture corpus synthetic and small enough to run regularly in continuous integration, reserving heavy load experiments for an isolated performance environment.
Operate the boundary with useful telemetry
Track accepted and rejected bytes, parse failures, validation failures, queue depth, active requests, and event-loop delay. Use bounded labels such as route and rejection category. Attacker-controlled keys or error strings must not become high-cardinality metric labels, which can create a separate monitoring cost problem.
Correlate changes with deployments and dependency versions. An increase in malformed input may come from a broken client release rather than hostile traffic. Preserve enough context to distinguish the two without recording every payload. A request identifier and client-version signal can be more useful than a large unstructured error log.
Review limits as product behavior changes. A previously safe cap may become too permissive when concurrency grows, while a legitimate new workflow may need a separate asynchronous import route instead of a larger synchronous endpoint. Document the reason for each limit and the expected client response. The goal is a system that rejects unsupported work clearly, keeps resources bounded, and never confuses successful JSON parsing with permission to interpret data as instructions.
Constrain validation and response amplification
Input limits should account for work after parsing. A small document can request a large database result, trigger many nested resolver calls, or produce an enormous validation report. Bound page sizes, operation counts, and diagnostic output separately. Otherwise the input parser becomes a narrow gate in front of an unbounded execution path.
Review regular expressions used in validation and filtering. Some patterns can take disproportionate time on particular near-matching strings. Prefer simple bounded expressions and established validators, and test deliberately failing inputs in an isolated environment. A regex length limit alone does not describe its runtime behavior.
Cap the number of reported errors and the length of each excerpt. A user-controlled key can be very long, so even a field-path response needs a size policy. Use stable error codes and omit raw values. This keeps diagnostics useful while preventing the error path from becoming an amplification mechanism.
Ensure every rejection path releases queued work and buffers. Resource safety is not only about rejecting a request; it is also about what remains allocated after rejection. Repeated malformed requests can expose leaks that ordinary successful traffic does not. Monitor memory after a sustained bounded invalid-input test, not just during one request.
Coordinate proxy and application enforcement
Write down which layer terminates TLS, decompresses bodies, enforces timeouts, and counts bytes. Conflicting assumptions can leave a gap where each layer believes another has applied a limit. For example, a gateway may cap compressed input while the application assumes that cap describes decoded size. Verify the real data path with a controlled fixture.
Header-based size checks are useful early hints but should not replace counting actual data. Transfer framing and intermediaries can affect how a request arrives. Keep the application limit independent enough to protect its own allocation budget, while letting the proxy reject obviously excessive traffic before it reaches the worker.
Slow clients require timeout and connection policies as well as byte limits. A client can occupy resources while sending very little data. Set header and body timing behavior according to legitimate network conditions, and test through the deployed proxy so its buffering does not conceal the application's exposure.
When limits differ across routes, make that configuration visible and reviewed. A bulk import endpoint may have a larger budget but should use a separate queue and processing model. Do not raise a shared global cap and inadvertently expand every interactive endpoint's exposure.
Finally, define overload responses that clients can handle predictably. Reject before allocating expensive work whenever possible, record bounded metrics, and avoid repeated restarts as a normal control strategy. The system should remain observable and responsive while refusing work outside its supported envelope.
Engineering Comparison
| Threat | Mechanism | Primary control | Insufficient by itself |
|---|---|---|---|
| Prototype pollution | Unsafe merge or property traversal | Explicit projection and safe structures | JSON.parse success |
| SQL or query injection | Data interpreted as query structure | Parameters and operator allowlist | String escaping |
| Nesting exhaustion | Expensive parse or traversal | Depth-aware design and byte caps | Post-parse schema alone |
| Compression expansion | Small body expands greatly | Decoded-byte cap | Content-Length alone |
| Concurrent overload | Many bounded requests | Admission and queue limits | Per-request cap |
| Sensitive log leakage | Raw diagnostics persisted | Redaction and bounded categories | Authentication |
Standard JSON has no XML entity expansion. Match each control to the actual amplification or interpretation mechanism and test rejection in an isolated environment.