fix(script): decode a compressed http() response body

This commit is contained in:
aykhans committed 2026-10-01 21:17:43 +04:00
1 parent 9e773a4e6c
commit 847c4b39ce
2 files changed
+46 -9

No files matched your search

+3
View File
@@ -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.
+43 -9
View File
@@ -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
}