From 6ee98f2533d4a580adcea0cb4b93652e473917d6 Mon Sep 17 00:00:00 2001 From: corey Date: Mon, 10 Aug 2026 22:57:23 +0800 Subject: [PATCH 1/6] fix(token-price-oracle): bound HTTP price response body size getJSONWithHeaders read price responses with an unbounded io.ReadAll, so a compromised or misbehaving CEX/Hermes endpoint could stream an arbitrarily large body and exhaust memory. Cap the read at 1 MiB via io.LimitReader (ticker and Hermes latest-price payloads are a few KB), rejecting anything larger. Covers the Binance, OKX and Pyth paths that share this helper. Co-authored-by: Cursor --- token-price-oracle/client/cex_feed.go | 11 +++++++- token-price-oracle/client/cex_feed_test.go | 32 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/token-price-oracle/client/cex_feed.go b/token-price-oracle/client/cex_feed.go index a2102d946..4848ec43b 100644 --- a/token-price-oracle/client/cex_feed.go +++ b/token-price-oracle/client/cex_feed.go @@ -22,6 +22,12 @@ const ( okxTickerPath = "/api/v5/market/ticker" ) +// maxResponseBodyBytes caps how much of an HTTP price response is read into memory. +// Ticker and Hermes latest-price payloads are a few KB at most; the cap only exists +// so a compromised or misbehaving endpoint cannot stream an unbounded body and +// exhaust memory. +const maxResponseBodyBytes = 1 << 20 // 1 MiB + type cexPriceFetcher func(ctx context.Context, httpClient *http.Client, baseURL string, symbol string) (*big.Float, error) // CEXPriceFeed fetches token prices from a centralized exchange REST API. @@ -243,10 +249,13 @@ func getJSONWithHeaders(ctx context.Context, httpClient *http.Client, requestURL } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes+1)) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } + if int64(len(body)) > maxResponseBodyBytes { + return nil, fmt.Errorf("response body exceeds %d byte limit", maxResponseBodyBytes) + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("HTTP status %d: %s", resp.StatusCode, string(body)) } diff --git a/token-price-oracle/client/cex_feed_test.go b/token-price-oracle/client/cex_feed_test.go index f70028d81..ad41a9c33 100644 --- a/token-price-oracle/client/cex_feed_test.go +++ b/token-price-oracle/client/cex_feed_test.go @@ -100,6 +100,38 @@ func TestFetchOKXPrice(t *testing.T) { } } +func TestGetJSONRejectsOversizedBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Stream more than the cap so a compromised/misbehaving endpoint cannot + // force an unbounded allocation. + oversized := make([]byte, maxResponseBodyBytes+1) + w.Write(oversized) + })) + defer server.Close() + + if _, err := getJSON(context.Background(), server.Client(), server.URL); err == nil { + t.Fatal("getJSON accepted an oversized response body, want error") + } +} + +func TestGetJSONAcceptsBodyAtLimit(t *testing.T) { + payload := `{"symbol":"BTCUSDT","price":"64385.12"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(payload)) + })) + defer server.Close() + + body, err := getJSON(context.Background(), server.Client(), server.URL) + if err != nil { + t.Fatal(err) + } + if string(body) != payload { + t.Fatalf("body = %q, want %q", string(body), payload) + } +} + func TestParseFixedStablecoinPrice(t *testing.T) { price, err := parseFixedStablecoinPrice("$1.0") if err != nil { From 9b24dbc7e2b11764b6693e3c1073ca2b54717886 Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 11 Aug 2026 10:31:58 +0800 Subject: [PATCH 2/6] fix(common): compute RLP full-tx buffer length in uint64 extractInnerTxFullBytes sized the full-tx buffer with 1+uint32(sizeByteLen)+size, which wraps when the declared RLP size is near MaxUint32 (e.g. a 0xffffffff length prefix), producing a buffer shorter than the slice copies below and panicking. The remaining-length guard added in #1028 already bounds size to the available input on the batch-decode path, so this only wraps for an out-of-band reader; computing the length in uint64 removes the panic unconditionally as defense in depth. Co-authored-by: Cursor --- common/batch/blob.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/common/batch/blob.go b/common/batch/blob.go index 9ecdfd27b..44195395f 100644 --- a/common/batch/blob.go +++ b/common/batch/blob.go @@ -246,7 +246,12 @@ func extractInnerTxFullBytes(firstByte byte, reader io.Reader) ([]byte, error) { if err := binary.Read(reader, binary.BigEndian, txRaw); err != nil { return nil, err } - fullTxBytes := make([]byte, 1+uint32(sizeByteLen)+size) + // Size the buffer in uint64: 1+uint32(sizeByteLen)+size wraps when size is + // near MaxUint32 (e.g. a 0xffffffff length prefix), yielding a buffer + // shorter than the slice expressions below and panicking. The Len() guard + // above already bounds size to the remaining input, so this only wraps on an + // out-of-band reader, but computing in uint64 removes the panic outright. + fullTxBytes := make([]byte, 1+uint64(sizeByteLen)+uint64(size)) copy(fullTxBytes[:1], []byte{firstByte}) copy(fullTxBytes[1:1+sizeByteLen], sizeByte) copy(fullTxBytes[1+sizeByteLen:], txRaw) From a753e2b94dc570dd790a7d7683a9572e8ca80943 Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 11 Aug 2026 10:46:40 +0800 Subject: [PATCH 3/6] fix(common): bound RLP tx length with a mandatory remaining-length check The previous change widened the buffer arithmetic to uint64, which removes the overflow panic only because size happens to be a uint32; it leaves the real risk untouched. The declared size still drives make([]byte, size), and the remaining-length guard that should cap it was conditional -- skipped entirely when the reader does not expose Len(). Make the bound explicit and fail-closed instead: require a reader that reports its remaining length (every decode path funnels through DecodeTxsFromBytes with a *bytes.Reader) and reject a declared size that exceeds it. With size bounded by the actual remaining input, the allocation can no longer be attacker-controlled and the length arithmetic is a small in-range value by construction, independent of the width of size's type. Co-authored-by: Cursor --- common/batch/blob.go | 26 ++++++++++++++++---------- common/batch/blob_test.go | 12 ++++++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/common/batch/blob.go b/common/batch/blob.go index 44195395f..dfc6a34f5 100644 --- a/common/batch/blob.go +++ b/common/batch/blob.go @@ -235,10 +235,18 @@ func extractInnerTxFullBytes(firstByte byte, reader io.Reader) ([]byte, error) { } size := binary.BigEndian.Uint32(append(make([]byte, 4-len(sizeByte)), sizeByte...)) - // Reject malformed lengths before allocating attacker-controlled memory. - // Batch decoding currently passes a *bytes.Reader, whose remaining length - // provides a strict upper bound for the encoded transaction payload. - if lr, ok := reader.(interface{ Len() int }); ok && uint64(size) > uint64(lr.Len()) { + // Bound the declared length against the reader's remaining bytes before + // allocating: a malformed prefix can declare up to ~4.29 GiB (uint32), which + // is both an out-of-memory vector and, unbounded, an integer-overflow panic + // in the buffer sizing below. Every decode path funnels through + // DecodeTxsFromBytes, which always passes a *bytes.Reader, so a reader that + // cannot report its remaining length is an unexpected caller, not a case to + // tolerate silently -- fail closed rather than allocate an unbounded size. + lr, ok := reader.(interface{ Len() int }) + if !ok { + return nil, fmt.Errorf("cannot bound tx allocation: reader %T does not report remaining length", reader) + } + if uint64(size) > uint64(lr.Len()) { return nil, fmt.Errorf("declared tx size %d exceeds remaining %d bytes", size, lr.Len()) } @@ -246,12 +254,10 @@ func extractInnerTxFullBytes(firstByte byte, reader io.Reader) ([]byte, error) { if err := binary.Read(reader, binary.BigEndian, txRaw); err != nil { return nil, err } - // Size the buffer in uint64: 1+uint32(sizeByteLen)+size wraps when size is - // near MaxUint32 (e.g. a 0xffffffff length prefix), yielding a buffer - // shorter than the slice expressions below and panicking. The Len() guard - // above already bounds size to the remaining input, so this only wraps on an - // out-of-band reader, but computing in uint64 removes the panic outright. - fullTxBytes := make([]byte, 1+uint64(sizeByteLen)+uint64(size)) + // size is now bounded by the remaining input above, so the total length is a + // small in-range value: the addition cannot overflow and the buffer is never + // shorter than the slice copies below. + fullTxBytes := make([]byte, 1+int(sizeByteLen)+int(size)) copy(fullTxBytes[:1], []byte{firstByte}) copy(fullTxBytes[1:1+sizeByteLen], sizeByte) copy(fullTxBytes[1+sizeByteLen:], txRaw) diff --git a/common/batch/blob_test.go b/common/batch/blob_test.go index 31ada97ed..0d4bff8cd 100644 --- a/common/batch/blob_test.go +++ b/common/batch/blob_test.go @@ -2,6 +2,7 @@ package batch import ( "bytes" + "io" "testing" "github.com/stretchr/testify/require" @@ -15,6 +16,17 @@ func TestExtractInnerTxFullBytesRejectsOversizedDeclaration(t *testing.T) { require.EqualError(t, err, "declared tx size 4294967295 exceeds remaining 0 bytes") } +func TestExtractInnerTxFullBytesRejectsReaderWithoutLen(t *testing.T) { + // io.MultiReader wraps the byte reader in a type that does not expose Len(), + // so the declared size cannot be bounded and the decode must fail closed + // rather than allocate an attacker-controlled length. + reader := io.MultiReader(bytes.NewReader([]byte{0xff, 0xff, 0xff, 0xff, 1})) + + _, err := extractInnerTxFullBytes(0xfb, reader) + + require.ErrorContains(t, err, "does not report remaining length") +} + func TestExtractInnerTxFullBytesAcceptsAvailablePayload(t *testing.T) { reader := bytes.NewReader([]byte{3, 1, 2, 3}) From 99a447200acd85054bc4ddcb0f550f14ca2bb1d7 Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 11 Aug 2026 10:59:33 +0800 Subject: [PATCH 4/6] revert(common): drop RLP allocation hardening End-to-end analysis shows the uint32 length wrap at blob.go is unreachable: the compressed batch input is hard-capped by the L1 blobs-per-tx limit, the #1028 size>remaining guard already bounds the declared length to the decompressed stream, and reaching a ~4.29 GiB stream would OOM inside zstd decompression before the RLP decoder runs. The #1028 guard on main is the sufficient defense; the extra hardening addressed a condition the real data flow cannot produce, so revert it and keep this PR to the oracle fix. Co-authored-by: Cursor --- common/batch/blob.go | 21 +++++---------------- common/batch/blob_test.go | 12 ------------ 2 files changed, 5 insertions(+), 28 deletions(-) diff --git a/common/batch/blob.go b/common/batch/blob.go index dfc6a34f5..9ecdfd27b 100644 --- a/common/batch/blob.go +++ b/common/batch/blob.go @@ -235,18 +235,10 @@ func extractInnerTxFullBytes(firstByte byte, reader io.Reader) ([]byte, error) { } size := binary.BigEndian.Uint32(append(make([]byte, 4-len(sizeByte)), sizeByte...)) - // Bound the declared length against the reader's remaining bytes before - // allocating: a malformed prefix can declare up to ~4.29 GiB (uint32), which - // is both an out-of-memory vector and, unbounded, an integer-overflow panic - // in the buffer sizing below. Every decode path funnels through - // DecodeTxsFromBytes, which always passes a *bytes.Reader, so a reader that - // cannot report its remaining length is an unexpected caller, not a case to - // tolerate silently -- fail closed rather than allocate an unbounded size. - lr, ok := reader.(interface{ Len() int }) - if !ok { - return nil, fmt.Errorf("cannot bound tx allocation: reader %T does not report remaining length", reader) - } - if uint64(size) > uint64(lr.Len()) { + // Reject malformed lengths before allocating attacker-controlled memory. + // Batch decoding currently passes a *bytes.Reader, whose remaining length + // provides a strict upper bound for the encoded transaction payload. + if lr, ok := reader.(interface{ Len() int }); ok && uint64(size) > uint64(lr.Len()) { return nil, fmt.Errorf("declared tx size %d exceeds remaining %d bytes", size, lr.Len()) } @@ -254,10 +246,7 @@ func extractInnerTxFullBytes(firstByte byte, reader io.Reader) ([]byte, error) { if err := binary.Read(reader, binary.BigEndian, txRaw); err != nil { return nil, err } - // size is now bounded by the remaining input above, so the total length is a - // small in-range value: the addition cannot overflow and the buffer is never - // shorter than the slice copies below. - fullTxBytes := make([]byte, 1+int(sizeByteLen)+int(size)) + fullTxBytes := make([]byte, 1+uint32(sizeByteLen)+size) copy(fullTxBytes[:1], []byte{firstByte}) copy(fullTxBytes[1:1+sizeByteLen], sizeByte) copy(fullTxBytes[1+sizeByteLen:], txRaw) diff --git a/common/batch/blob_test.go b/common/batch/blob_test.go index 0d4bff8cd..31ada97ed 100644 --- a/common/batch/blob_test.go +++ b/common/batch/blob_test.go @@ -2,7 +2,6 @@ package batch import ( "bytes" - "io" "testing" "github.com/stretchr/testify/require" @@ -16,17 +15,6 @@ func TestExtractInnerTxFullBytesRejectsOversizedDeclaration(t *testing.T) { require.EqualError(t, err, "declared tx size 4294967295 exceeds remaining 0 bytes") } -func TestExtractInnerTxFullBytesRejectsReaderWithoutLen(t *testing.T) { - // io.MultiReader wraps the byte reader in a type that does not expose Len(), - // so the declared size cannot be bounded and the decode must fail closed - // rather than allocate an attacker-controlled length. - reader := io.MultiReader(bytes.NewReader([]byte{0xff, 0xff, 0xff, 0xff, 1})) - - _, err := extractInnerTxFullBytes(0xfb, reader) - - require.ErrorContains(t, err, "does not report remaining length") -} - func TestExtractInnerTxFullBytesAcceptsAvailablePayload(t *testing.T) { reader := bytes.NewReader([]byte{3, 1, 2, 3}) From 67ee5c28f88f8d5916072feba894dd4678c01011 Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 11 Aug 2026 11:02:21 +0800 Subject: [PATCH 5/6] fix(common): reject RLP tx length that overflows uint32 size is a uint32, so 1+sizeByteLen+size can exceed MaxUint32 and wrap to a tiny buffer length, leaving fullTxBytes shorter than the copies that follow and panicking. The #1028 remaining-bytes guard keeps this unreachable on the current decode path, but compute the length in uint64 and reject the overflow before allocating so the decoder stays safe if the size type or the upstream length bounds ever change. Co-authored-by: Cursor --- common/batch/blob.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/common/batch/blob.go b/common/batch/blob.go index 9ecdfd27b..74d977341 100644 --- a/common/batch/blob.go +++ b/common/batch/blob.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "fmt" "io" + "math" "morph-l2/common/codec/zstd" @@ -242,11 +243,22 @@ func extractInnerTxFullBytes(firstByte byte, reader io.Reader) ([]byte, error) { return nil, fmt.Errorf("declared tx size %d exceeds remaining %d bytes", size, lr.Len()) } + // Guard the reconstructed length against uint32 overflow before allocating. + // size is a uint32, so 1+sizeByteLen+size can exceed MaxUint32 and wrap to a + // tiny value, leaving fullTxBytes shorter than the copies below and + // panicking. The remaining-bytes guard above keeps this unreachable today, + // but computing in uint64 and rejecting overflow keeps it safe if the size + // type or the upstream bounds ever change. + fullLen := 1 + uint64(sizeByteLen) + uint64(size) + if fullLen > math.MaxUint32 { + return nil, fmt.Errorf("declared tx size %d overflows uint32 length prefix", size) + } + txRaw := make([]byte, size) if err := binary.Read(reader, binary.BigEndian, txRaw); err != nil { return nil, err } - fullTxBytes := make([]byte, 1+uint32(sizeByteLen)+size) + fullTxBytes := make([]byte, fullLen) copy(fullTxBytes[:1], []byte{firstByte}) copy(fullTxBytes[1:1+sizeByteLen], sizeByte) copy(fullTxBytes[1+sizeByteLen:], txRaw) From ee4dd35fd69afbae7ca60c9651c0cbc3d363ed78 Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 11 Aug 2026 11:20:56 +0800 Subject: [PATCH 6/6] fix(node): validate layer1 metrics port range fail-fast MetricsPort is a uint64 with no upper bound, so a misconfigured value (e.g. >65535) produced an invalid listen address whose metrics ListenAndServe failed silently in the background, logging only. Reject a port outside 1..65535 in SetCliContext so the misconfig fails at startup. The default (26660, matching Tendermint's instrumentation port) is always in range, so valid configs are unaffected. Co-authored-by: Cursor --- node/derivation/config.go | 7 +++++++ node/derivation/config_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/node/derivation/config.go b/node/derivation/config.go index fb010bab3..a3f194ba8 100644 --- a/node/derivation/config.go +++ b/node/derivation/config.go @@ -195,6 +195,13 @@ func (c *Config) SetCliContext(ctx *cli.Context) error { if c.VerifyMode == VerifyModeLayer1 { c.MetricsPort = ctx.GlobalUint64(flags.MetricsPort.Name) + // Reject an out-of-range port up front: MetricsPort is a uint64, so a + // misconfigured value would only surface later as an invalid listen + // address whose ListenAndServe fails silently in the background. The + // default (26660) is always in range, so a valid config never trips this. + if c.MetricsPort == 0 || c.MetricsPort > 65535 { + return fmt.Errorf("--%s must be in 1..65535, got %d", flags.MetricsPort.Name, c.MetricsPort) + } } if ctx.GlobalIsSet(flags.DerivationReorgCheckDepth.Name) { diff --git a/node/derivation/config_test.go b/node/derivation/config_test.go index 8701196a5..4148c0c96 100644 --- a/node/derivation/config_test.go +++ b/node/derivation/config_test.go @@ -90,6 +90,32 @@ func TestVerifyMode_RejectsUnknown(t *testing.T) { } } +func TestMetricsPort_AcceptsDefaultInLayer1(t *testing.T) { + cfg := DefaultConfig() + if err := cfg.SetCliContext(newVerifyModeTestContext(t, map[string]string{ + flags.DerivationVerifyMode.Name: VerifyModeLayer1, + })); err != nil { + t.Fatalf("layer1 with default metrics-port rejected: %v", err) + } + if cfg.MetricsPort != 26660 { + t.Fatalf("default metrics-port = %d, want 26660", cfg.MetricsPort) + } +} + +func TestMetricsPort_RejectsOutOfRange(t *testing.T) { + cfg := DefaultConfig() + err := cfg.SetCliContext(newVerifyModeTestContext(t, map[string]string{ + flags.DerivationVerifyMode.Name: VerifyModeLayer1, + flags.MetricsPort.Name: "70000", + })) + if err == nil { + t.Fatal("out-of-range metrics-port accepted, want error") + } + if !strings.Contains(err.Error(), flags.MetricsPort.Name) { + t.Fatalf("error should mention %q; got: %v", flags.MetricsPort.Name, err) + } +} + func TestBeaconRpcList(t *testing.T) { for _, tc := range []struct { name string @@ -126,6 +152,7 @@ func newVerifyModeTestContext(t *testing.T, values map[string]string) *cli.Conte for _, f := range []cli.Flag{ flags.LegacyValidatorMode, flags.DerivationVerifyMode, + flags.MetricsPort, flags.L1BeaconAddr, flags.L2EngineJWTSecret, } {