Skip to content

Commit 52427fb

Browse files
committed
Add body_size_limit option to http module
This option limits the maximum body length that will be read from the HTTP server. It's meant to prevent misconfigured servers from causing the probe to use too many resources, even if temporarily. It's not an additional check on the response, for that, use the resulting metrics (probe_http_content_length, probe_http_uncompressed_body_length, etc). Signed-off-by: Marcelo E. Magallon <[email protected]>
1 parent 72910e5 commit 52427fb

File tree

7 files changed

+147
-0
lines changed

7 files changed

+147
-0
lines changed

CONFIGURATION.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ The other placeholders are specified separately.
4949
headers:
5050
[ <string>: <string> ... ]
5151

52+
# The maximum uncompressed body length in bytes that will be processed. A value of 0 means no limit.
53+
#
54+
# If the response includes a Content-Length header, it is NOT validated against this value. This
55+
# setting is only meant to limit the amount of data that you are willing to read from the server.
56+
#
57+
# Example: 10MB
58+
[ body_size_limit: <size> | default = 0 ]
59+
5260
# The compression algorithm to use to decompress the response (gzip, br, deflate, identity).
5361
#
5462
# If an "Accept-Encoding" header is specified, it MUST be such that the compression algorithm

config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828

2929
yaml "gopkg.in/yaml.v3"
3030

31+
"github.com/alecthomas/units"
3132
"github.com/go-kit/kit/log"
3233
"github.com/go-kit/kit/log/level"
3334
"github.com/miekg/dns"
@@ -207,6 +208,7 @@ type HTTPProbe struct {
207208
Body string `yaml:"body,omitempty"`
208209
HTTPClientConfig config.HTTPClientConfig `yaml:"http_client_config,inline"`
209210
Compression string `yaml:"compression,omitempty"`
211+
BodySizeLimit units.Base2Bytes `yaml:"body_size_limit,omitempty"`
210212
}
211213

