mirror of
https://github.com/aykhans/sarin.git
synced 2026-10-16 14:43:53 +00:00
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:
16 files changed
+222
-173
No files matched your search
@@ -3,9 +3,6 @@ package config
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
"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.
|
||||
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 {
|
||||
return formatByteSize(uint64(size))
|
||||
return types.FormatByteSize(uint64(size))
|
||||
}
|
||||
|
||||
// Set fills the size from a command line value.
|
||||
func (size *byteSize) Set(value string) error {
|
||||
parsed, err := parseByteSize(value)
|
||||
parsed, err := types.ParseByteSize(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ func (parser ConfigCLIParser) PrintHelp() {
|
||||
|
||||
Defaults.Method,
|
||||
Defaults.RequestTimeout,
|
||||
formatByteSize(Defaults.MaxResponseBody),
|
||||
types.FormatByteSize(Defaults.MaxResponseBody),
|
||||
Defaults.Insecure,
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -59,8 +58,6 @@ var (
|
||||
ValidLogLevels = []string{"info", "error"}
|
||||
)
|
||||
|
||||
const maxResponseBodyLimit = min(1<<40, math.MaxInt) // 1 TiB on 64 bit, 2 GiB - 1 on 32 bit
|
||||
|
||||
var (
|
||||
StyleYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
|
||||
StyleRed = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||||
@@ -184,7 +181,7 @@ func (config Config) MarshalYAML() (any, error) {
|
||||
addField(content, "timeout", toNode(*config.Timeout), "")
|
||||
}
|
||||
if config.MaxResponseBody != nil {
|
||||
addField(content, "maxResponseBody", toNode(formatByteSize(*config.MaxResponseBody)), "")
|
||||
addField(content, "maxResponseBody", toNode(types.FormatByteSize(*config.MaxResponseBody)), "")
|
||||
}
|
||||
if config.Concurrency != nil {
|
||||
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")))
|
||||
}
|
||||
|
||||
if config.MaxResponseBody != nil && *config.MaxResponseBody > maxResponseBodyLimit {
|
||||
if config.MaxResponseBody != nil && *config.MaxResponseBody > types.ByteSizeLimit {
|
||||
validationErrors = append(validationErrors, types.NewFieldValidationError(
|
||||
"MaxResponseBody",
|
||||
formatByteSize(*config.MaxResponseBody),
|
||||
errors.New("max response body must not exceed "+formatByteSize(maxResponseBodyLimit)),
|
||||
types.FormatByteSize(*config.MaxResponseBody),
|
||||
errors.New("max response body must not exceed "+types.FormatByteSize(types.ByteSizeLimit)),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
||||
}
|
||||
|
||||
if maxResponseBody := parser.getEnv("MAX_RESPONSE_BODY"); maxResponseBody != "" {
|
||||
maxResponseBodyParsed, err := parseByteSize(maxResponseBody)
|
||||
maxResponseBodyParsed, err := types.ParseByteSize(maxResponseBody)
|
||||
if err != nil {
|
||||
fieldParseErrors = append(
|
||||
fieldParseErrors,
|
||||
|
||||
@@ -79,10 +79,13 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
||||
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 {
|
||||
return nil, types.NewHTTPFetchError(url, err)
|
||||
}
|
||||
if len(data) > types.RemoteFetchLimit {
|
||||
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -93,10 +93,13 @@ func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
|
||||
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 {
|
||||
return nil, "", types.NewHTTPFetchError(url, err)
|
||||
}
|
||||
if len(content) > types.RemoteFetchLimit {
|
||||
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
// Extract filename from URL path
|
||||
filename := path.Base(url)
|
||||
|
||||
@@ -34,3 +34,10 @@ func safeUint64ToInt(u uint64) int {
|
||||
}
|
||||
return int(u)
|
||||
}
|
||||
|
||||
func safeInt64ToInt(i int64) int {
|
||||
if i > math.MaxInt {
|
||||
return math.MaxInt
|
||||
}
|
||||
return int(i)
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func NewSarin(
|
||||
|
||||
var scriptHTTPClients []*scriptHTTPClient
|
||||
if !scriptChain.IsEmpty() {
|
||||
scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers, maxResponseBody)
|
||||
scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers)
|
||||
if err != nil {
|
||||
jobsCancel()
|
||||
return nil, err
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
@@ -18,11 +17,22 @@ import (
|
||||
// scriptHTTPDefaultTimeout is the default timeout for script requests, independent of -T.
|
||||
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.
|
||||
type scriptHTTPClient struct {
|
||||
ctx context.Context //nolint:containedctx
|
||||
client *fasthttp.Client
|
||||
insecureClient *fasthttp.Client
|
||||
newClient func(key scriptHTTPClientKey) *fasthttp.Client
|
||||
clients sync.Map
|
||||
defaultTimeout time.Duration
|
||||
}
|
||||
|
||||
@@ -31,9 +41,9 @@ var _ script.HTTPDoer = (*scriptHTTPClient)(nil)
|
||||
// newScriptHTTPClients creates one client per proxy, in the same order as NewHostClients.
|
||||
// It can return the following errors:
|
||||
// - 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 {
|
||||
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))
|
||||
@@ -42,36 +52,47 @@ func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint,
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint, maxResponseBody uint64) *scriptHTTPClient {
|
||||
newClient := func(insecure bool) *fasthttp.Client {
|
||||
// Read and write timeouts stay unset because they would cap the per-call timeout,
|
||||
// so dialWithDeadline is what bounds the TLS handshake.
|
||||
return &fasthttp.Client{
|
||||
DialTimeout: dialWithDeadline(dial),
|
||||
MaxConnsPerHost: safeUintToInt(maxConns),
|
||||
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||
TLSConfig: &tls.Config{
|
||||
InsecureSkipVerify: insecure, //nolint:gosec
|
||||
},
|
||||
DisableHeaderNamesNormalizing: true,
|
||||
DisablePathNormalizing: true,
|
||||
NoDefaultUserAgentHeader: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint) *scriptHTTPClient {
|
||||
return &scriptHTTPClient{
|
||||
ctx: ctx,
|
||||
client: newClient(false),
|
||||
insecureClient: newClient(true),
|
||||
ctx: ctx,
|
||||
newClient: func(key scriptHTTPClientKey) *fasthttp.Client {
|
||||
// Read and write timeouts stay unset because they would cap the per-call timeout,
|
||||
// so dialWithDeadline is what bounds the TLS handshake.
|
||||
return &fasthttp.Client{
|
||||
DialTimeout: dialWithDeadline(dial),
|
||||
MaxConnsPerHost: safeUintToInt(maxConns),
|
||||
MaxResponseBodySize: key.maxBodySize,
|
||||
TLSConfig: &tls.Config{
|
||||
InsecureSkipVerify: key.insecure, //nolint:gosec
|
||||
},
|
||||
DisableHeaderNamesNormalizing: true,
|
||||
DisablePathNormalizing: true,
|
||||
NoDefaultUserAgentHeader: true,
|
||||
}
|
||||
},
|
||||
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,
|
||||
// so the TLS handshake fasthttp runs on the first write cannot outlive the request.
|
||||
func dialWithDeadline(dial fasthttp.DialFuncWithTimeout) fasthttp.DialFuncWithTimeout {
|
||||
@@ -142,64 +163,41 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro
|
||||
timeout = c.defaultTimeout
|
||||
}
|
||||
|
||||
client := c.client
|
||||
if r.Insecure {
|
||||
client = c.insecureClient
|
||||
}
|
||||
client := c.clientFor(r.Insecure, r.MaxBodySize)
|
||||
|
||||
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 {
|
||||
err = client.DoRedirects(req, resp, r.MaxRedirects)
|
||||
} else {
|
||||
err = client.Do(req, resp)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
|
||||
err = types.ErrScriptHTTPBodyTooLarge
|
||||
}
|
||||
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
|
||||
}
|
||||
|
||||
body, err := readScriptBody(resp, r.MaxBodySize)
|
||||
if err != nil {
|
||||
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()
|
||||
if r.MaxBodySize > 0 && int64(len(body)) > r.MaxBodySize {
|
||||
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, types.ErrScriptHTTPBodyTooLarge)
|
||||
}
|
||||
|
||||
return &script.HTTPResponse{
|
||||
Status: resp.StatusCode(),
|
||||
Headers: collectRespHeaders(resp),
|
||||
Body: body,
|
||||
Body: string(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readScriptBody reads the streamed body, refusing it once it passes maxBodySize.
|
||||
// It can return the following errors:
|
||||
// - types.ErrScriptHTTPBodyTooLarge
|
||||
func readScriptBody(resp *fasthttp.Response, maxBodySize int64) (string, error) {
|
||||
stream := resp.BodyStream()
|
||||
if stream == nil {
|
||||
return string(resp.Body()), nil
|
||||
// clientFor returns the client whose body limit covers maxBodySize, building it once.
|
||||
func (c *scriptHTTPClient) clientFor(insecure bool, maxBodySize int64) *fasthttp.Client {
|
||||
key := scriptHTTPClientKey{insecure: insecure, maxBodySize: scriptHTTPBodyCap(maxBodySize)}
|
||||
if client, ok := c.clients.Load(key); ok {
|
||||
return client.(*fasthttp.Client) //nolint:forcetypeassert
|
||||
}
|
||||
|
||||
var (
|
||||
body bytes.Buffer
|
||||
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
|
||||
client, _ := c.clients.LoadOrStore(key, c.newClient(key))
|
||||
return client.(*fasthttp.Client) //nolint:forcetypeassert
|
||||
}
|
||||
+20
-3
@@ -67,8 +67,8 @@ const httpDefaultMethod = http.MethodGet
|
||||
// httpMaxRedirectsLimit is the highest maxRedirects a script may ask for.
|
||||
const httpMaxRedirectsLimit = 100
|
||||
|
||||
// httpMaxBodySizeLimit is the highest maxBodySize a script may ask for, 1 TiB.
|
||||
const httpMaxBodySizeLimit int64 = 1 << 40
|
||||
// httpMaxBodySizeLimit is the highest maxBodySize a script may ask for.
|
||||
const httpMaxBodySizeLimit int64 = types.ByteSizeLimit
|
||||
|
||||
// Option keys accepted by the http function.
|
||||
const (
|
||||
@@ -132,13 +132,30 @@ func parseHTTPMaxBodySize(size float64) (int64, error) {
|
||||
httpOptionMaxBodySize,
|
||||
types.NewScriptTypeError(
|
||||
"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
|
||||
}
|
||||
|
||||
// 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".
|
||||
// It can return the following errors:
|
||||
// - types.ScriptHTTPOptionError
|
||||
|
||||
@@ -118,13 +118,18 @@ func (e *JsEngine) parseHTTPOptions(opts *goja.Object, req *HTTPRequest) error {
|
||||
return err
|
||||
}
|
||||
case httpOptionMaxBodySize:
|
||||
size, ok := numberValue(value)
|
||||
if !ok {
|
||||
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", jsTypeName(value)))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -107,12 +107,14 @@ func (e *LuaEngine) parseHTTPOptions(opts *lua.LTable, req *HTTPRequest) error {
|
||||
}
|
||||
req.MaxRedirects, err = parseHTTPMaxRedirects(float64(count))
|
||||
case httpOptionMaxBodySize:
|
||||
size, ok := v.(lua.LNumber)
|
||||
if !ok {
|
||||
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", v.Type().String()))
|
||||
return
|
||||
switch size := v.(type) {
|
||||
case lua.LNumber:
|
||||
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()))
|
||||
}
|
||||
req.MaxBodySize, err = parseHTTPMaxBodySize(float64(size))
|
||||
default:
|
||||
err = types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption)
|
||||
}
|
||||
|
||||
@@ -171,10 +171,13 @@ func fetchURL(ctx context.Context, url string) (string, error) {
|
||||
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 {
|
||||
return "", types.NewHTTPFetchError(url, err)
|
||||
}
|
||||
if len(data) > types.RemoteFetchLimit {
|
||||
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -15,6 +15,8 @@ var (
|
||||
"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'",
|
||||
)
|
||||
|
||||
ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit))
|
||||
)
|
||||
|
||||
type FieldParseError struct {
|
||||
@@ -413,7 +415,7 @@ var (
|
||||
ErrScriptHTTPMethodEmpty = errors.New("http method cannot be empty")
|
||||
ErrScriptHTTPURLInvalid = errors.New("URL must be an absolute http or https URL")
|
||||
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")
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user