Repository navigation
feat: add the request-decompress plugin - #14016
shreemaan-abhishek merged 7 commits into
Conversation
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) |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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>
Description
Adds a
request-decompressplugin, so a client can send a request body withContent-Encoding: gziporContent-Encoding: deflate, which RFC 9110 §8.4 allows.Nothing on the request path inflates a request body today. The
gzipplugin works on responses throughheader_filter, andapisix/utils/content-decode.luais reached only fromresponse-rewriteandlog-util. So a plugin that reads the request body sees compressed bytes:oas-validatorvalidates them against the schema and fails,request-validationrejects them,chaitin-wafinspects them, andbody-transformercannot parse them.How
Core.
core.request.get_body(max_size, ctx, opts)gainsopts.decompress, off by default, so the roughly 35 existing callers keep the bytes exactly as they arrived.hmac-auth'sDigestcheck is the one that matters most. On failure the third return value names the cause, one ofunsupported_encoding,decompress_failedortoo_large, so each caller picks its own status code.gzipanddeflateboth work from a singleinflate_gzipcall, since both are zlib streams and ffi-zlib's default windowBits of 15+32 auto-detects the header.Content-Encoding: gzip, gzipworks, andidentityis a no-op.max_sizebounds 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_bodystill returns a single value on success. Some callers splat the result straight into varargs,ngx.print(core.request.get_body())int/plugin/body-transformer.tamong them, so a trailingnilwould 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.luanow 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 aContent-Encodingit reads the body through the core path, writes the inflated bytes back withngx.req.set_body_data, and removes theContent-Encodingheader. Every later reader sees plain bytes with no change of its own. A request without aContent-Encodingreturns immediately and its body is never read.max_req_body_sizeforward_compressedfalseforward_compressedrestores a compressed body inbefore_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 originalContent-Encodingand 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
415with anAccept-Encoding: gzip, deflateresponse header for an unsupported coding, per RFC 9110 §12.5.3,400for a malformed body, and413for a body inflating pastmax_req_body_size.Priority 2850
ip-restrictionholds 3000, so the plugin takes 2850. That puts it after the plugins that reject a request cheaply (ua-restriction2999,referer-restriction2990,csrf2980,uri-blocker2900), so a blocked request is never inflated, and before every plugin that reads the body:request-validation2800,chaitin-waf2700, the auth plugins,body-transformer1080,proxy-rewrite1008,oas-validator512,degraphql509,grpc-transcode506. 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.prioritychanges either:hmac-auth(2530) withvalidate_request_bodyhashes the inflated body on a route that also enables this plugin. A client that signs the compressed bytes needshmac-authabove 2850.max_req_body_sizeandclient_max_body_sizebound the work an unauthenticated client can cause.bris deliberately left out for now, even thoughcontent-decode.luacan decode it whenlua-brotliis present, so that the supported set is the same whether or not that optional dependency is installed. It can be added behind the sameis_br_libs_loadedcheck if there is demand.Tests
t/plugin/request-decompress.t, 25 blocks: schema defaults and validation; gzip, deflate, chainedgzip, gzip,identity, and a request with noContent-Encoding; the rewrittenContent-Length; a malformed body, an unsupportedbr, and an over-cap body; aContent-Encodingon a bodyless GET; stacking withoas-validatorfor a body that passes the spec and one that fails it; and forforward_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 abody-transformerroute exercising the real compress path, where the forwarded body differs from the original and inflates to the transformed JSON.t/core/request.tgains 4 blocks: raw bytes withoutopts.decompress, and each of the three failure kinds.t/utils/gzip.tcovers the new helper, including the mid-streammax_outputabort.Checklist