fix: cap body limits at 1GiB and key script clients by the exact size

This commit is contained in:
aykhans committed 2026-09-29 00:55:04 +04:00
1 parent 1956eb1c44
commit ef5907dc0c
5 files changed
+44 -35

No files matched your search

+4 -4
View File
@@ -173,13 +173,13 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
Largest response body sarin reads. Defaults to `10MiB`, and `0` removes the limit. Largest response body sarin reads. Defaults to `10MiB`, and `0` removes the limit.
The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1TiB`, or `2147483647` on a 32-bit platform. The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1GiB`, because the limit is also the most memory one response may take.
**Examples:** `1048576`, `10MiB`, `2MB`, `512KiB` **Examples:** `1048576`, `10MiB`, `2MB`, `512KiB`
A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `body size exceeds the given limit` and is counted like any other error. A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `body size exceeds the given limit` and is counted like any other error.
The limit applies per response in flight, so the worst case is roughly the limit times the concurrency. Requests made by scripts have their own limit, see [HTTP Requests in Scripts](#http-requests-in-scripts). 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. Requests made by scripts have their own limit, see [HTTP Requests in Scripts](#http-requests-in-scripts).
## Concurrency ## Concurrency
@@ -596,7 +596,7 @@ http(url, opts)
| `timeout` | duration string (`"500ms"`) | `30s` | Request timeout (the `timeout` config does not apply), applied to each hop when redirects are followed | | `timeout` | duration string (`"500ms"`) | `30s` | Request timeout (the `timeout` config does not apply), applied to each hop when redirects are followed |
| `insecure` | boolean | `false` | Skip TLS verification (the `insecure` config does not apply) | | `insecure` | boolean | `false` | Skip TLS verification (the `insecure` config does not apply) |
| `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none, maximum 100 | | `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none, maximum 100 |
| `maxBodySize` | number or size (`"10MiB"`) | `10MiB` | Largest response body to accept (the max response body config does not apply), maximum `1TiB` | | `maxBodySize` | number or size (`"10MiB"`) | `10MiB` | Largest response body to accept, `0` accepts any size, maximum `1GiB` |
Unknown options and values of the wrong type raise an error. Unknown options and values of the wrong type raise an error.
@@ -619,7 +619,7 @@ In Lua, call the helpers as `res:header("name")` or `res.header("name")`.
- **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to. - **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to.
- **Status codes:** non-2xx responses are returned normally; check `status` in the script. - **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). - **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:** script requests carry their own limit, like their own timeout, so the max response body config does not apply to them. A body above `maxBodySize` raises an error instead of being truncated, and a body whose declared `Content-Length` is already above it is refused before it is read. - **Response bodies:** script requests carry their own limit, like their own timeout, so the max response body config does not apply to them. A body above `maxBodySize` raises an error instead of being truncated, and a body whose declared `Content-Length` is already above it is refused before it is read. The limit is also the most memory one response may take, so `0` gives a server as much memory as it asks for.
- **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. - **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. - **Dry run:** script requests are still sent in dry-run mode.
- **Not measured:** script requests are not included in the results. - **Not measured:** script requests are not included in the results.
+1 -1
View File
@@ -521,7 +521,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` | | `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` | | `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
| `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` | | `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` |
| `fakeit_Quote` | Random quote | `"Energy is contagious—so is fear"` | | `fakeit_Quote` | Random quote | `"Energy is contagious so is fear"` |
| `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` | | `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` |
| `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` | | `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` |
| `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` | | `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` |
+26 -20
View File
@@ -7,6 +7,7 @@ import (
"net" "net"
"net/url" "net/url"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
@@ -21,6 +22,9 @@ const scriptHTTPDefaultTimeout = 30 * time.Second
// independent of the max response body config. // independent of the max response body config.
const scriptHTTPDefaultMaxBodySize = 10 << 20 // 10 MiB const scriptHTTPDefaultMaxBodySize = 10 << 20 // 10 MiB
// scriptHTTPMaxClients bounds the client cache, since each one holds a connection pool.
const scriptHTTPMaxClients = 16
// scriptHTTPClientKey picks the client a request needs, since fasthttp takes the body // scriptHTTPClientKey picks the client a request needs, since fasthttp takes the body
// limit per client rather than per request. // limit per client rather than per request.
type scriptHTTPClientKey struct { type scriptHTTPClientKey struct {
@@ -33,6 +37,7 @@ type scriptHTTPClient struct {
ctx context.Context //nolint:containedctx ctx context.Context //nolint:containedctx
newClient func(key scriptHTTPClientKey) *fasthttp.Client newClient func(key scriptHTTPClientKey) *fasthttp.Client
clients sync.Map clients sync.Map
clientCount atomic.Int64
defaultTimeout time.Duration defaultTimeout time.Duration
} }
@@ -79,18 +84,13 @@ func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout,
} }
} }
// scriptHTTPBodyCap rounds a limit up to a power of two, so a script that varies its // scriptHTTPBodyLimit resolves the limit a request is read with, where a request
// limit cannot mint a client, and a connection pool with it, per request. // without one takes the default and zero means no limit.
func scriptHTTPBodyCap(maxBodySize int64) int { func scriptHTTPBodyLimit(maxBodySize *int64) int {
if maxBodySize <= 0 { if maxBodySize == nil {
return scriptHTTPDefaultMaxBodySize return scriptHTTPDefaultMaxBodySize
} }
return safeInt64ToInt(*maxBodySize)
size := int64(1)
for size < maxBodySize {
size <<= 1
}
return safeInt64ToInt(size)
} }
// dialWithDeadline dials within the request timeout and keeps it as the connection deadline, // dialWithDeadline dials within the request timeout and keeps it as the connection deadline,
@@ -163,7 +163,8 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro
timeout = c.defaultTimeout timeout = c.defaultTimeout
} }
client := c.clientFor(r.Insecure, r.MaxBodySize) limit := scriptHTTPBodyLimit(r.MaxBodySize)
client := c.clientFor(r.Insecure, limit)
req.SetTimeout(timeout) req.SetTimeout(timeout)
if r.MaxRedirects > 0 { if r.MaxRedirects > 0 {
@@ -178,11 +179,7 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
} }
// The client caps the body at the rounded up limit, so the exact one is checked here.
body := resp.Body() body := resp.Body()
if r.MaxBodySize > 0 && int64(len(body)) > r.MaxBodySize {
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, types.ErrScriptHTTPBodyTooLarge)
}
return &script.HTTPResponse{ return &script.HTTPResponse{
Status: resp.StatusCode(), Status: resp.StatusCode(),
@@ -191,13 +188,22 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro
}, nil }, nil
} }
// clientFor returns the client whose body limit covers maxBodySize, building it once. // clientFor returns the client enforcing maxBodySize, building it once.
func (c *scriptHTTPClient) clientFor(insecure bool, maxBodySize int64) *fasthttp.Client { func (c *scriptHTTPClient) clientFor(insecure bool, maxBodySize int) *fasthttp.Client {
key := scriptHTTPClientKey{insecure: insecure, maxBodySize: scriptHTTPBodyCap(maxBodySize)} key := scriptHTTPClientKey{insecure: insecure, maxBodySize: maxBodySize}
if client, ok := c.clients.Load(key); ok { if client, ok := c.clients.Load(key); ok {
return client.(*fasthttp.Client) //nolint:forcetypeassert return client.(*fasthttp.Client) //nolint:forcetypeassert
} }
client, _ := c.clients.LoadOrStore(key, c.newClient(key)) client := c.newClient(key)
return client.(*fasthttp.Client) //nolint:forcetypeassert // A script that varies its limit would otherwise grow the cache without end.
if c.clientCount.Load() >= scriptHTTPMaxClients {
return client
}
stored, loaded := c.clients.LoadOrStore(key, client)
if !loaded {
c.clientCount.Add(1)
}
return stored.(*fasthttp.Client) //nolint:forcetypeassert
} }
+10 -8
View File
@@ -21,7 +21,7 @@ type HTTPRequest struct {
Timeout time.Duration // zero means the doer's default timeout Timeout time.Duration // zero means the doer's default timeout
Insecure bool Insecure bool
MaxRedirects int MaxRedirects int
MaxBodySize int64 // zero means no limit MaxBodySize *int64 // nil takes the doer default, zero means no limit
} }
// HTTPResponse is the response returned to a script by the http.* functions. // HTTPResponse is the response returned to a script by the http.* functions.
@@ -126,9 +126,9 @@ func parseHTTPMaxRedirects(count float64) (int, error) {
// parseHTTPMaxBodySize checks that the body limit is a non-negative whole number. // parseHTTPMaxBodySize checks that the body limit is a non-negative whole number.
// It can return the following errors: // It can return the following errors:
// - types.ScriptHTTPOptionError // - types.ScriptHTTPOptionError
func parseHTTPMaxBodySize(size float64) (int64, error) { func parseHTTPMaxBodySize(size float64) (*int64, error) {
if math.IsNaN(size) || size < 0 || size > float64(httpMaxBodySizeLimit) || size != math.Trunc(size) { if math.IsNaN(size) || size < 0 || size > float64(httpMaxBodySizeLimit) || size != math.Trunc(size) {
return 0, types.NewScriptHTTPOptionError( return nil, types.NewScriptHTTPOptionError(
httpOptionMaxBodySize, httpOptionMaxBodySize,
types.NewScriptTypeError( types.NewScriptTypeError(
"a whole number of bytes between 0 and "+strconv.FormatInt(httpMaxBodySizeLimit, 10), "a whole number of bytes between 0 and "+strconv.FormatInt(httpMaxBodySizeLimit, 10),
@@ -136,24 +136,26 @@ func parseHTTPMaxBodySize(size float64) (int64, error) {
), ),
) )
} }
return int64(size), nil limit := int64(size)
return &limit, nil
} }
// parseHTTPMaxBodySizeString reads a body limit written with a unit, such as "10MiB". // parseHTTPMaxBodySizeString reads a body limit written with a unit, such as "10MiB".
// It can return the following errors: // It can return the following errors:
// - types.ScriptHTTPOptionError // - types.ScriptHTTPOptionError
func parseHTTPMaxBodySizeString(value string) (int64, error) { func parseHTTPMaxBodySizeString(value string) (*int64, error) {
size, err := types.ParseByteSize(value) size, err := types.ParseByteSize(value)
if err != nil { if err != nil {
return 0, types.NewScriptHTTPOptionError(httpOptionMaxBodySize, err) return nil, types.NewScriptHTTPOptionError(httpOptionMaxBodySize, err)
} }
if size > uint64(httpMaxBodySizeLimit) { if size > uint64(httpMaxBodySizeLimit) {
return 0, types.NewScriptHTTPOptionError( return nil, types.NewScriptHTTPOptionError(
httpOptionMaxBodySize, httpOptionMaxBodySize,
types.NewScriptTypeError("a size up to "+types.FormatByteSize(uint64(httpMaxBodySizeLimit)), value), types.NewScriptTypeError("a size up to "+types.FormatByteSize(uint64(httpMaxBodySizeLimit)), value),
) )
} }
return int64(size), nil limit := int64(size)
return &limit, nil
} }
// parseHTTPTimeout parses a Go duration string such as "500ms" or "2s". // parseHTTPTimeout parses a Go duration string such as "500ms" or "2s".
+3 -2
View File
@@ -6,8 +6,9 @@ import (
"strings" "strings"
) )
// ByteSizeLimit is the largest size any option may ask for, 1 TiB, less where int is 32 bit. // ByteSizeLimit is the largest size any option may ask for. A limit is also the most
const ByteSizeLimit = min(1<<40, math.MaxInt) // memory one response may take, so it stays well under what a machine can hold.
const ByteSizeLimit = 1 << 30 // 1 GiB
// RemoteFetchLimit bounds a file sarin downloads for itself, such as a config file or a script. // RemoteFetchLimit bounds a file sarin downloads for itself, such as a config file or a script.
const RemoteFetchLimit = 64 << 20 // 64 MiB const RemoteFetchLimit = 64 << 20 // 64 MiB