fix(script): enforce script body limits without streaming

Streaming accepted a chunked body cut at a chunk boundary as complete,
so the limit now comes from the client that reads the body, picked per
requested size. Script requests keep their own default, maxBodySize
accepts sizes like 10MiB, and files sarin downloads for itself are
capped at 64MiB.
This commit is contained in:
aykhans committed 2026-09-29 00:41:48 +04:00
1 parent f1d91e3d77
commit 1956eb1c44
16 files changed
+212 -163

No files matched your search

+3 -3
View File
@@ -179,7 +179,7 @@ The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `
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. It also applies to the requests scripts make, unless the script passes its own `maxBodySize` (see [HTTP Requests in Scripts](#http-requests-in-scripts)). 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).
## 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 | - | Largest response body to accept in bytes, maximum 1099511627776 | | `maxBodySize` | number or size (`"10MiB"`) | `10MiB` | Largest response body to accept (the max response body config does not apply), maximum `1TiB` |
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:** without `maxBodySize` the [Max Response Body](#max-response-body) config applies, as it does to the main request. With `maxBodySize` the body is read as it arrives, so a `Content-Length` the server never sends costs nothing, the limit is exact, and it may be larger than the config. Passing the limit raises an error instead of truncating the body. - **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.
- **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.
+2 -76
View File
@@ -3,9 +3,6 @@ package config
import ( import (
"flag" "flag"
"fmt" "fmt"
"math"
"strconv"
"strings"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
@@ -19,84 +16,13 @@ var (
// byteSize is a size in bytes that reads and prints unit suffixes such as 10MiB. // byteSize is a size in bytes that reads and prints unit suffixes such as 10MiB.
type byteSize uint64 type byteSize uint64
// byteSizeUnits run from the largest scale down, so formatting picks the largest unit
// that fits. A bare "K" is out: the common libraries disagree on whether it counts
// 1000 or 1024 bytes.
var byteSizeUnits = []struct {
suffix string
scale uint64
}{
{"TiB", 1 << 40},
{"TB", 1000 * 1000 * 1000 * 1000},
{"GiB", 1 << 30},
{"GB", 1000 * 1000 * 1000},
{"MiB", 1 << 20},
{"MB", 1000 * 1000},
{"KiB", 1 << 10},
{"KB", 1000},
{"B", 1},
}
// parseByteSize reads a byte count, with or without a unit suffix.
func parseByteSize(value string) (uint64, error) {
text := strings.TrimSpace(value)
// The suffix is split off by index, so no case mapping can shift the digits.
digitsEnd := len(text)
for digitsEnd > 0 && isASCIILetter(text[digitsEnd-1]) {
digitsEnd--
}
scale := uint64(1)
if suffix := text[digitsEnd:]; suffix != "" {
unitScale, ok := byteSizeScale(suffix)
if !ok {
return 0, types.ErrByteSizeInvalid
}
scale = unitScale
}
size, err := strconv.ParseUint(strings.TrimSpace(text[:digitsEnd]), 10, 64)
if err != nil {
return 0, types.ErrByteSizeInvalid
}
if size > math.MaxUint64/scale {
return 0, types.ErrByteSizeInvalid
}
return size * scale, nil
}
// byteSizeScale looks up a unit suffix, ignoring case.
func byteSizeScale(suffix string) (uint64, bool) {
for _, unit := range byteSizeUnits {
if strings.EqualFold(suffix, unit.suffix) {
return unit.scale, true
}
}
return 0, false
}
func isASCIILetter(char byte) bool {
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
}
// formatByteSize renders a byte count so that parseByteSize reads back the same value.
func formatByteSize(size uint64) string {
for _, unit := range byteSizeUnits {
if unit.scale > 1 && size >= unit.scale && size%unit.scale == 0 {
return strconv.FormatUint(size/unit.scale, 10) + unit.suffix
}
}
return strconv.FormatUint(size, 10)
}
func (size byteSize) String() string { func (size byteSize) String() string {
return formatByteSize(uint64(size)) return types.FormatByteSize(uint64(size))
} }
// Set fills the size from a command line value. // Set fills the size from a command line value.
func (size *byteSize) Set(value string) error { func (size *byteSize) Set(value string) error {
parsed, err := parseByteSize(value) parsed, err := types.ParseByteSize(value)
if err != nil { if err != nil {
return err return err
} }
+1 -1
View File
@@ -293,7 +293,7 @@ func (parser ConfigCLIParser) PrintHelp() {
Defaults.Method, Defaults.Method,
Defaults.RequestTimeout, Defaults.RequestTimeout,
formatByteSize(Defaults.MaxResponseBody), types.FormatByteSize(Defaults.MaxResponseBody),
Defaults.Insecure, Defaults.Insecure,
) )
} }
+4 -7
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -59,8 +58,6 @@ var (
ValidLogLevels = []string{"info", "error"} ValidLogLevels = []string{"info", "error"}
) )
const maxResponseBodyLimit = min(1<<40, math.MaxInt) // 1 TiB on 64 bit, 2 GiB - 1 on 32 bit
var ( var (
StyleYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) StyleYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
StyleRed = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) StyleRed = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
@@ -184,7 +181,7 @@ func (config Config) MarshalYAML() (any, error) {
addField(content, "timeout", toNode(*config.Timeout), "") addField(content, "timeout", toNode(*config.Timeout), "")
} }
if config.MaxResponseBody != nil { if config.MaxResponseBody != nil {
addField(content, "maxResponseBody", toNode(formatByteSize(*config.MaxResponseBody)), "") addField(content, "maxResponseBody", toNode(types.FormatByteSize(*config.MaxResponseBody)), "")
} }
if config.Concurrency != nil { if config.Concurrency != nil {
addField(content, "concurrency", toNode(*config.Concurrency), "") addField(content, "concurrency", toNode(*config.Concurrency), "")
@@ -469,11 +466,11 @@ func (config Config) Validate() error {
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0"))) validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
} }
if config.MaxResponseBody != nil && *config.MaxResponseBody > maxResponseBodyLimit { if config.MaxResponseBody != nil && *config.MaxResponseBody > types.ByteSizeLimit {
validationErrors = append(validationErrors, types.NewFieldValidationError( validationErrors = append(validationErrors, types.NewFieldValidationError(
"MaxResponseBody", "MaxResponseBody",
formatByteSize(*config.MaxResponseBody), types.FormatByteSize(*config.MaxResponseBody),
errors.New("max response body must not exceed "+formatByteSize(maxResponseBodyLimit)), errors.New("max response body must not exceed "+types.FormatByteSize(types.ByteSizeLimit)),
)) ))
} }
+1 -1
View File
@@ -196,7 +196,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
} }
if maxResponseBody := parser.getEnv("MAX_RESPONSE_BODY"); maxResponseBody != "" { if maxResponseBody := parser.getEnv("MAX_RESPONSE_BODY"); maxResponseBody != "" {
maxResponseBodyParsed, err := parseByteSize(maxResponseBody) maxResponseBodyParsed, err := types.ParseByteSize(maxResponseBody)
if err != nil { if err != nil {
fieldParseErrors = append( fieldParseErrors = append(
fieldParseErrors, fieldParseErrors,
+4 -1
View File
@@ -79,10 +79,13 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
} }
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
if err != nil { if err != nil {
return nil, types.NewHTTPFetchError(url, err) return nil, types.NewHTTPFetchError(url, err)
} }
if len(data) > types.RemoteFetchLimit {
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
return data, nil return data, nil
} }
+4 -1
View File
@@ -93,10 +93,13 @@ func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
} }
content, err := io.ReadAll(resp.Body) content, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
if err != nil { if err != nil {
return nil, "", types.NewHTTPFetchError(url, err) return nil, "", types.NewHTTPFetchError(url, err)
} }
if len(content) > types.RemoteFetchLimit {
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
// Extract filename from URL path // Extract filename from URL path
filename := path.Base(url) filename := path.Base(url)
+7
View File
@@ -34,3 +34,10 @@ func safeUint64ToInt(u uint64) int {
} }
return int(u) return int(u)
} }
func safeInt64ToInt(i int64) int {
if i > math.MaxInt {
return math.MaxInt
}
return int(i)
}
+1 -1
View File
@@ -281,7 +281,7 @@ func NewSarin(
var scriptHTTPClients []*scriptHTTPClient var scriptHTTPClients []*scriptHTTPClient
if !scriptChain.IsEmpty() { if !scriptChain.IsEmpty() {
scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers, maxResponseBody) scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers)
if err != nil { if err != nil {
jobsCancel() jobsCancel()
return nil, err return nil, err
+54 -56
View File
@@ -1,13 +1,12 @@
package sarin package sarin
import ( import (
"bytes"
"context" "context"
"crypto/tls" "crypto/tls"
"errors" "errors"
"io"
"net" "net"
"net/url" "net/url"
"sync"
"time" "time"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
@@ -18,11 +17,22 @@ import (
// scriptHTTPDefaultTimeout is the default timeout for script requests, independent of -T. // scriptHTTPDefaultTimeout is the default timeout for script requests, independent of -T.
const scriptHTTPDefaultTimeout = 30 * time.Second const scriptHTTPDefaultTimeout = 30 * time.Second
// scriptHTTPDefaultMaxBodySize is the default body limit for script requests,
// independent of the max response body config.
const scriptHTTPDefaultMaxBodySize = 10 << 20 // 10 MiB
// scriptHTTPClientKey picks the client a request needs, since fasthttp takes the body
// limit per client rather than per request.
type scriptHTTPClientKey struct {
insecure bool
maxBodySize int
}
// scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use. // scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use.
type scriptHTTPClient struct { type scriptHTTPClient struct {
ctx context.Context //nolint:containedctx ctx context.Context //nolint:containedctx
client *fasthttp.Client newClient func(key scriptHTTPClientKey) *fasthttp.Client
insecureClient *fasthttp.Client clients sync.Map
defaultTimeout time.Duration defaultTimeout time.Duration
} }
@@ -31,9 +41,9 @@ var _ script.HTTPDoer = (*scriptHTTPClient)(nil)
// newScriptHTTPClients creates one client per proxy, in the same order as NewHostClients. // newScriptHTTPClients creates one client per proxy, in the same order as NewHostClients.
// It can return the following errors: // It can return the following errors:
// - types.ProxyDialError // - types.ProxyDialError
func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint, maxResponseBody uint64) ([]*scriptHTTPClient, error) { func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint) ([]*scriptHTTPClient, error) {
if len(proxies) == 0 { if len(proxies) == 0 {
return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)}, nil return []*scriptHTTPClient{newScriptHTTPClient(ctx, fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns)}, nil
} }
clients := make([]*scriptHTTPClient, 0, len(proxies)) clients := make([]*scriptHTTPClient, 0, len(proxies))
@@ -42,36 +52,47 @@ func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint,
if err != nil { if err != nil {
return nil, types.NewProxyDialError(proxy.String(), err) return nil, types.NewProxyDialError(proxy.String(), err)
} }
clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)) clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns))
} }
return clients, nil return clients, nil
} }
func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint, maxResponseBody uint64) *scriptHTTPClient { func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint) *scriptHTTPClient {
newClient := func(insecure bool) *fasthttp.Client { return &scriptHTTPClient{
ctx: ctx,
newClient: func(key scriptHTTPClientKey) *fasthttp.Client {
// Read and write timeouts stay unset because they would cap the per-call timeout, // Read and write timeouts stay unset because they would cap the per-call timeout,
// so dialWithDeadline is what bounds the TLS handshake. // so dialWithDeadline is what bounds the TLS handshake.
return &fasthttp.Client{ return &fasthttp.Client{
DialTimeout: dialWithDeadline(dial), DialTimeout: dialWithDeadline(dial),
MaxConnsPerHost: safeUintToInt(maxConns), MaxConnsPerHost: safeUintToInt(maxConns),
MaxResponseBodySize: safeUint64ToInt(maxResponseBody), MaxResponseBodySize: key.maxBodySize,
TLSConfig: &tls.Config{ TLSConfig: &tls.Config{
InsecureSkipVerify: insecure, //nolint:gosec InsecureSkipVerify: key.insecure, //nolint:gosec
}, },
DisableHeaderNamesNormalizing: true, DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true, DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true, NoDefaultUserAgentHeader: true,
} }
} },
return &scriptHTTPClient{
ctx: ctx,
client: newClient(false),
insecureClient: newClient(true),
defaultTimeout: defaultTimeout, defaultTimeout: defaultTimeout,
} }
} }
// scriptHTTPBodyCap rounds a limit up to a power of two, so a script that varies its
// limit cannot mint a client, and a connection pool with it, per request.
func scriptHTTPBodyCap(maxBodySize int64) int {
if maxBodySize <= 0 {
return scriptHTTPDefaultMaxBodySize
}
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,
// so the TLS handshake fasthttp runs on the first write cannot outlive the request. // so the TLS handshake fasthttp runs on the first write cannot outlive the request.
func dialWithDeadline(dial fasthttp.DialFuncWithTimeout) fasthttp.DialFuncWithTimeout { func dialWithDeadline(dial fasthttp.DialFuncWithTimeout) fasthttp.DialFuncWithTimeout {
@@ -142,64 +163,41 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro
timeout = c.defaultTimeout timeout = c.defaultTimeout
} }
client := c.client client := c.clientFor(r.Insecure, r.MaxBodySize)
if r.Insecure {
client = c.insecureClient
}
req.SetTimeout(timeout) req.SetTimeout(timeout)
// A body limit streams the response, so a declared Content-Length is never allocated.
resp.StreamBody = r.MaxBodySize > 0
if r.MaxRedirects > 0 { if r.MaxRedirects > 0 {
err = client.DoRedirects(req, resp, r.MaxRedirects) err = client.DoRedirects(req, resp, r.MaxRedirects)
} else { } else {
err = client.Do(req, resp) err = client.Do(req, resp)
} }
if err != nil { if err != nil {
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
err = types.ErrScriptHTTPBodyTooLarge
}
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
} }
body, err := readScriptBody(resp, r.MaxBodySize) // The client caps the body at the rounded up limit, so the exact one is checked here.
if err != nil { body := resp.Body()
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) 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(),
Headers: collectRespHeaders(resp), Headers: collectRespHeaders(resp),
Body: body, Body: string(body),
}, nil }, nil
} }
// readScriptBody reads the streamed body, refusing it once it passes maxBodySize. // clientFor returns the client whose body limit covers maxBodySize, building it once.
// It can return the following errors: func (c *scriptHTTPClient) clientFor(insecure bool, maxBodySize int64) *fasthttp.Client {
// - types.ErrScriptHTTPBodyTooLarge key := scriptHTTPClientKey{insecure: insecure, maxBodySize: scriptHTTPBodyCap(maxBodySize)}
func readScriptBody(resp *fasthttp.Response, maxBodySize int64) (string, error) { if client, ok := c.clients.Load(key); ok {
stream := resp.BodyStream() return client.(*fasthttp.Client) //nolint:forcetypeassert
if stream == nil {
return string(resp.Body()), nil
} }
var ( client, _ := c.clients.LoadOrStore(key, c.newClient(key))
body bytes.Buffer return client.(*fasthttp.Client) //nolint:forcetypeassert
err error
)
if maxBodySize > 0 {
// One byte past the limit tells an exact fit from an overflow.
_, err = io.CopyN(&body, stream, maxBodySize+1)
if err == nil {
err = types.ErrScriptHTTPBodyTooLarge
} else if errors.Is(err, io.EOF) {
err = nil
}
} else {
_, err = io.Copy(&body, stream)
}
// An unfinished stream makes fasthttp drop the connection instead of pooling it.
_ = resp.CloseBodyStream()
if err != nil {
return "", err
}
return body.String(), nil
} }
+20 -3
View File
@@ -67,8 +67,8 @@ const httpDefaultMethod = http.MethodGet
// httpMaxRedirectsLimit is the highest maxRedirects a script may ask for. // httpMaxRedirectsLimit is the highest maxRedirects a script may ask for.
const httpMaxRedirectsLimit = 100 const httpMaxRedirectsLimit = 100
// httpMaxBodySizeLimit is the highest maxBodySize a script may ask for, 1 TiB. // httpMaxBodySizeLimit is the highest maxBodySize a script may ask for.
const httpMaxBodySizeLimit int64 = 1 << 40 const httpMaxBodySizeLimit int64 = types.ByteSizeLimit
// Option keys accepted by the http function. // Option keys accepted by the http function.
const ( const (
@@ -132,13 +132,30 @@ func parseHTTPMaxBodySize(size float64) (int64, error) {
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),
strconv.FormatFloat(size, 'g', -1, 64), strconv.FormatFloat(size, 'f', -1, 64),
), ),
) )
} }
return int64(size), nil return int64(size), nil
} }
// parseHTTPMaxBodySizeString reads a body limit written with a unit, such as "10MiB".
// It can return the following errors:
// - types.ScriptHTTPOptionError
func parseHTTPMaxBodySizeString(value string) (int64, error) {
size, err := types.ParseByteSize(value)
if err != nil {
return 0, types.NewScriptHTTPOptionError(httpOptionMaxBodySize, err)
}
if size > uint64(httpMaxBodySizeLimit) {
return 0, types.NewScriptHTTPOptionError(
httpOptionMaxBodySize,
types.NewScriptTypeError("a size up to "+types.FormatByteSize(uint64(httpMaxBodySizeLimit)), value),
)
}
return int64(size), nil
}
// parseHTTPTimeout parses a Go duration string such as "500ms" or "2s". // parseHTTPTimeout parses a Go duration string such as "500ms" or "2s".
// It can return the following errors: // It can return the following errors:
// - types.ScriptHTTPOptionError // - types.ScriptHTTPOptionError
+11 -6
View File
@@ -118,13 +118,18 @@ func (e *JsEngine) parseHTTPOptions(opts *goja.Object, req *HTTPRequest) error {
return err return err
} }
case httpOptionMaxBodySize: case httpOptionMaxBodySize:
size, ok := numberValue(value)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", jsTypeName(value)))
}
var err error var err error
if req.MaxBodySize, err = parseHTTPMaxBodySize(size); err != nil { switch size := value.Export().(type) {
case string:
req.MaxBodySize, err = parseHTTPMaxBodySizeString(size)
default:
number, ok := numberValue(value)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number or size string", jsTypeName(value)))
}
req.MaxBodySize, err = parseHTTPMaxBodySize(number)
}
if err != nil {
return err return err
} }
default: default:
+7 -5
View File
@@ -107,12 +107,14 @@ func (e *LuaEngine) parseHTTPOptions(opts *lua.LTable, req *HTTPRequest) error {
} }
req.MaxRedirects, err = parseHTTPMaxRedirects(float64(count)) req.MaxRedirects, err = parseHTTPMaxRedirects(float64(count))
case httpOptionMaxBodySize: case httpOptionMaxBodySize:
size, ok := v.(lua.LNumber) switch size := v.(type) {
if !ok { case lua.LNumber:
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", v.Type().String()))
return
}
req.MaxBodySize, err = parseHTTPMaxBodySize(float64(size)) req.MaxBodySize, err = parseHTTPMaxBodySize(float64(size))
case lua.LString:
req.MaxBodySize, err = parseHTTPMaxBodySizeString(string(size))
default:
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number or size string", v.Type().String()))
}
default: default:
err = types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption) err = types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption)
} }
+4 -1
View File
@@ -171,10 +171,13 @@ func fetchURL(ctx context.Context, url string) (string, error) {
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status) return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
} }
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
if err != nil { if err != nil {
return "", types.NewHTTPFetchError(url, err) return "", types.NewHTTPFetchError(url, err)
} }
if len(data) > types.RemoteFetchLimit {
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
}
return string(data), nil return string(data), nil
} }
+86
View File
@@ -0,0 +1,86 @@
package types
import (
"math"
"strconv"
"strings"
)
// ByteSizeLimit is the largest size any option may ask for, 1 TiB, less where int is 32 bit.
const ByteSizeLimit = min(1<<40, math.MaxInt)
// RemoteFetchLimit bounds a file sarin downloads for itself, such as a config file or a script.
const RemoteFetchLimit = 64 << 20 // 64 MiB
// byteSizeUnits run from the largest scale down, so formatting picks the largest unit
// that fits. A bare "K" is out: the common libraries disagree on whether it counts
// 1000 or 1024 bytes.
var byteSizeUnits = []struct {
suffix string
scale uint64
}{
{"TiB", 1 << 40},
{"TB", 1000 * 1000 * 1000 * 1000},
{"GiB", 1 << 30},
{"GB", 1000 * 1000 * 1000},
{"MiB", 1 << 20},
{"MB", 1000 * 1000},
{"KiB", 1 << 10},
{"KB", 1000},
{"B", 1},
}
// ParseByteSize reads a byte count, with or without a unit suffix.
// It can return the following errors:
// - ErrByteSizeInvalid
func ParseByteSize(value string) (uint64, error) {
text := strings.TrimSpace(value)
// The suffix is split off by index, so no case mapping can shift the digits.
digitsEnd := len(text)
for digitsEnd > 0 && isASCIILetter(text[digitsEnd-1]) {
digitsEnd--
}
scale := uint64(1)
if suffix := text[digitsEnd:]; suffix != "" {
unitScale, ok := byteSizeScale(suffix)
if !ok {
return 0, ErrByteSizeInvalid
}
scale = unitScale
}
size, err := strconv.ParseUint(strings.TrimSpace(text[:digitsEnd]), 10, 64)
if err != nil {
return 0, ErrByteSizeInvalid
}
if size > math.MaxUint64/scale {
return 0, ErrByteSizeInvalid
}
return size * scale, nil
}
// FormatByteSize renders a byte count so that ParseByteSize reads back the same value.
func FormatByteSize(size uint64) string {
for _, unit := range byteSizeUnits {
if unit.scale > 1 && size >= unit.scale && size%unit.scale == 0 {
return strconv.FormatUint(size/unit.scale, 10) + unit.suffix
}
}
return strconv.FormatUint(size, 10)
}
// byteSizeScale looks up a unit suffix, ignoring case.
func byteSizeScale(suffix string) (uint64, bool) {
for _, unit := range byteSizeUnits {
if strings.EqualFold(suffix, unit.suffix) {
return unit.scale, true
}
}
return 0, false
}
func isASCIILetter(char byte) bool {
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
}
+3 -1
View File
@@ -15,6 +15,8 @@ var (
"invalid value for size, expected a whole number of bytes or a whole number with a unit: " + "invalid value for size, expected a whole number of bytes or a whole number with a unit: " +
"B, KB, MB, GB, TB (1000 based) or KiB, MiB, GiB, TiB (1024 based), e.g. '1048576', '10MiB', '2MB'", "B, KB, MB, GB, TB (1000 based) or KiB, MiB, GiB, TiB (1024 based), e.g. '1048576', '10MiB', '2MB'",
) )
ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit))
) )
type FieldParseError struct { type FieldParseError struct {
@@ -413,7 +415,7 @@ var (
ErrScriptHTTPMethodEmpty = errors.New("http method cannot be empty") ErrScriptHTTPMethodEmpty = errors.New("http method cannot be empty")
ErrScriptHTTPURLInvalid = errors.New("URL must be an absolute http or https URL") ErrScriptHTTPURLInvalid = errors.New("URL must be an absolute http or https URL")
ErrScriptHTTPUnknownOption = errors.New("unknown option") ErrScriptHTTPUnknownOption = errors.New("unknown option")
ErrScriptHTTPBodyTooLarge = errors.New("response body exceeds maxBodySize") ErrScriptHTTPBodyTooLarge = errors.New("response body exceeds the body limit")
ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself") ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself")
) )