Skip to content

Commit 84aaa71

Browse files
committed
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. Signed-off-by: Max Kotliar <[email protected]>
1 parent 5d937cd commit 84aaa71

File tree

3 files changed

+161
-0
lines changed

3 files changed

+161
-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+
:warning: This release introduces a 32MB hard limit on decoded snappy payloads to prevent potential OOM attacks. Requests with decoded payloads exceeding this limit will be rejected with HTTP 400 status code. See #1917 for details. :warning:
4+
5+
* [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.
6+
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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,14 @@ func WithWriteHandlerMiddlewares(middlewares ...func(http.Handler) http.Handler)
448448
}
449449
}
450450

451+
// maxDecodedSize limits the maximum allowed size 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+
//
456+
// See more in https://github.com/prometheus/client_golang/pull/1917
457+
const maxDecodedSize = 32 * 1024 * 1024
458+
451459
// SnappyDecodeMiddleware returns a middleware that checks if the request body is snappy-encoded and decompresses it.
452460
// If the request body is not snappy-encoded, it returns an error.
453461
// Used by default in NewHandler.
@@ -479,6 +487,18 @@ func SnappyDecodeMiddleware(logger *slog.Logger) func(http.Handler) http.Handler
479487
return
480488
}
481489

490+
decodedSize, err := snappy.DecodedLen(bodyBytes)
491+
if err != nil {
492+
logger.Error("Error snappy decoding remote write request", "err", err)
493+
http.Error(w, err.Error(), http.StatusBadRequest)
494+
return
495+
}
496+
if decodedSize > maxDecodedSize {
497+
logger.Error("Error snappy decoding remote write request: decoded size exceeds maximum allowed", "decodedSize", decodedSize, "maxDecodedSize", maxDecodedSize)
498+
http.Error(w, "decoded size exceeds maximum allowed", http.StatusBadRequest)
499+
return
500+
}
501+
482502
decompressed, err := snappy.Decode(nil, bodyBytes)
483503
if err != nil {
484504
// TODO(bwplotka): Add more context to responded error?

exp/api/remote/remote_api_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,22 @@
1414
package remote
1515

1616
import (
17+
"bytes"
1718
"context"
19+
"encoding/binary"
1820
"errors"
1921
"io"
2022
"log/slog"
2123
"net/http"
2224
"net/http/httptest"
25+
"strconv"
2326
"strings"
2427
"testing"
2528
"time"
2629

2730
"github.com/google/go-cmp/cmp"
2831
"github.com/google/go-cmp/cmp/cmpopts"
32+
"github.com/klauspost/compress/snappy"
2933
"github.com/prometheus/common/model"
3034
"google.golang.org/protobuf/proto"
3135
"google.golang.org/protobuf/testing/protocmp"
@@ -295,3 +299,136 @@ func TestRemoteAPI_Write_WithHandler(t *testing.T) {
295299
}
296300
})
297301
}
302+
303+
func TestSnappyDecodeMiddleware(t *testing.T) {
304+
tLogger := slog.Default()
305+
306+
var actReq *writev2.Request
307+
successH := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
308+
b, err := io.ReadAll(r.Body)
309+
if err != nil {
310+
t.Fatalf("failed to read body: %v", err)
311+
}
312+
actReq = &writev2.Request{}
313+
if err := proto.Unmarshal(b, actReq); err != nil {
314+
t.Fatalf("failed to unmarshal request: %v", err)
315+
}
316+
317+
w.WriteHeader(http.StatusOK)
318+
})
319+
320+
mw := SnappyDecodeMiddleware(tLogger)(successH)
321+
322+
t.Run("success", func(t *testing.T) {
323+
// populated by successH handler
324+
actReq = nil
325+
expReq := testV2()
326+
327+
serializedExpReq, err := proto.Marshal(expReq)
328+
if err != nil {
329+
t.Fatal(err)
330+
}
331+
compressedExpReq := snappy.Encode(nil, serializedExpReq)
332+
333+
// Create HTTP request
334+
r := httptest.NewRequest("POST", "/api/v1/write", bytes.NewReader(compressedExpReq))
335+
r.Header.Set("Content-Encoding", "snappy")
336+
rw := httptest.NewRecorder()
337+
338+
mw.ServeHTTP(rw, r)
339+
340+
if rw.Code != http.StatusOK {
341+
t.Fatalf("expected status 200, got %d: %s", rw.Code, rw.Body.String())
342+
}
343+
if diff := cmp.Diff(expReq, actReq, protocmp.Transform()); diff != "" {
344+
t.Fatalf("unexpected request after decoding: %s", diff)
345+
}
346+
})
347+
348+
t.Run("too_big", func(t *testing.T) {
349+
// populated by successH handler
350+
actReq = nil
351+
352+
s := writev2.NewSymbolTable()
353+
expReq := &writev2.Request{}
354+
for i := 0; i < 1024*1024; i++ {
355+
expReq.Timeseries = append(expReq.Timeseries, &writev2.TimeSeries{
356+
Metadata: &writev2.Metadata{
357+
Type: writev2.Metadata_METRIC_TYPE_COUNTER,
358+
HelpRef: s.Symbolize("My lovely counter" + strconv.Itoa(i)),
359+
},
360+
LabelsRefs: s.SymbolizeLabels([]string{"__name__", "metric" + strconv.Itoa(i), "foo", "bar" + strconv.Itoa(i)}, nil),
361+
Samples: []*writev2.Sample{
362+
{Value: 1.1, Timestamp: 1214141},
363+
{Value: 1.5, Timestamp: 1214180},
364+
},
365+
})
366+
}
367+
368+
serializedExpReq, err := proto.Marshal(expReq)
369+
if err != nil {
370+
t.Fatal(err)
371+
}
372+
compressedExpReq := snappy.Encode(nil, serializedExpReq)
373+
374+
// Create HTTP request
375+
r := httptest.NewRequest("POST", "/api/v1/write", bytes.NewReader(compressedExpReq))
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, "decoded size exceeds maximum allowed") {
387+
t.Fatalf("expected error message about size limit, got: %s", body)
388+
}
389+
})
390+
391+
t.Run("crafted_decode_len", func(t *testing.T) {
392+
// Snappy format: varint(decoded_len) + compressed_data
393+
// For a claimed size of 33MB (exceeds 32MB limit), we need varint encoding
394+
dst := make([]byte, binary.MaxVarintLen64)
395+
binary.PutUvarint(dst, uint64(33*1024*1024))
396+
// Add some dummy compressed data. Doesn't need to be valid.
397+
dst = append(dst, []byte{0x00, 0x01, 0x02}...)
398+
399+
r := httptest.NewRequest("POST", "/api/v1/write", bytes.NewReader(dst))
400+
r.Header.Set("Content-Encoding", "snappy")
401+
rw := httptest.NewRecorder()
402+
403+
mw.ServeHTTP(rw, r)
404+
405+
if rw.Code != http.StatusBadRequest {
406+
t.Fatalf("expected status 400, got %d", rw.Code)
407+
}
408+
409+
body := rw.Body.String()
410+
if !strings.Contains(body, "decoded size exceeds maximum allowed") {
411+
t.Fatalf("expected error message about size limit, got: %s", body)
412+
}
413+
})
414+
415+
t.Run("invalid", func(t *testing.T) {
416+
// Completely invalid snappy data
417+
invalidData := []byte{0xff, 0xff, 0xff, 0xff}
418+
419+
r := httptest.NewRequest("POST", "/api/v1/write", bytes.NewReader(invalidData))
420+
r.Header.Set("Content-Encoding", "snappy")
421+
rw := httptest.NewRecorder()
422+
423+
mw.ServeHTTP(rw, r)
424+
425+
if rw.Code != http.StatusBadRequest {
426+
t.Fatalf("expected status 400, got %d", rw.Code)
427+
}
428+
429+
body := rw.Body.String()
430+
if !strings.Contains(body, "corrupt input") {
431+
t.Fatalf("expected error message about corrupt input, got: %s", body)
432+
}
433+
})
434+
}

0 commit comments

Comments
 (0)