212214
type HeaderMatch struct {
@@ -287,6 +289,11 @@ func (s *HTTPProbe) UnmarshalYAML(unmarshal func(interface{}) error) error {
287289
if err := unmarshal((*plain)(s)); err != nil {
288290
return err
289291
}
292+
293+
if s.BodySizeLimit < 0 {
294+
s.BodySizeLimit = math.MaxInt64
295+
}
296+
290297
if err := s.HTTPClientConfig.Validate(); err != nil {
291298
return err
292299
}

config/testdata/blackbox-good.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ modules:
1111
basic_auth:
1212
username: "username"
1313
password: "mysecret"
14+
body_size_limit: 1MB
1415
tcp_connect:
1516
prober: tcp
1617
timeout: 5s

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module github.com/prometheus/blackbox_exporter
22

33
require (
4+
github.com/alecthomas/units v0.0.0-20210912230133-d1bdfacee922
45
github.com/andybalholm/brotli v1.0.2
56
github.com/go-kit/kit v0.10.0
67
github.com/miekg/dns v1.1.41

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
1313
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
1414
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E=
1515
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
16+
github.com/alecthomas/units v0.0.0-20210912230133-d1bdfacee922 h1:8ypNbf5sd3Sm3cKJ9waOGoQv6dKAFiFty9L6NP1AqJ4=
17+
github.com/alecthomas/units v0.0.0-20210912230133-d1bdfacee922/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
1618
github.com/andybalholm/brotli v1.0.2 h1:JKnhI/XQ75uFBTiuzXpzFrUriDPiZjlOSzh6wXogP0E=
1719
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
1820
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=

prober/http.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,14 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
499499
}
500500
}
501501

502+
// If there's a configured body_size_limit, wrap the body in the response in a http.MaxBytesReader.
503+
// This will read up to BodySizeLimit bytes from the body, and return an error if the response is
504+
// larger. It forwards the Close call to the original resp.Body to make sure the TCP connection is
505+
// correctly shut down. The limit is applied _after decompression_ if applicable.
506+
if httpConfig.BodySizeLimit > 0 {
507+
resp.Body = http.MaxBytesReader(nil, resp.Body, int64(httpConfig.BodySizeLimit))
508+
}
509+
502510
byteCounter := &byteCounter{ReadCloser: resp.Body}
503511

504512
if success && (len(httpConfig.FailIfBodyMatchesRegexp) > 0 || len(httpConfig.FailIfBodyNotMatchesRegexp) > 0) {

prober/http_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,126 @@ func TestHandlingOfCompressionSetting(t *testing.T) {
513513
}
514514
}
515515

516+
func TestMaxResponseLength(t *testing.T) {
517+
const max = 128
518+
519+
var shortGzippedPayload bytes.Buffer
520+
enc := gzip.NewWriter(&shortGzippedPayload)
521+
enc.Write(bytes.Repeat([]byte{'A'}, max-1))
522+
enc.Close()
523+
524+
var longGzippedPayload bytes.Buffer
525+
enc = gzip.NewWriter(&longGzippedPayload)
526+
enc.Write(bytes.Repeat([]byte{'A'}, max+1))
527+
enc.Close()
528+
529+
testcases := map[string]struct {
530+
target string
531+
compression string
532+
expectedMetrics map[string]float64
533+
expectFailure bool
534+
}{
535+
"short": {
536+
target: "/short",
537+
expectedMetrics: map[string]float64{
538+
"probe_http_uncompressed_body_length": float64(max - 1),
539+
"probe_http_content_length": float64(max - 1),
540+
},
541+
},
542+
"long": {
543+
target: "/long",
544+
expectFailure: true,
545+
expectedMetrics: map[string]float64{
546+
"probe_http_content_length": float64(max + 1),
547+
},
548+
},
549+
"short compressed": {
550+
target: "/short-compressed",
551+
compression: "gzip",
552+
expectedMetrics: map[string]float64{
553+
"probe_http_content_length": float64(shortGzippedPayload.Len()),
554+
"probe_http_uncompressed_body_length": float64(max - 1),
555+
},
556+
},
557+
"long compressed": {
558+
target: "/long-compressed",
559+
compression: "gzip",
560+
expectFailure: true,
561+
expectedMetrics: map[string]float64{
562+
"probe_http_content_length": float64(longGzippedPayload.Len()),
563+
"probe_http_uncompressed_body_length": max, // it should stop decompressing at max bytes
564+
},
565+
},
566+
}
567+
568+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
569+
var resp []byte
570+
571+
switch r.URL.Path {
572+
case "/short-compressed":
573+
resp = shortGzippedPayload.Bytes()
574+
w.Header().Add("Content-Encoding", "gzip")
575+
576+
case "/long-compressed":
577+
resp = longGzippedPayload.Bytes()
578+
w.Header().Add("Content-Encoding", "gzip")
579+
580+
case "/long":
581+
resp = bytes.Repeat([]byte{'A'}, max+1)
582+
583+
case "/short":
584+
resp = bytes.Repeat([]byte{'A'}, max-1)
585+
586+
default:
587+
w.WriteHeader(http.StatusBadRequest)
588+
return
589+
}
590+
591+
w.Header().Set("Content-Length", strconv.Itoa(len(resp)))
592+
w.WriteHeader(http.StatusOK)
593+
w.Write(resp)
594+
}))
595+
defer ts.Close()
596+
597+
for name, tc := range testcases {
598+
t.Run(name, func(t *testing.T) {
599+
registry := prometheus.NewRegistry()
600+
testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second)
601+
defer cancel()
602+
603+
result := ProbeHTTP(
604+
testCTX,
605+
ts.URL+tc.target,
606+
config.Module{
607+
Timeout: time.Second,
608+
HTTP: config.HTTPProbe{
609+
IPProtocolFallback: true,
610+
BodySizeLimit: max,
611+
HTTPClientConfig: pconfig.DefaultHTTPClientConfig,
612+
Compression: tc.compression,
613+
},
614+
},
615+
registry,
616+
log.NewNopLogger(),
617+
)
618+
619+
switch {
620+
case tc.expectFailure && result:
621+
t.Fatalf("test passed unexpectedly")
622+
case !tc.expectFailure && !result:
623+
t.Fatalf("test failed unexpectedly")
624+
}
625+
626+
mfs, err := registry.Gather()
627+
if err != nil {
628+
t.Fatal(err)
629+
}
630+
631+
checkRegistryResults(tc.expectedMetrics, mfs, t)
632+
})
633+
}
634+
}
635+
516636
func TestRedirectFollowed(t *testing.T) {
517637
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
518638
if r.URL.Path == "/" {

0 commit comments

Comments
 (0)