Skip to content

Commit f00ef28

Browse files
makasimbwplotka
andcommitted
Prevent OOM from malformed snappy payloads by validating decoded length
A specially crafted remote-write request can declare an extremely large decoded length while providing only a small encoded payload. Prometheus allocates memory based on the declared decoded size, so a single request can trigger an allocation of ~2.5 GB. A few such requests are enough to crash the process with OOM. Here's the script that can be used to reproduce the issue: echo "97eab4890a170a085f5f6e616d655f5f120b746573745f6d6574726963121009000000000000f03f10d48fc9b2a333" \ | xxd -r -p \ | curl -X POST \ "http://127.0.0.1:9090/api/v1/write" \ -H "Content-Type: application/x-protobuf" \ -H "Content-Encoding: snappy" \ -H "X-Prometheus-Remote-Write-Version: 0.1.0" \ --data-binary @- This change adds a hard limit: the requested decoded length must be less than 32 MB. Requests exceeding the limit are rejected with HTTP 400 before any allocation occurs. Co-authored-by: Bartlomiej Plotka <[email protected]> Signed-off-by: Max Kotliar <[email protected]>
1 parent b3886a6 commit f00ef28

File tree

3 files changed

+115
-0
lines changed

3 files changed

+115
-0
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
## Unreleased
22

3+
4+
## Unreleased `exp` module
5+
6+
* [BUGFIX] exp/api: Reject malformed snappy payloads declaring huge decoded sizes. Enforce a 32MB decoded-size limit to prevent OOM from oversized remote-write requests. #1917.
37
## 1.23.2 / 2025-09-05
48

59
This release is made to upgrade to prometheus/common v0.66.1, which drops the dependencies github.com/grafana/regexp and go.uber.org/atomic and replaces gopkg.in/yaml.v2 with go.yaml.in/yaml/v2 (a drop-in replacement).

exp/api/remote/remote_api.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,12 @@ func WithWriteHandlerMiddlewares(middlewares ...func(http.Handler) http.Handler)
448448
}
449449
}
450450

451+
// maxDecodedSize limits the maximum allowed bytes of decompressed snappy payloads.
452+
// This protects against maliciously crafted payloads that could cause excessive memory
453+
// allocation and potentially lead to out-of-memory (OOM) conditions.
454+
// All usual payloads should be much smaller than this limit and pass without any problems.
455+
const maxDecodedSize = 32 * 1024 * 1024
456+
451457
// SnappyDecodeMiddleware returns a middleware that checks if the request body is snappy-encoded and decompresses it.
452458
// If the request body is not snappy-encoded, it returns an error.
453459
// Used by default in NewHandler.
@@ -479,6 +485,18 @@ func SnappyDecodeMiddleware(logger *slog.Logger) func(http.Handler) http.Handler
479485
return
480486
}
481487

