diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 900ff52e52..4bc13aff92 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -19,7 +19,7 @@ eth/tracers/ @s1na
ethclient/ @fjl
ethdb/ @rjl493456442
event/ @fjl
-trie/ @rjl493456442
+trie/ @rjl493456442 @gballet
triedb/ @rjl493456442
core/tracing/ @s1na
graphql/ @s1na
diff --git a/core/rawdb/database.go b/core/rawdb/database.go
index 25cd20d164..6a1b717066 100644
--- a/core/rawdb/database.go
+++ b/core/rawdb/database.go
@@ -32,7 +32,6 @@ import (
"github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/ethdb/memorydb"
"github.com/ethereum/go-ethereum/log"
- "github.com/olekukonko/tablewriter"
)
var ErrDeleteRangeInterrupted = errors.New("safe delete range operation interrupted")
@@ -582,7 +581,7 @@ func InspectDatabase(db ethdb.Database, keyPrefix, keyStart []byte) error {
}
total += ancient.size()
}
- table := tablewriter.NewWriter(os.Stdout)
+ table := newTableWriter(os.Stdout)
table.SetHeader([]string{"Database", "Category", "Size", "Items"})
table.SetFooter([]string{"", "Total", total.String(), " "})
table.AppendBulk(stats)
diff --git a/core/rawdb/database_tablewriter_tinygo.go b/core/rawdb/database_tablewriter_tinygo.go
new file mode 100644
index 0000000000..2f8e456fd5
--- /dev/null
+++ b/core/rawdb/database_tablewriter_tinygo.go
@@ -0,0 +1,208 @@
+// Copyright 2025 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// The go-ethereum library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see .
+
+// TODO: naive stub implementation for tablewriter
+
+//go:build tinygo
+// +build tinygo
+
+package rawdb
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+)
+
+const (
+ cellPadding = 1 // Number of spaces on each side of cell content
+ totalPadding = 2 * cellPadding // Total padding per cell. Its two because we pad equally on both sides
+)
+
+type Table struct {
+ out io.Writer
+ headers []string
+ footer []string
+ rows [][]string
+}
+
+func newTableWriter(w io.Writer) *Table {
+ return &Table{out: w}
+}
+
+// SetHeader sets the header row for the table. Headers define the column names
+// and determine the number of columns for the entire table.
+//
+// All data rows and footer must have the same number of columns as the headers.
+//
+// Note: Headers are required - tables without headers will fail validation.
+func (t *Table) SetHeader(headers []string) {
+ t.headers = headers
+}
+
+// SetFooter sets an optional footer row for the table.
+//
+// The footer must have the same number of columns as the headers, or validation will fail.
+func (t *Table) SetFooter(footer []string) {
+ t.footer = footer
+}
+
+// AppendBulk sets all data rows for the table at once, replacing any existing rows.
+//
+// Each row must have the same number of columns as the headers, or validation
+// will fail during Render().
+func (t *Table) AppendBulk(rows [][]string) {
+ t.rows = rows
+}
+
+// Render outputs the complete table to the configured writer. The table is rendered
+// with headers, data rows, and optional footer.
+//
+// If validation fails, an error message is written to the output and rendering stops.
+func (t *Table) Render() {
+ if err := t.render(); err != nil {
+ fmt.Fprintf(t.out, "Error: %v\n", err)
+ return
+ }
+}
+
+func (t *Table) render() error {
+ if err := t.validateColumnCount(); err != nil {
+ return err
+ }
+
+ widths := t.calculateColumnWidths()
+ rowSeparator := t.buildRowSeparator(widths)
+
+ if len(t.headers) > 0 {
+ t.printRow(t.headers, widths)
+ fmt.Fprintln(t.out, rowSeparator)
+ }
+
+ for _, row := range t.rows {
+ t.printRow(row, widths)
+ }
+
+ if len(t.footer) > 0 {
+ fmt.Fprintln(t.out, rowSeparator)
+ t.printRow(t.footer, widths)
+ }
+
+ return nil
+}
+
+// validateColumnCount checks that all rows and footer match the header column count
+func (t *Table) validateColumnCount() error {
+ if len(t.headers) == 0 {
+ return errors.New("table must have headers")
+ }
+
+ expectedCols := len(t.headers)
+
+ // Check all rows have same column count as headers
+ for i, row := range t.rows {
+ if len(row) != expectedCols {
+ return fmt.Errorf("row %d has %d columns, expected %d", i, len(row), expectedCols)
+ }
+ }
+
+ // Check footer has same column count as headers (if present)
+ footerPresent := len(t.footer) > 0
+ if footerPresent && len(t.footer) != expectedCols {
+ return fmt.Errorf("footer has %d columns, expected %d", len(t.footer), expectedCols)
+ }
+
+ return nil
+}
+
+// calculateColumnWidths determines the minimum width needed for each column.
+//
+// This is done by finding the longest content in each column across headers, rows, and footer.
+//
+// Returns an int slice where widths[i] is the display width for column i (including padding).
+func (t *Table) calculateColumnWidths() []int {
+ // Headers define the number of columns
+ cols := len(t.headers)
+ if cols == 0 {
+ return nil
+ }
+
+ // Track maximum content width for each column (before padding)
+ widths := make([]int, cols)
+
+ // Start with header widths
+ for i, h := range t.headers {
+ widths[i] = len(h)
+ }
+
+ // Find max width needed for data cells in each column
+ for _, row := range t.rows {
+ for i, cell := range row {
+ widths[i] = max(widths[i], len(cell))
+ }
+ }
+
+ // Find max width needed for footer in each column
+ for i, f := range t.footer {
+ widths[i] = max(widths[i], len(f))
+ }
+
+ for i := range widths {
+ widths[i] += totalPadding
+ }
+
+ return widths
+}
+
+// buildRowSeparator creates a horizontal line to separate table rows.
+//
+// It generates a string with dashes (-) for each column width, joined by plus signs (+).
+//
+// Example output: "----------+--------+-----------"
+func (t *Table) buildRowSeparator(widths []int) string {
+ parts := make([]string, len(widths))
+ for i, w := range widths {
+ parts[i] = strings.Repeat("-", w)
+ }
+ return strings.Join(parts, "+")
+}
+
+// printRow outputs a single row to the table writer.
+//
+// Each cell is padded with spaces and separated by pipe characters (|).
+//
+// Example output: " Database | Size | Items "
+func (t *Table) printRow(row []string, widths []int) {
+ for i, cell := range row {
+ if i > 0 {
+ fmt.Fprint(t.out, "|")
+ }
+
+ // Calculate centering pad without padding
+ contentWidth := widths[i] - totalPadding
+ cellLen := len(cell)
+ leftPadCentering := (contentWidth - cellLen) / 2
+ rightPadCentering := contentWidth - cellLen - leftPadCentering
+
+ // Build padded cell with centering
+ leftPadding := strings.Repeat(" ", cellPadding+leftPadCentering)
+ rightPadding := strings.Repeat(" ", cellPadding+rightPadCentering)
+
+ fmt.Fprintf(t.out, "%s%s%s", leftPadding, cell, rightPadding)
+ }
+ fmt.Fprintln(t.out)
+}
diff --git a/core/rawdb/database_tablewriter_tinygo_test.go b/core/rawdb/database_tablewriter_tinygo_test.go
new file mode 100644
index 0000000000..3bcf93832b
--- /dev/null
+++ b/core/rawdb/database_tablewriter_tinygo_test.go
@@ -0,0 +1,124 @@
+// Copyright 2025 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// The go-ethereum library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see .
+
+//go:build tinygo
+// +build tinygo
+
+package rawdb
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func TestTableWriterTinyGo(t *testing.T) {
+ var buf bytes.Buffer
+ table := newTableWriter(&buf)
+
+ headers := []string{"Database", "Size", "Items", "Status"}
+ rows := [][]string{
+ {"chaindata", "2.5 GB", "1,234,567", "Active"},
+ {"state", "890 MB", "456,789", "Active"},
+ {"ancient", "15.2 GB", "2,345,678", "Readonly"},
+ {"logs", "120 MB", "89,012", "Active"},
+ }
+ footer := []string{"Total", "18.71 GB", "4,125,046", "-"}
+
+ table.SetHeader(headers)
+ table.AppendBulk(rows)
+ table.SetFooter(footer)
+ table.Render()
+
+ output := buf.String()
+ t.Logf("Table output using custom stub implementation:\n%s", output)
+}
+
+func TestTableWriterValidationErrors(t *testing.T) {
+ // Test missing headers
+ t.Run("MissingHeaders", func(t *testing.T) {
+ var buf bytes.Buffer
+ table := newTableWriter(&buf)
+
+ rows := [][]string{{"x", "y", "z"}}
+
+ table.AppendBulk(rows)
+ table.Render()
+
+ output := buf.String()
+ if !strings.Contains(output, "table must have headers") {
+ t.Errorf("Expected error for missing headers, got: %s", output)
+ }
+ })
+
+ t.Run("NotEnoughRowColumns", func(t *testing.T) {
+ var buf bytes.Buffer
+ table := newTableWriter(&buf)
+
+ headers := []string{"A", "B", "C"}
+ badRows := [][]string{
+ {"x", "y"}, // Missing column
+ }
+
+ table.SetHeader(headers)
+ table.AppendBulk(badRows)
+ table.Render()
+
+ output := buf.String()
+ if !strings.Contains(output, "row 0 has 2 columns, expected 3") {
+ t.Errorf("Expected validation error for row 0, got: %s", output)
+ }
+ })
+
+ t.Run("TooManyRowColumns", func(t *testing.T) {
+ var buf bytes.Buffer
+ table := newTableWriter(&buf)
+
+ headers := []string{"A", "B", "C"}
+ badRows := [][]string{
+ {"p", "q", "r", "s"}, // Extra column
+ }
+
+ table.SetHeader(headers)
+ table.AppendBulk(badRows)
+ table.Render()
+
+ output := buf.String()
+ if !strings.Contains(output, "row 0 has 4 columns, expected 3") {
+ t.Errorf("Expected validation error for row 0, got: %s", output)
+ }
+ })
+
+ // Test mismatched footer columns
+ t.Run("MismatchedFooterColumns", func(t *testing.T) {
+ var buf bytes.Buffer
+ table := newTableWriter(&buf)
+
+ headers := []string{"A", "B", "C"}
+ rows := [][]string{{"x", "y", "z"}}
+ footer := []string{"total", "sum"} // Missing column
+
+ table.SetHeader(headers)
+ table.AppendBulk(rows)
+ table.SetFooter(footer)
+ table.Render()
+
+ output := buf.String()
+ if !strings.Contains(output, "footer has 2 columns, expected 3") {
+ t.Errorf("Expected validation error for footer, got: %s", output)
+ }
+ })
+}
diff --git a/core/rawdb/database_tablewriter_unix.go b/core/rawdb/database_tablewriter_unix.go
new file mode 100644
index 0000000000..8bec5396e8
--- /dev/null
+++ b/core/rawdb/database_tablewriter_unix.go
@@ -0,0 +1,33 @@
+// Copyright 2025 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// The go-ethereum library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see .
+
+//go:build !tinygo
+// +build !tinygo
+
+package rawdb
+
+import (
+ "io"
+
+ "github.com/olekukonko/tablewriter"
+)
+
+// Re-export the real tablewriter types and functions
+type Table = tablewriter.Table
+
+func newTableWriter(w io.Writer) *Table {
+ return tablewriter.NewWriter(w)
+}
diff --git a/core/rawdb/schema.go b/core/rawdb/schema.go
index 72f9bd34ec..3588063468 100644
--- a/core/rawdb/schema.go
+++ b/core/rawdb/schema.go
@@ -384,21 +384,48 @@ func accountHistoryIndexKey(addressHash common.Hash) []byte {
// storageHistoryIndexKey = StateHistoryStorageMetadataPrefix + addressHash + storageHash
func storageHistoryIndexKey(addressHash common.Hash, storageHash common.Hash) []byte {
- return append(append(StateHistoryStorageMetadataPrefix, addressHash.Bytes()...), storageHash.Bytes()...)
+ totalLen := len(StateHistoryStorageMetadataPrefix) + 2*common.HashLength
+ out := make([]byte, totalLen)
+
+ off := 0
+ off += copy(out[off:], StateHistoryStorageMetadataPrefix)
+ off += copy(out[off:], addressHash.Bytes())
+ copy(out[off:], storageHash.Bytes())
+
+ return out
}
// accountHistoryIndexBlockKey = StateHistoryAccountBlockPrefix + addressHash + blockID
func accountHistoryIndexBlockKey(addressHash common.Hash, blockID uint32) []byte {
- var buf [4]byte
- binary.BigEndian.PutUint32(buf[:], blockID)
- return append(append(StateHistoryAccountBlockPrefix, addressHash.Bytes()...), buf[:]...)
+ var buf4 [4]byte
+ binary.BigEndian.PutUint32(buf4[:], blockID)
+
+ totalLen := len(StateHistoryAccountBlockPrefix) + common.HashLength + 4
+ out := make([]byte, totalLen)
+
+ off := 0
+ off += copy(out[off:], StateHistoryAccountBlockPrefix)
+ off += copy(out[off:], addressHash.Bytes())
+ copy(out[off:], buf4[:])
+
+ return out
}
// storageHistoryIndexBlockKey = StateHistoryStorageBlockPrefix + addressHash + storageHash + blockID
func storageHistoryIndexBlockKey(addressHash common.Hash, storageHash common.Hash, blockID uint32) []byte {
- var buf [4]byte
- binary.BigEndian.PutUint32(buf[:], blockID)
- return append(append(append(StateHistoryStorageBlockPrefix, addressHash.Bytes()...), storageHash.Bytes()...), buf[:]...)
+ var buf4 [4]byte
+ binary.BigEndian.PutUint32(buf4[:], blockID)
+
+ totalLen := len(StateHistoryStorageBlockPrefix) + 2*common.HashLength + 4
+ out := make([]byte, totalLen)
+
+ off := 0
+ off += copy(out[off:], StateHistoryStorageBlockPrefix)
+ off += copy(out[off:], addressHash.Bytes())
+ off += copy(out[off:], storageHash.Bytes())
+ copy(out[off:], buf4[:])
+
+ return out
}
// transitionStateKey = transitionStatusKey + hash
diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go
index 038328d9ba..7f6dd40907 100644
--- a/eth/catalyst/api.go
+++ b/eth/catalyst/api.go
@@ -20,10 +20,12 @@ package catalyst
import (
"errors"
"fmt"
+ "reflect"
"strconv"
"sync"
"sync/atomic"
"time"
+ "unicode"
"github.com/ethereum/go-ethereum/beacon/engine"
"github.com/ethereum/go-ethereum/common"
@@ -80,41 +82,6 @@ const (
beaconUpdateWarnFrequency = 5 * time.Minute
)
-// All methods provided over the engine endpoint.
-var caps = []string{
- "engine_forkchoiceUpdatedV1",
- "engine_forkchoiceUpdatedV2",
- "engine_forkchoiceUpdatedV3",
- "engine_forkchoiceUpdatedWithWitnessV1",
- "engine_forkchoiceUpdatedWithWitnessV2",
- "engine_forkchoiceUpdatedWithWitnessV3",
- "engine_exchangeTransitionConfigurationV1",
- "engine_getPayloadV1",
- "engine_getPayloadV2",
- "engine_getPayloadV3",
- "engine_getPayloadV4",
- "engine_getPayloadV5",
- "engine_getBlobsV1",
- "engine_getBlobsV2",
- "engine_newPayloadV1",
- "engine_newPayloadV2",
- "engine_newPayloadV3",
- "engine_newPayloadV4",
- "engine_newPayloadWithWitnessV1",
- "engine_newPayloadWithWitnessV2",
- "engine_newPayloadWithWitnessV3",
- "engine_newPayloadWithWitnessV4",
- "engine_executeStatelessPayloadV1",
- "engine_executeStatelessPayloadV2",
- "engine_executeStatelessPayloadV3",
- "engine_executeStatelessPayloadV4",
- "engine_getPayloadBodiesByHashV1",
- "engine_getPayloadBodiesByHashV2",
- "engine_getPayloadBodiesByRangeV1",
- "engine_getPayloadBodiesByRangeV2",
- "engine_getClientVersionV1",
-}
-
var (
// Number of blobs requested via getBlobsV2
getBlobsRequestedCounter = metrics.NewRegisteredCounter("engine/getblobs/requested", nil)
@@ -916,6 +883,15 @@ func (api *ConsensusAPI) checkFork(timestamp uint64, forks ...forks.Fork) bool {
// ExchangeCapabilities returns the current methods provided by this node.
func (api *ConsensusAPI) ExchangeCapabilities([]string) []string {
+ valueT := reflect.TypeOf(api)
+ caps := make([]string, 0, valueT.NumMethod())
+ for i := 0; i < valueT.NumMethod(); i++ {
+ name := []rune(valueT.Method(i).Name)
+ if string(name) == "ExchangeCapabilities" {
+ continue
+ }
+ caps = append(caps, "engine_"+string(unicode.ToLower(name[0]))+string(name[1:]))
+ }
return caps
}
diff --git a/eth/syncer/syncer.go b/eth/syncer/syncer.go
index 6e3918ce88..6b33ec54ba 100644
--- a/eth/syncer/syncer.go
+++ b/eth/syncer/syncer.go
@@ -89,6 +89,7 @@ func (s *Syncer) run() {
target *types.Header
ticker = time.NewTicker(time.Second * 5)
)
+ defer ticker.Stop()
for {
select {
case req := <-s.request:
diff --git a/ethclient/gethclient/gethclient.go b/ethclient/gethclient/gethclient.go
index d030878e54..54997cbf51 100644
--- a/ethclient/gethclient/gethclient.go
+++ b/ethclient/gethclient/gethclient.go
@@ -209,7 +209,7 @@ func (ec *Client) SubscribePendingTransactions(ctx context.Context, ch chan<- co
// and returns them as a JSON object.
func (ec *Client) TraceTransaction(ctx context.Context, hash common.Hash, config *tracers.TraceConfig) (any, error) {
var result any
- err := ec.c.CallContext(ctx, &result, "debug_traceTransaction", hash.Hex(), config)
+ err := ec.c.CallContext(ctx, &result, "debug_traceTransaction", hash, config)
if err != nil {
return nil, err
}
diff --git a/graphql/graphql_test.go b/graphql/graphql_test.go
index 0f6ba10b90..ca864d5fb2 100644
--- a/graphql/graphql_test.go
+++ b/graphql/graphql_test.go
@@ -430,6 +430,40 @@ func TestWithdrawals(t *testing.T) {
}
}
+// TestGraphQLMaxDepth ensures that queries exceeding the configured maximum depth
+// are rejected to prevent resource exhaustion from deeply nested operations.
+func TestGraphQLMaxDepth(t *testing.T) {
+ stack := createNode(t)
+ defer stack.Close()
+
+ h, err := newHandler(stack, nil, nil, []string{}, []string{})
+ if err != nil {
+ t.Fatalf("could not create graphql service: %v", err)
+ }
+
+ var b strings.Builder
+ for i := 0; i < maxQueryDepth+1; i++ {
+ b.WriteString("ommers{")
+ }
+ b.WriteString("number")
+ for i := 0; i < maxQueryDepth+1; i++ {
+ b.WriteString("}")
+ }
+ query := fmt.Sprintf("{block{%s}}", b.String())
+
+ res := h.Schema.Exec(context.Background(), query, "", nil)
+ var found bool
+ for _, err := range res.Errors {
+ if err.Rule == "MaxDepthExceeded" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected max depth exceeded error, got %v", res.Errors)
+ }
+}
+
func createNode(t *testing.T) *node.Node {
stack, err := node.New(&node.Config{
HTTPHost: "127.0.0.1",
diff --git a/graphql/service.go b/graphql/service.go
index 584165bdb8..9381a51da6 100644
--- a/graphql/service.go
+++ b/graphql/service.go
@@ -32,6 +32,9 @@ import (
gqlErrors "github.com/graph-gophers/graphql-go/errors"
)
+// maxQueryDepth limits the maximum field nesting depth allowed in GraphQL queries.
+const maxQueryDepth = 20
+
type handler struct {
Schema *graphql.Schema
}
@@ -116,7 +119,7 @@ func New(stack *node.Node, backend ethapi.Backend, filterSystem *filters.FilterS
func newHandler(stack *node.Node, backend ethapi.Backend, filterSystem *filters.FilterSystem, cors, vhosts []string) (*handler, error) {
q := Resolver{backend, filterSystem}
- s, err := graphql.ParseSchema(schema, &q)
+ s, err := graphql.ParseSchema(schema, &q, graphql.MaxDepth(maxQueryDepth))
if err != nil {
return nil, err
}
diff --git a/p2p/config.go b/p2p/config.go
index 68a9c0bb5f..17607a1f88 100644
--- a/p2p/config.go
+++ b/p2p/config.go
@@ -35,8 +35,7 @@ type Config struct {
// This field must be set to a valid secp256k1 private key.
PrivateKey *ecdsa.PrivateKey `toml:"-"`
- // MaxPeers is the maximum number of peers that can be
- // connected. It must be greater than zero.
+ // MaxPeers is the maximum number of peers that can be connected.
MaxPeers int
// MaxPendingPeers is the maximum number of peers that can be pending in the
diff --git a/rpc/server.go b/rpc/server.go
index 42b59f8f6f..599e31fb41 100644
--- a/rpc/server.go
+++ b/rpc/server.go
@@ -54,6 +54,7 @@ type Server struct {
batchItemLimit int
batchResponseLimit int
httpBodyLimit int
+ wsReadLimit int64
}
// NewServer creates a new server instance with no registered handlers.
@@ -62,6 +63,7 @@ func NewServer() *Server {
idgen: randomIDGenerator(),
codecs: make(map[ServerCodec]struct{}),
httpBodyLimit: defaultBodyLimit,
+ wsReadLimit: wsDefaultReadLimit,
}
server.run.Store(true)
// Register the default service providing meta information about the RPC service such
@@ -89,6 +91,13 @@ func (s *Server) SetHTTPBodyLimit(limit int) {
s.httpBodyLimit = limit
}
+// SetWebsocketReadLimit sets the limit for max message size for Websocket requests.
+//
+// This method should be called before processing any requests via Websocket server.
+func (s *Server) SetWebsocketReadLimit(limit int64) {
+ s.wsReadLimit = limit
+}
+
// RegisterName creates a service for the given receiver type under the given name. When no
// methods on the given receiver match the criteria to be either an RPC method or a
// subscription an error is returned. Otherwise a new service is created and added to the
diff --git a/rpc/server_test.go b/rpc/server_test.go
index 9ee545d81a..a38a64b080 100644
--- a/rpc/server_test.go
+++ b/rpc/server_test.go
@@ -19,13 +19,18 @@ package rpc
import (
"bufio"
"bytes"
+ "context"
+ "errors"
"io"
"net"
+ "net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
+
+ "github.com/gorilla/websocket"
)
func TestServerRegisterName(t *testing.T) {
@@ -202,3 +207,86 @@ func TestServerBatchResponseSizeLimit(t *testing.T) {
}
}
}
+
+func TestServerWebsocketReadLimit(t *testing.T) {
+ t.Parallel()
+
+ // Test different read limits
+ testCases := []struct {
+ name string
+ readLimit int64
+ testSize int
+ shouldFail bool
+ }{
+ {
+ name: "limit with small request - should succeed",
+ readLimit: 4096, // generous limit to comfortably allow JSON overhead
+ testSize: 256, // reasonably small payload
+ shouldFail: false,
+ },
+ {
+ name: "limit with large request - should fail",
+ readLimit: 256, // tight limit to trigger server-side read limit
+ testSize: 1024, // payload that will exceed the limit including JSON overhead
+ shouldFail: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Create server and set read limits
+ srv := newTestServer()
+ srv.SetWebsocketReadLimit(tc.readLimit)
+ defer srv.Stop()
+
+ // Start HTTP server with WebSocket handler
+ httpsrv := httptest.NewServer(srv.WebsocketHandler([]string{"*"}))
+ defer httpsrv.Close()
+
+ wsURL := "ws:" + strings.TrimPrefix(httpsrv.URL, "http:")
+
+ // Connect WebSocket client
+ client, err := DialOptions(context.Background(), wsURL)
+ if err != nil {
+ t.Fatalf("can't dial: %v", err)
+ }
+ defer client.Close()
+
+ // Create large request data - this is what will be limited
+ largeString := strings.Repeat("A", tc.testSize)
+
+ // Send the large string as a parameter in the request
+ var result echoResult
+ err = client.Call(&result, "test_echo", largeString, 42, &echoArgs{S: "test"})
+
+ if tc.shouldFail {
+ // Expecting an error due to read limit exceeded
+ if err == nil {
+ t.Fatalf("expected error for request size %d with limit %d, but got none", tc.testSize, tc.readLimit)
+ }
+ // Be tolerant about the exact error surfaced by gorilla/websocket.
+ // Prefer a CloseError with code 1009, but accept ErrReadLimit or an error string containing 1009/message too big.
+ var cerr *websocket.CloseError
+ if errors.As(err, &cerr) {
+ if cerr.Code != websocket.CloseMessageTooBig {
+ t.Fatalf("unexpected websocket close code: have %d want %d (err=%v)", cerr.Code, websocket.CloseMessageTooBig, err)
+ }
+ } else if !errors.Is(err, websocket.ErrReadLimit) &&
+ !strings.Contains(strings.ToLower(err.Error()), "1009") &&
+ !strings.Contains(strings.ToLower(err.Error()), "message too big") {
+ // Not the error we expect from exceeding the message size limit.
+ t.Fatalf("unexpected error for read limit violation: %v", err)
+ }
+ } else {
+ // Expecting success
+ if err != nil {
+ t.Fatalf("unexpected error for request size %d with limit %d: %v", tc.testSize, tc.readLimit, err)
+ }
+ // Verify the response is correct - the echo should return our string
+ if result.String != largeString {
+ t.Fatalf("expected echo result to match input")
+ }
+ }
+ })
+ }
+}
diff --git a/rpc/websocket.go b/rpc/websocket.go
index 9f67caf859..543ff617ba 100644
--- a/rpc/websocket.go
+++ b/rpc/websocket.go
@@ -60,7 +60,7 @@ func (s *Server) WebsocketHandler(allowedOrigins []string) http.Handler {
log.Debug("WebSocket upgrade failed", "err", err)
return
}
- codec := newWebsocketCodec(conn, r.Host, r.Header, wsDefaultReadLimit)
+ codec := newWebsocketCodec(conn, r.Host, r.Header, s.wsReadLimit)
s.ServeCodec(codec, 0)
})
}