Rejecting Oversized and Malformed Payloads

The controls in this page are the cheapest security and stability work available at a gateway, and the ones most often left at their defaults. A body size limit, a header size limit, a parse depth limit and a timeout on reading the request together close a family of failures that range from a memory spike to a node falling over — and none of them require knowing anything about the application.

Prerequisite concepts

This sits under request validation and schema enforcement and applies the memory arithmetic from scaling limits and capacity planning.

Reject on the header, not on the byte count

Two ways to reject the same request, with very different costs Reading the declared content length and rejecting immediately costs one round trip and no memory. Reading until an internal limit trips means the bytes have already crossed the network and been buffered, so an attacker can make the gateway do the work regardless of the eventual rejection. reject from content-length headers arrive 413 immediately no body read, no memory, one round trip reject after buffering headers arrive read 50 MB, then 413 bandwidth and memory already spent A chunked request has no declared length, so both limits are needed: the header check and the streaming ceiling.
# Kong 3.x / NGINX — the four limits, all of them
client_max_body_size        1m;      # rejects from content-length with 413
client_body_buffer_size     16k;     # above this, bodies spill to disk
client_header_buffer_size   4k;
large_client_header_buffers 4 8k;    # total header budget per request
client_body_timeout         10s;     # slow-body attacks end here
client_header_timeout       10s;
# Envoy 1.32+ — equivalents, per listener and per route
common_http_protocol_options:
  max_headers_count: 100
  headers_with_underscores_action: REJECT_REQUEST
max_request_headers_kb: 60
request_timeout: 15s                  # header-to-last-byte ceiling
# and per route, where the body is buffered for validation:
typed_per_filter_config:
  envoy.filters.http.buffer:
    "@type": type.googleapis.com/envoy.extensions.filters.http.buffer.v3.BufferPerRoute
    buffer: { max_request_bytes: 1048576 }

request_timeout is the one most often missing. Without it, a client that sends headers and then dribbles a body one byte at a time holds a connection indefinitely — the classic slow-body attack, which needs no bandwidth and defeats a size limit entirely because the body never gets large.

Malformed is not the same as oversized

Four small payloads that cost more than a large one Deeply nested JSON exhausts a recursive parser's stack. A compressed archive that expands enormously exhausts memory after decompression. Duplicate keys produce implementation-defined behaviour that differs between the gateway and the service. Numbers outside the representable range change value silently between parsers. shape what it costs control 10,000 nested arrays, 2 KB parser stack exhaustion max parse depth gzip bomb, 40 KB on the wire gigabytes after inflate decompressed size cap duplicate JSON keys gateway and service disagree reject duplicates integer beyond 2^53 value changes silently bound it in the schema

The duplicate-key case is the subtle one and worth dwelling on. If the gateway’s parser keeps the last occurrence and the service’s keeps the first, a request can pass validation with one value and be processed with another — a validation bypass that requires no cleverness beyond sending the same key twice.

Compression, and the limit that must be applied after it

A request with content-encoding: gzip is small on the wire and arbitrarily large after inflation. A size limit applied to the compressed bytes therefore protects nothing. Either apply a limit to the decompressed size, or refuse compressed request bodies on routes that do not need them — which is most of them.

The limit has to apply after decompression Forty kilobytes on the wire passes a one megabyte body limit comfortably. After inflation the same request becomes four gigabytes in memory. Bounding the expansion ratio rejects it at the point where the size becomes knowable, which is during decompression rather than on arrival. the same request, measured twice on the wire, gzipped 40 KB — passes a 1 MB limit without comment after inflation 4 GB — the process is already gone with a 100× ratio cap rejected at 4 MB of output, mid-inflation Most API routes have no reason to accept a compressed request body at all — refusing them is simpler than bounding them. Where uploads genuinely need compression, cap the ratio and the absolute output size together.
# Envoy 1.32+ — bound the result of decompression, not the input
- name: envoy.filters.http.decompressor
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.filters.http.decompressor.v3.Decompressor
    decompressor_library:
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.compression.gzip.decompressor.v3.Gzip
        max_inflate_ratio: 100          # refuse anything that expands more than 100x
        window_bits: 15

Decision matrix

Route type Body limit Notes
JSON API, ordinary 256 KB – 1 MB generous for real payloads, useless for abuse
Bulk import 10 MB+, streaming do not buffer; validate at the service
File upload multipart, streamed to storage the gateway should not hold it
Webhook receiver 64 KB senders are known and small
GraphQL 64 KB a large query document is not legitimate

Gotchas and failure signals

A limit that is too low is a support ticket; a limit that is absent is an outage. Start generous relative to real traffic — measure the 99.9th percentile body size and multiply by two — rather than guessing small.

413 must be returned before the body is read, or the client sees a connection reset instead and retries. A reset also loses the opportunity to tell the caller what the limit is.

Header limits apply to the total, not per header. A request with two thousand small cookies can exceed the budget without any single header being large.

Different limits at different hops produce a request rejected by an inner component with a status the outer one turns into a 502. Make the gateway’s limit the smallest in the path so the rejection is the one you designed.

Validation

  • Body limit set per route, derived from measured payload sizes
  • Header count and total header size limits set explicitly
  • Request timeout set, and a slow-body client tested against it
  • Compressed bodies either refused or bounded by inflated size
  • Parse depth limited, and a deeply nested payload rejected in a test
  • Duplicate-key handling verified to match between gateway and service
  • Gateway limits are the smallest in the path, so 413 comes from where you expect

FAQ

Where should the size limit be enforced?

On the declared content length, before any body is read, so an oversized request costs one round trip and no memory. A limit that only trips after buffering means the bytes have already crossed the network and been held. Chunked requests declare no length, so a streaming ceiling is needed as well — both limits, not either.

What stops a slow-body attack?

A request timeout covering headers through last byte. A client that sends headers and then dribbles the body one byte at a time never triggers a size limit, because the body never becomes large; it simply holds a connection until something times out. Without that timeout there is nothing to time out, and a few thousand such clients exhaust the connection budget with negligible bandwidth.

Why does a 40 KB request sometimes use gigabytes of memory?

Compression. A body sent with content-encoding gzip is small on the wire and arbitrarily large after inflation, so a limit applied to the compressed bytes protects nothing. Either refuse compressed request bodies on routes that do not need them, or bound the inflated size with a maximum expansion ratio.

Why do duplicate JSON keys matter?

Because the behaviour is implementation-defined and the gateway and the service may not agree. If one parser keeps the first occurrence and the other keeps the last, a request can pass validation with one value and be processed with another — a validation bypass that needs nothing more sophisticated than sending the same key twice. Reject duplicates outright.


Parent: Request Validation & Schema Enforcement