488+
decodedSize, err := snappy.DecodedLen(bodyBytes)
489+
if err != nil {
490+
logger.Error("Error snappy decoding request body length", "err", err)
491+
http.Error(w, err.Error(), http.StatusBadRequest)
492+
return
493+
}
494+
if decodedSize > maxDecodedSize {
495+
logger.Error("Snappy decoded size exceeds the limit", "sizeBytes", decodedSize, "limitBytes", maxDecodedSize)
496+
http.Error(w, fmt.Sprintf("decoded size exceeds the %v bytes limit", maxDecodedSize), http.StatusBadRequest)
497+
return
498+
}
499+
482500
decompressed, err := snappy.Decode(nil, bodyBytes)
483501
if err != nil {
484502
// TODO(bwplotka): Add more context to responded error?

exp/api/remote/remote_api_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
package remote
1515

1616
import (
17+
"bytes"
1718
"context"
19+
"encoding/binary"
1820
"errors"
1921
"io"
2022
"log/slog"
@@ -26,6 +28,7 @@ import (
2628

2729
"github.com/google/go-cmp/cmp"
2830
"github.com/google/go-cmp/cmp/cmpopts"
31+
"github.com/klauspost/compress/snappy"
2932
"github.com/prometheus/common/model"
3033
"google.golang.org/protobuf/proto"
3134
"google.golang.org/protobuf/testing/protocmp"
@@ -295,3 +298,93 @@ func TestRemoteAPI_Write_WithHandler(t *testing.T) {
295298
}
296299
})
297300
}
301+
302+
func TestSnappyDecodeMiddleware(t *testing.T) {
303+
tLogger := slog.Default()
304+
305+
var gotRequest *writev2.Request
306+
successHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
307+
b, err := io.ReadAll(r.Body)
308+
if err != nil {
309+
t.Fatalf("failed to read body: %v", err)
310+
}
311+
gotRequest = &writev2.Request{}
312+
if err := proto.Unmarshal(b, gotRequest); err != nil {
313+
t.Fatalf("failed to unmarshal request: %v", err)
314+
}
315+
316+
w.WriteHeader(http.StatusOK)
317+
})
318+
319+
mw := SnappyDecodeMiddleware(tLogger)(successHandler)
320+
321+
t.Run("success", func(t *testing.T) {
322+
// populated by successHandler handler
323+
gotRequest = nil
324+
expReq := testV2()
325+
326+
serializedExpReq, err := proto.Marshal(expReq)
327+
if err != nil {
328+
t.Fatal(err)
329+
}
330+
compressedExpReq := snappy.Encode(nil, serializedExpReq)
331+
332+
// Create HTTP request
333+
r := httptest.NewRequest("POST", "/api/v1/write", bytes.NewReader(compressedExpReq))
334+
r.Header.Set("Content-Encoding", "snappy")
335+
rw := httptest.NewRecorder()
336+
337+
mw.ServeHTTP(rw, r)
338+
339+
if rw.Code != http.StatusOK {
340+
t.Fatalf("expected status 200, got %d: %s", rw.Code, rw.Body.String())
341+
}
342+
if diff := cmp.Diff(expReq, gotRequest, protocmp.Transform()); diff != "" {
343+
t.Fatalf("unexpected request after decoding: %s", diff)
344+
}
345+
})
346+
347+
t.Run("crafted_decode_len", func(t *testing.T) {
348+
// Snappy format: varint(decoded_len) + compressed_data
349+
// For a claimed size of 33MB (exceeds 32MB limit), we need varint encoding
350+
dst := make([]byte, binary.MaxVarintLen64)
351+
binary.PutUvarint(dst, uint64(33*1024*1024))
352+
// Add some dummy compressed data. Doesn't need to be valid.
353+
dst = append(dst, []byte{0x00, 0x01, 0x02}...)
354+
355+
r := httptest.NewRequest("POST", "/api/v1/write", bytes.NewReader(dst))
356+
r.Header.Set("Content-Encoding", "snappy")
357+
rw := httptest.NewRecorder()
358+
359+
mw.ServeHTTP(rw, r)
360+
361+
if rw.Code != http.StatusBadRequest {
362+
t.Fatalf("expected status 400, got %d", rw.Code)
363+
}
364+
365+
body := rw.Body.String()
366+
if !strings.Contains(body, "decoded size exceeds the") {
367+
t.Fatalf("expected decoded size exceeds error, got: %s", body)
368+
}
369+
})
370+
371+
t.Run("invalid", func(t *testing.T) {
372+
// Completely invalid snappy data
373+
invalidData := []byte{0xff, 0xff, 0xff, 0xff}
374+
375+
r := httptest.NewRequest("POST", "/api/v1/write", bytes.NewReader(invalidData))
376+
r.Header.Set("Content-Encoding", "snappy")
377+
rw := httptest.NewRecorder()
378+
379+
mw.ServeHTTP(rw, r)
380+
381+
if rw.Code != http.StatusBadRequest {
382+
t.Fatalf("expected status 400, got %d", rw.Code)
383+
}
384+
385+
body := rw.Body.String()
386+
if !strings.Contains(body, "corrupt input") {
387+
t.Fatalf("expected error message about corrupt input, got: %s", body)
388+
}
389+
})
390+
}

0 commit comments

Comments
 (0)