From 847c4b39ce676e7314646d449aca9d35f17bfe63 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Thu, 1 Oct 2026 21:17:43 +0400 Subject: [PATCH] fix(script): decode a compressed http() response body --- docs/configuration.md | 3 +++ internal/sarin/scripthttp.go | 52 +++++++++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6b3e499..916e025 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -181,6 +181,8 @@ A server can declare a `Content-Length` far larger than it sends, and the body i The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. It covers the requests scripts make as well. +A compressed response in a script is decoded first, so the limit covers the decoded size as well as the bytes that arrived. Decoding holds both at once, so a brotli or zstd body peaks at over ten times the limit. Ratios are unbounded, and 106 bytes of brotli can hold 64MiB, so with `0` a tiny download can still exhaust memory. + ## Concurrency Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`. @@ -622,6 +624,7 @@ In Lua, call the helpers as `res:header("name")` or `res.header("name")`. - **Status codes:** non-2xx responses are returned normally; check `status` in the script. - **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript). - **Response bodies:** the [Max Response Body](#max-response-body) config bounds these requests too, and a body above it raises an error the script can catch. +- **Compressed responses:** sarin asks for no encoding, so this only applies when a script sends `Accept-Encoding` itself or the server compresses unasked. A `gzip`, `deflate`, `br` or `zstd` body is then decoded before the script sees it, and `Content-Encoding` and `Content-Length` are both dropped from `headers` because neither describes what the script receives. Anything else is left raw with its header intact: an empty body, `identity`, a `Content-Encoding` sarin does not recognise, and two or more codings at once. A body that fails to decode raises an error the script can catch, though `br` carries no checksum, so damage to a brotli body can go unnoticed. - **Only inside `transform`:** `http` works anywhere while `transform` runs, including in helper functions it calls. Calling it at the top level of a script raises an error, because that code also runs during config validation and when each worker starts. - **Dry run:** script requests are still sent in dry-run mode. - **Not measured:** script requests are not included in the results. diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 614c822..c7f791f 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -1,6 +1,7 @@ package sarin import ( + "compress/zlib" "context" "crypto/tls" "errors" @@ -18,10 +19,11 @@ const scriptHTTPDefaultTimeout = 30 * time.Second // scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use. type scriptHTTPClient struct { - ctx context.Context //nolint:containedctx - client *fasthttp.Client - insecureClient *fasthttp.Client - defaultTimeout time.Duration + ctx context.Context //nolint:containedctx + client *fasthttp.Client + insecureClient *fasthttp.Client + defaultTimeout time.Duration + maxResponseBody int } var _ script.HTTPDoer = (*scriptHTTPClient)(nil) @@ -71,10 +73,11 @@ func newScriptHTTPClient( } return &scriptHTTPClient{ - ctx: ctx, - client: newClient(false), - insecureClient: newClient(true), - defaultTimeout: defaultTimeout, + ctx: ctx, + client: newClient(false), + insecureClient: newClient(true), + defaultTimeout: defaultTimeout, + maxResponseBody: safeUint64ToInt(maxResponseBody), } } @@ -166,7 +169,10 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) } - body := resp.Body() + body, err := decodeResponseBody(resp, c.maxResponseBody) + if err != nil { + return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) + } return &script.HTTPResponse{ Status: resp.StatusCode(), @@ -174,3 +180,31 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro Body: string(body), }, nil } + +// decodeResponseBody decodes a body the server compressed, since scripts read it as text. +// The limit covers the decoded size, which is what a compression bomb inflates to. +// An empty body is left alone because the decoders reject zero bytes, and a HEAD or a 304 +// carries the encoding with no body. A coding fasthttp does not decode, and the bare +// deflate stream some servers send without a zlib header, are left raw for the script. +func decodeResponseBody(resp *fasthttp.Response, maxResponseBody int) ([]byte, error) { + if len(resp.Header.ContentEncoding()) == 0 || len(resp.Body()) == 0 { + return resp.Body(), nil + } + + decoded, err := resp.BodyUncompressedWithLimit(maxResponseBody) + if errors.Is(err, fasthttp.ErrContentEncodingUnsupported) || errors.Is(err, zlib.ErrHeader) { + return resp.Body(), nil + } + if err != nil { + if errors.Is(err, fasthttp.ErrBodyTooLarge) { + err = types.ErrResponseBodyTooLarge + } + return nil, err + } + + // Neither header describes the decoded body, so both go, as net/http does on a gunzip. + resp.Header.SetContentEncoding("") + resp.Header.Del(fasthttp.HeaderContentLength) + + return decoded, nil +}