Skip to content

feat: add the request-decompress plugin - #14016

Merged
shreemaan-abhishek merged 7 commits into
apache:masterfrom
shreemaan-abhishek:feat/request-decompress
Oct 12, 2026
Merged

shreemaan-abhishek merged 7 commits into
apache:masterfrom
shreemaan-abhishek:feat/request-decompress

Conversation

@shreemaan-abhishek

Copy link
Copy Markdown
Contributor

Description

Adds a request-decompress plugin, so a client can send a request body with Content-Encoding: gzip or Content-Encoding: deflate, which RFC 9110 §8.4 allows.

Nothing on the request path inflates a request body today. The gzip plugin works on responses through header_filter, and apisix/utils/content-decode.lua is reached only from response-rewrite and log-util. So a plugin that reads the request body sees compressed bytes: oas-validator validates them against the schema and fails, request-validation rejects them, chaitin-waf inspects them, and body-transformer cannot parse them.

How

Core. core.request.get_body(max_size, ctx, opts) gains opts.decompress, off by default, so the roughly 35 existing callers keep the bytes exactly as they arrived. hmac-auth's Digest check is the one that matters most. On failure the third return value names the cause, one of unsupported_encoding, decompress_failed or too_large, so each caller picks its own status code.

  • gzip and deflate both work from a single inflate_gzip call, since both are zlib streams and ffi-zlib's default windowBits of 15+32 auto-detects the header.
  • Codings are inflated in the reverse of the order the client applied them, so a chained Content-Encoding: gzip, gzip works, and identity is a no-op.
  • max_size bounds the inflated data as well as the raw read, which is what makes a small payload that expands to a very large one safe. The cap is enforced mid-stream: a falsy return from the ffi-zlib output callback stops the stream, so an oversized body is never buffered in full.
  • get_body still returns a single value on success. Some callers splat the result straight into varargs, ngx.print(core.request.get_body()) in t/plugin/body-transformer.t among them, so a trailing nil would corrupt them.

The inflate helper moves to apisix/utils/gzip.lua, which also exposes a deflate for the plugin's re-compress path. apisix/utils/content-decode.lua now requires it instead of carrying its own copy, so the repository keeps one inflate implementation rather than two.

The plugin runs in rewrite. When the request carries a Content-Encoding it reads the body through the core path, writes the inflated bytes back with ngx.req.set_body_data, and removes the Content-Encoding header. Every later reader sees plain bytes with no change of its own. A request without a Content-Encoding returns immediately and its body is never read.

field type default meaning
max_req_body_size integer, min 1 1048576 bounds the decompressed size
forward_compressed boolean false the upstream receives a compressed body

forward_compressed restores a compressed body in before_proxy, the last hook before proxying, so the plugins on the route still read the plain body while the upstream gets compressed bytes. When the body is still byte-identical to the plaintext the plugin wrote, the bytes as they arrived are restored verbatim along with the original Content-Encoding and nothing is compressed. Only a body another plugin rewrote is really deflated, re-applying the client's codings in the order the client applied them, and the header then names the codings actually applied rather than the list as sent.

Rejections are 415 with an Accept-Encoding: gzip, deflate response header for an unsupported coding, per RFC 9110 §12.5.3, 400 for a malformed body, and 413 for a body inflating past max_req_body_size.

Priority 2850

ip-restriction holds 3000, so the plugin takes 2850. That puts it after the plugins that reject a request cheaply (ua-restriction 2999, referer-restriction 2990, csrf 2980, uri-blocker 2900), so a blocked request is never inflated, and before every plugin that reads the body: request-validation 2800, chaitin-waf 2700, the auth plugins, body-transformer 1080, proxy-rewrite 1008, oas-validator 512, degraphql 509, grpc-transcode 506. Running ahead of the WAF is deliberate, since a WAF inspecting compressed bytes can be walked past by gzipping the payload.

Two consequences are documented, and _meta.priority changes either:

  • hmac-auth (2530) with validate_request_body hashes the inflated body on a route that also enables this plugin. A client that signs the compressed bytes needs hmac-auth above 2850.
  • Decompression happens before authentication. max_req_body_size and client_max_body_size bound the work an unauthenticated client can cause.

br is deliberately left out for now, even though content-decode.lua can decode it when lua-brotli is present, so that the supported set is the same whether or not that optional dependency is installed. It can be added behind the same is_br_libs_loaded check if there is demand.

Tests

  • t/plugin/request-decompress.t, 25 blocks: schema defaults and validation; gzip, deflate, chained gzip, gzip, identity, and a request with no Content-Encoding; the rewritten Content-Length; a malformed body, an unsupported br, and an over-cap body; a Content-Encoding on a bodyless GET; stacking with oas-validator for a body that passes the spec and one that fails it; and for forward_compressed, the upstream receiving the bytes as they arrived for gzip, deflate and chained codings, validation still rejecting an invalid body, an uncompressed request passing through, and a body-transformer route exercising the real compress path, where the forwarded body differs from the original and inflates to the transformed JSON.
  • t/core/request.t gains 4 blocks: raw bytes without opts.decompress, and each of the three failure kinds.
  • t/utils/gzip.t covers the new helper, including the mid-stream max_output abort.

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible (If not, please discuss on the APISIX mailing list first)

Decompresses a gzip or deflate request body and rewrites the request with
it, so every plugin that reads the body sees the plain content.
forward_compressed keeps the upstream receiving a compressed body.

Decompression itself lives in core.request.get_body behind opts.decompress,
off by default, so callers that need the bytes as they arrived are
unaffected. The inflate helper moves to apisix/utils/gzip.lua, which
content-decode.lua now shares instead of carrying its own copy.
A plugin that moves the body to a temp file leaves ngx.req.get_body_data()
nil, and the upstream then received a compressed empty body.
inflate_gzip consumed only the first member of a concatenated gzip
stream, so a multi-member body was silently truncated. It now inflates
every member, bounds their total by one cumulative counter, and rejects
bytes trailing the last member.

The plugin on both a global rule and a route compressed the body twice.
run_policy prefer_route hands the request to the route's instance, and
the forwarding state is consumed on first use.

A body over max_req_body_size answered 500. read_body now classifies a
size violation as too_large and get_body passes the kind on, so the
Content-Length, chunked and file-backed paths all answer 413.
core.request.header collapses a repeated header to its first value, so a
request with two Content-Encoding lines had a twice compressed body
restored under a single coding. The header is now rebuilt from the parsed
coding list, which makes the stored header value redundant.

An unreadable body file left before_proxy compressing an empty string and
forwarding it. The read failure is now an error.
CI runs as root, which reads straight through a file whose permissions
were dropped, so the read in before_proxy succeeded there and the block
saw a 200. Removing the file fails the read for every uid.
Content-Encoding parsing and inflation lived in core.request behind
opts.decompress, with the plugin as the only caller, so both move into
the plugin and core.request.get_body keeps its old signature.

What stays in core is the size classification: the Content-Length and
body-file checks run inside get_body, so only it can tell a body over
the cap from a body that could not be read, and the plugin answers 413
or 500 on that. No new API, one extra return value on the failure path.

Signed-off-by: Shreemaan Abhishek <shreemaan.abhishek@gmail.com>

-- nothing rewrote the body, so the bytes as they arrived still apply
if current == state.plain then
ngx_req.set_body_data(state.original)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep the plaintext body available to loggers

With forward_compressed=true, before_proxy restores the compressed body, but http-logger reads it later in the log phase. Consequently, include_req_body and custom $request_body fields contain compressed bytes, contrary to the documented plaintext behavior. Please preserve the final plaintext for log-util and add a regression asserting both compressed upstream data and plaintext logs.

@shreemaan-abhishek shreemaan-abhishek Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the docs in 776f34e, keeping the behaviour.

The body must be compressed before the request is proxied and the log phase runs after that, so a logger reads what actually went upstream. The docs now say so instead of promising the plain body to every plugin.

This also already happens on master without the plugin: a gzip request body on a route with include_req_body logs gzip bytes, since log-util.get_request_body never inflates. The fix belongs there, symmetric with the response body it already decodes at lines 431-446, which would cover that case too.

forward_compressed restores the compressed body before the request is
proxied, so the log phase reads it. Two sentences claimed the plain body
for every plugin on the route without that qualification.

Signed-off-by: Shreemaan Abhishek <shreemaan.abhishek@gmail.com>

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@shreemaan-abhishek
shreemaan-abhishek merged commit 3551401 into apache:master Oct 12, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants