klever-go: REST API slow-header connection exhaustion via Gin Engine.Run
Description
Summary
The Klever seednode REST API starts a Gin engine with Engine.Run(restAPIInterface). In Gin v1.9.1, Engine.Run calls Go's default http.ListenAndServe, which constructs an HTTP server without application-level ReadHeaderTimeout, ReadTimeout, or MaxHeaderBytes limits.
An unauthenticated client that can reach a REST listener bound with Klever's documented --rest-api-interface :8080 all-interface option can hold incomplete HTTP headers open indefinitely. In a local proof against the real cmd/seednode/api.Start path on v1.7.17, 120 slow-header connections caused 20/20 legitimate /log probes to fail with accept: too many open files. A fixed control using the same Gin router behind an explicit http.Server with ReadHeaderTimeout, ReadTimeout, and MaxHeaderBytes retained 0 slow connections and served 20/20 probes.
This report is distinct from the P2P advisories and from my direct-message goroutine report. This finding concerns Klever-owned HTTP REST startup code (cmd/seednode/api and network/api) using Gin Engine.Run without server-level header deadlines. It does not depend on MultiDataInterceptor, Batch.Decompress, libp2p, malformed P2P messages, or direct-message goroutine spawning.
Details
Seednode REST API, latest release v1.7.17:
cmd/seednode/api/api.go:17definesStart(restAPIInterface, marshalizer).cmd/seednode/api/api.go:18createsws := gin.Default().cmd/seednode/api/api.go:23returnsws.Run(restAPIInterface).cmd/seednode/CLI.md:23documents--rest-api-interface; it says:8080binds all interfaces andoffdisables the API.
Node REST API, latest release v1.7.17:
network/api/api.go:79createsws = gin.Default().network/api/api.go:98returnsws.Run(kleverFacade.RestAPIInterface()).cmd/node/main.go:147-150documents the same--rest-api-interfaceflag and says:8080binds all interfaces.docker/README.md:56-61anddocker/README.md:67-70publish host port8080for full-node and validator Docker examples.README.md:264-268documents that the node exposes a REST API for blockchain queries and operations.
The seednode REST API source is byte-identical across v1.7.14 through v1.7.17; the captured runtime PoC was executed on v1.7.17.
Current develop commit 10bcfd50 remains affected:
network/api/api.go:98still returnsws.Run(kleverFacade.RestAPIInterface()).cmd/seednode/api/api.go:59still returnsws.Run(restAPIInterface).
Gin v1.9.1 implements Engine.Run as:
func (engine *Engine) Run(addr ...string) (err error) {
address := resolveAddress(addr)
err = http.ListenAndServe(address, engine.Handler())
return
}
In my source sweep, I did not find a production http.Server{ReadHeaderTimeout: ...} wrapper for either REST start path. The only ReadHeaderTimeout hit I found in the repository was a test helper under network/api/websocket/routes_test.go.
PoC
GitHub Private Vulnerability Reporting does not appear to allow file attachments in this form, so I am including the reproduction command and captured output inline. I can paste the full 254-line Go test patch in a reply immediately if useful.
The test starts two local child servers:
- Vulnerable: the real
cmd/seednode/api.Startpath. - Fixed control: the same Gin router served through
http.Server{ReadHeaderTimeout: 250ms, ReadTimeout: 250ms, MaxHeaderBytes: 4096}.
Reproduction from a clean checkout:
git clone https://github.com/klever-io/klever-go
cd klever-go
git checkout v1.7.17
# Apply the PoC patch to cmd/seednode/api.
# I can provide the full patch in this advisory thread.
go test ./cmd/seednode/api -run TestPoC_SeednodeAPISlowlorisDifferential -count=1 -v -timeout 60s
Captured output on v1.7.17:
POC_RESULT mode=vulnerable slow_connections_opened=120 slow_connections_still_open=111 legitimate_probe_ok=0 legitimate_probe_fail=20
POC_RESULT mode=fixed slow_connections_opened=120 slow_connections_still_open=0 legitimate_probe_ok=20 legitimate_probe_fail=0
The vulnerable server also logs repeated accept failures:
http: Accept error: accept tcp 127.0.0.1:56415: accept: too many open files; retrying in 1s
Impact
For an externally reachable Klever REST listener, a single unauthenticated client can retain many server-side connections by never completing HTTP headers. Because the Go server has no read-header deadline, those connections persist until the client closes them or an external proxy/firewall intervenes.
The direct result is REST API unavailability for legitimate clients. The local proof demonstrates this as 0/20 legitimate /log probes succeeding while the vulnerable server is saturated, versus 20/20 succeeding with the fixed server wrapper.
I am not claiming default public internet exposure. The default bind is localhost:8080. The affected condition is a REST API listener exposed through Klever's documented all-interface bind or Docker port-publish deployment shape.
This maps to the SECURITY.md High category: "Denial of Service affecting network availability." If Klever treats externally reachable REST API unavailability as non-critical because the default bind is localhost, the conservative classification is Medium under "Performance degradation attacks" / "Non-critical DoS vectors."
All testing was local loopback only. I did not contact Klever mainnet, public testnet, hosted RPCs, explorers, or third-party production infrastructure.
Suggested fix:
Start both REST APIs through explicit http.Server values instead of Engine.Run, for example:
srv := &http.Server{
Addr: restAPIInterface,
Handler: ws.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 32 << 10,
}
return srv.ListenAndServe()
Apply the same pattern to:
cmd/seednode/api.Startnetwork/api.Start
If Klever expects deployments to expose the REST API through a reverse proxy, I still recommend setting server-level limits in the application. That keeps the binary safe when operators use the documented direct bind or Docker port-publish path.
Affected products
1- Range: v1.7.14 - v1.7.17
Patches
2785b77ccb271[KLC-2434] bound REST slow-body reads and tighten header/body caps (GHSA-w4c6-7r69-w7j9)
3 files changed · +270 −35
cmd/seednode/api/api_test.go+38 −0 modified@@ -1,7 +1,9 @@ package api import ( + "bufio" "encoding/json" + "net" "net/http" "net/http/httptest" "strings" @@ -10,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/klever-io/klever-go/core" + "github.com/klever-io/klever-go/network/api/httpserver" ) type stubMessenger struct { @@ -45,6 +48,41 @@ func setup(t *testing.T) (*gin.Engine, *stubMessenger) { return r, stub } +// TestSeednodeAPI_HardenedServerDropsSlowHeader serves the real seednode routes through +// NewHardenedServer and confirms the seednode listener (the reporter's PoC path) drops a +// slow-header connection — GHSA-w4c6-7r69-w7j9, verified end-to-end, not just wired. +func TestSeednodeAPI_HardenedServerDropsSlowHeader(t *testing.T) { + r, _ := setup(t) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + srv := httpserver.NewHardenedServer(ln.Addr().String(), r.Handler()) + srv.ReadHeaderTimeout = 200 * time.Millisecond // tighten for a fast test + go func() { _ = srv.Serve(ln) }() + defer func() { _ = srv.Close() }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + + // Partial header, never terminated: the seednode listener must drop it. + if _, err := conn.Write([]byte("GET /node/status HTTP/1.1\r\nHost: x\r\n")); err != nil { + t.Fatalf("write: %v", err) + } + + start := time.Now() + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _ = bufio.NewReader(conn).ReadString('\n') + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("slow-header connection not dropped promptly: %v", elapsed) + } +} + func TestPeers_returnsCountsAndSortedAddresses(t *testing.T) { r, _ := setup(t)
network/api/httpserver/httpserver.go+74 −33 modified@@ -1,61 +1,102 @@ -// Package httpserver builds the hardened *http.Server shared by both REST start -// paths (seednode and node). Gin's Engine.Run uses http.ListenAndServe with no -// ReadHeaderTimeout, leaving it open to slow-header connection exhaustion -// (GHSA-w4c6-7r69-w7j9); this helper hardens both listeners identically. +// Package httpserver builds the hardened *http.Server shared by both REST start paths +// (seednode and node). Gin's Engine.Run uses http.ListenAndServe with no +// ReadHeaderTimeout, leaving it open to slow-header exhaustion (GHSA-w4c6-7r69-w7j9). package httpserver import ( + "io" "net/http" + "sync" "time" ) const ( - // ReadHeaderTimeout is the slow-header (slowloris) mitigation: it bounds the - // time to send the complete header. Header-only, so it is safe for the - // long-lived websocket streams these APIs serve (cleared before hijack). + // ReadHeaderTimeout bounds header read time — the slow-header (slowloris) fix. ReadHeaderTimeout = 10 * time.Second - // IdleTimeout bounds how long an idle keep-alive connection stays open. + // BodyReadTimeout bounds body read time — the slow-body fix. + BodyReadTimeout = 30 * time.Second + + // IdleTimeout bounds idle keep-alive connections. IdleTimeout = 120 * time.Second - // MaxHeaderBytes caps request header size (Go's default, set explicitly). - MaxHeaderBytes = 1 << 20 // 1 MiB + // MaxHeaderBytes caps total request header size (Go's 1 MiB default is a no-op). + MaxHeaderBytes = 32 << 10 // 32 KiB - // MaxBodyBytes caps the request body. A single tx is bounded by the ~960 KiB - // P2P wire limit (~1.9 MiB once JSON-encoded), so 4 MiB covers the largest - // legitimate request with margin; bulk /transaction/broadcast is additionally - // bounded by an explicit tx count. Over-cap bodies are refused (400 on bind, - // 413 raw). Bounds body size, not read time — see the body read-deadline follow-up. - MaxBodyBytes = 4 << 20 // 4 MiB + // MaxBodyBytes caps the request body; over-cap bodies are refused (400/413). + MaxBodyBytes = 2 << 20 // 2 MiB ) -// NewHardenedServer returns an *http.Server for addr serving handler, hardened -// against slow-header exhaustion and oversized bodies. ReadTimeout/WriteTimeout -// are left unset on purpose: a whole-connection deadline would sever the -// long-lived websocket streams these APIs serve (/log, /subscribe). +// NewHardenedServer returns an *http.Server hardened against slow-header/slow-body +// exhaustion. ReadTimeout/WriteTimeout are left unset: a whole-connection deadline +// would sever the long-lived websocket streams these APIs serve (/log, /subscribe). func NewHardenedServer(addr string, handler http.Handler) *http.Server { return &http.Server{ Addr: addr, - Handler: limitRequestBody(handler), + Handler: guardRequestBody(handler), ReadHeaderTimeout: ReadHeaderTimeout, IdleTimeout: IdleTimeout, MaxHeaderBytes: MaxHeaderBytes, } } -// limitRequestBody caps every request body at MaxBodyBytes. -func limitRequestBody(next http.Handler) http.Handler { - return limitRequestBodyN(next, MaxBodyBytes) -} - -// limitRequestBodyN caps each request body at limit bytes. Applied ahead of gin so -// w is the *http.response MaxBytesReader needs to close an over-cap connection. -// Websocket upgrades hijack the connection and never read r.Body, so are unaffected. -func limitRequestBodyN(next http.Handler, limit int64) http.Handler { +// guardRequestBody caps body size (MaxBodyBytes) and body read time (BodyReadTimeout). +// Applied ahead of gin so w is the *http.response both mechanisms need. +func guardRequestBody(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Body != nil { - r.Body = http.MaxBytesReader(w, r.Body, limit) - } + capRequestBody(w, r, MaxBodyBytes) + applyBodyReadDeadline(w, r, BodyReadTimeout) next.ServeHTTP(w, r) }) } + +// capRequestBody limits r.Body to limit bytes. +func capRequestBody(w http.ResponseWriter, r *http.Request, limit int64) { + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, limit) + } +} + +// applyBodyReadDeadline bounds body read time at d, clearing the deadline once the body +// is read so it never cancels a slow post-read handler. Bodiless requests are skipped — +// that covers the websocket handshakes (/log, /subscribe), which are bodiless GETs, and +// leaves no Upgrade-header check for a body-bearing request to spoof. +func applyBodyReadDeadline(w http.ResponseWriter, r *http.Request, d time.Duration) { + if r.Body == nil || !requestHasBody(r) { + return + } + rc := http.NewResponseController(w) + if rc.SetReadDeadline(time.Now().Add(d)) != nil { + return + } + r.Body = &deadlineClearingBody{ReadCloser: r.Body, rc: rc} +} + +func requestHasBody(r *http.Request) bool { + return r.ContentLength != 0 // 0 = no body; -1 (chunked) and positive = body +} + +// deadlineClearingBody clears the read deadline once the body read completes (EOF or +// error) or the body is closed, so the deadline never cancels a later slow handler. +type deadlineClearingBody struct { + io.ReadCloser + rc *http.ResponseController + clearOnce sync.Once +} + +func (b *deadlineClearingBody) clear() { + b.clearOnce.Do(func() { _ = b.rc.SetReadDeadline(time.Time{}) }) +} + +func (b *deadlineClearingBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + if err != nil { + b.clear() + } + return n, err +} + +func (b *deadlineClearingBody) Close() error { + b.clear() + return b.ReadCloser.Close() +}
network/api/httpserver/httpserver_test.go+158 −2 modified@@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/gorilla/websocket" "github.com/stretchr/testify/require" ) @@ -125,13 +126,14 @@ func TestNewHardenedServer_CapsRequestBody(t *testing.T) { // Status carries the read outcome, so assertions read only the response code — // no state shared across the server/test goroutines. - handler := limitRequestBodyN(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capRequestBody(w, r, limit) if _, err := io.ReadAll(r.Body); err != nil { w.WriteHeader(http.StatusRequestEntityTooLarge) return } w.WriteHeader(http.StatusOK) - }), limit) + }) srv := httptest.NewServer(handler) defer srv.Close() @@ -148,3 +150,157 @@ func TestNewHardenedServer_CapsRequestBody(t *testing.T) { _ = resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode, "body within the cap must be accepted") } + +// TestApplyBodyReadDeadline_CutsSlowBody: a client that completes the header, promises +// a large body, then stalls is cut at ~BodyReadTimeout instead of pinning the connection. +func TestApplyBodyReadDeadline_CutsSlowBody(t *testing.T) { + t.Parallel() + + const deadline = 300 * time.Millisecond + + readDone := make(chan error, 1) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + applyBodyReadDeadline(w, r, deadline) + _, err := io.ReadAll(r.Body) + readDone <- err + }) + + srv := httptest.NewServer(handler) + defer srv.Close() + + conn, err := net.Dial("tcp", srv.Listener.Addr().String()) + require.Nil(t, err) + defer func() { _ = conn.Close() }() + + // Promise 1 MiB but send only a few bytes, then stall — the body read blocks. + _, err = fmt.Fprint(conn, "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 1048576\r\n\r\npartial") + require.Nil(t, err) + + start := time.Now() + select { + case err := <-readDone: + require.Error(t, err, "a stalled body read must be cut by the deadline") + require.Less(t, time.Since(start), 2*time.Second, "body must be cut promptly (~BodyReadTimeout)") + case <-time.After(2 * time.Second): + t.Fatal("stalled body read was not cut by the deadline") + } +} + +// TestApplyBodyReadDeadline_DoesNotCancelSlowHandler: a handler that reads the body then +// works past the deadline still returns 200 — the deadline bounds the body read, not +// post-read CPU work (e.g. a VM query) or the response write. (clear() has no observable +// effect to assert in standard net/http, so this checks the property that matters.) +func TestApplyBodyReadDeadline_DoesNotCancelSlowHandler(t *testing.T) { + t.Parallel() + + const deadline = 200 * time.Millisecond + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + applyBodyReadDeadline(w, r, deadline) + if _, err := io.ReadAll(r.Body); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + time.Sleep(3 * deadline) // work well past the deadline + w.WriteHeader(http.StatusOK) + }) + + srv := httptest.NewServer(handler) + defer srv.Close() + + resp, err := http.Post(srv.URL, "text/plain", bytes.NewReader([]byte("hello"))) + require.Nil(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, + "the body deadline must not cancel a slow post-read handler") +} + +// TestRequestHasBody covers the predicate that gates the body deadline: bodiless requests +// (including the bodiless-GET websocket handshakes) are skipped. +func TestRequestHasBody(t *testing.T) { + t.Parallel() + + require.False(t, requestHasBody(&http.Request{ContentLength: 0}), "no body (incl. websocket handshake)") + require.True(t, requestHasBody(&http.Request{ContentLength: 5}), "known body") + require.True(t, requestHasBody(&http.Request{ContentLength: -1}), "chunked body") +} + +// TestApplyBodyReadDeadline_SkipsBodilessUpgrade: a bodiless GET with Upgrade: websocket +// is not wrapped (no deadline armed), so the /log and /subscribe streams are never severed. +func TestApplyBodyReadDeadline_SkipsBodilessUpgrade(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + before := r.Body + applyBodyReadDeadline(w, r, BodyReadTimeout) + // r.Body must be unchanged — a wrapped body would mean a deadline was armed. + if r.Body != before { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + }) + + srv := httptest.NewServer(handler) + defer srv.Close() + + req, err := http.NewRequest(http.MethodGet, srv.URL, nil) // bodiless GET, like a handshake + require.Nil(t, err) + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + + resp, err := http.DefaultClient.Do(req) + require.Nil(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "bodiless websocket handshake must not be wrapped") +} + +// TestNewHardenedServer_WebSocketStreamWorks: a real websocket upgrade survives the +// hardened server — frames round-trip across idle gaps without the stream being severed. +func TestNewHardenedServer_WebSocketStreamWorks(t *testing.T) { + t.Parallel() + + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + mux := http.NewServeMux() + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + for { // echo until the client closes + mt, msg, err := conn.ReadMessage() + if err != nil { + return + } + if err := conn.WriteMessage(mt, msg); err != nil { + return + } + } + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.Nil(t, err) + srv := NewHardenedServer(ln.Addr().String(), mux) + go func() { _ = srv.Serve(ln) }() + defer func() { _ = srv.Close() }() + + dialer := websocket.Dialer{} + wsURL := "ws://" + ln.Addr().String() + "/ws" + conn, resp, err := dialer.Dial(wsURL, nil) + require.Nil(t, err, "websocket upgrade must succeed through the hardened server") + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + defer func() { _ = conn.Close() }() + + // Exchange frames with an idle gap between them to show the stream stays open and is + // not cut by ReadHeaderTimeout/IdleTimeout/body deadline once upgraded. + for i, gap := range []time.Duration{0, 250 * time.Millisecond, 250 * time.Millisecond} { + time.Sleep(gap) + want := fmt.Sprintf("frame-%d", i) + require.Nil(t, conn.WriteMessage(websocket.TextMessage, []byte(want))) + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, got, err := conn.ReadMessage() + require.Nil(t, err, "frame %d must round-trip", i) + require.Equal(t, want, string(got)) + } +}
a8d6682f8009[KLC-2428] harden REST listeners against slow-header DoS (GHSA-w4c6-7r69-w7j9)
6 files changed · +305 −2
cmd/seednode/api/api.go+4 −1 modified@@ -12,6 +12,7 @@ import ( "github.com/gorilla/websocket" logger "github.com/klever-io/klever-go-logger" "github.com/klever-io/klever-go/core" + "github.com/klever-io/klever-go/network/api/httpserver" "github.com/klever-io/klever-go/network/api/logs" "github.com/klever-io/klever-go/tools/marshal" ) @@ -56,7 +57,9 @@ func Start(restAPIInterface string, marshalizer marshal.Marshalizer, messenger p srv.registerRoutes(ws) - return ws.Run(restAPIInterface) + // Hardened http.Server instead of ws.Run: adds the ReadHeaderTimeout that + // http.ListenAndServe lacks (slow-header DoS, GHSA-w4c6-7r69-w7j9). + return httpserver.NewHardenedServer(restAPIInterface, ws.Handler()).ListenAndServe() } func (s *server) registerRoutes(ws *gin.Engine) {
network/api/api.go+4 −1 modified@@ -14,6 +14,7 @@ import ( ginSwagger "github.com/swaggo/gin-swagger" indexer "github.com/klever-io/klever-go/indexer" + "github.com/klever-io/klever-go/network/api/httpserver" clientSocket "github.com/klever-io/klever-go/websocket" "github.com/gin-contrib/cors" @@ -95,7 +96,9 @@ func Start(ctx context.Context, kleverFacade MainAPIHandler, routesConfig config ws.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.InstanceName(docs.SwaggerInfonode.InstanceName()))) RegisterRoutes(ctx, ws, routesConfig, kleverFacade) - return ws.Run(kleverFacade.RestAPIInterface()) + // Hardened http.Server instead of ws.Run: adds the ReadHeaderTimeout that + // http.ListenAndServe lacks (slow-header DoS, GHSA-w4c6-7r69-w7j9). + return httpserver.NewHardenedServer(kleverFacade.RestAPIInterface(), ws.Handler()).ListenAndServe() } func registerRouteGroup(ws *gin.Engine, name string, routesConfig config.APIRoutesConfig, authHandler gin.HandlerFunc, register func(*wrapper.RouterWrapper)) {
network/api/httpserver/httpserver.go+61 −0 added@@ -0,0 +1,61 @@ +// Package httpserver builds the hardened *http.Server shared by both REST start +// paths (seednode and node). Gin's Engine.Run uses http.ListenAndServe with no +// ReadHeaderTimeout, leaving it open to slow-header connection exhaustion +// (GHSA-w4c6-7r69-w7j9); this helper hardens both listeners identically. +package httpserver + +import ( + "net/http" + "time" +) + +const ( + // ReadHeaderTimeout is the slow-header (slowloris) mitigation: it bounds the + // time to send the complete header. Header-only, so it is safe for the + // long-lived websocket streams these APIs serve (cleared before hijack). + ReadHeaderTimeout = 10 * time.Second + + // IdleTimeout bounds how long an idle keep-alive connection stays open. + IdleTimeout = 120 * time.Second + + // MaxHeaderBytes caps request header size (Go's default, set explicitly). + MaxHeaderBytes = 1 << 20 // 1 MiB + + // MaxBodyBytes caps the request body. A single tx is bounded by the ~960 KiB + // P2P wire limit (~1.9 MiB once JSON-encoded), so 4 MiB covers the largest + // legitimate request with margin; bulk /transaction/broadcast is additionally + // bounded by an explicit tx count. Over-cap bodies are refused (400 on bind, + // 413 raw). Bounds body size, not read time — see the body read-deadline follow-up. + MaxBodyBytes = 4 << 20 // 4 MiB +) + +// NewHardenedServer returns an *http.Server for addr serving handler, hardened +// against slow-header exhaustion and oversized bodies. ReadTimeout/WriteTimeout +// are left unset on purpose: a whole-connection deadline would sever the +// long-lived websocket streams these APIs serve (/log, /subscribe). +func NewHardenedServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: limitRequestBody(handler), + ReadHeaderTimeout: ReadHeaderTimeout, + IdleTimeout: IdleTimeout, + MaxHeaderBytes: MaxHeaderBytes, + } +} + +// limitRequestBody caps every request body at MaxBodyBytes. +func limitRequestBody(next http.Handler) http.Handler { + return limitRequestBodyN(next, MaxBodyBytes) +} + +// limitRequestBodyN caps each request body at limit bytes. Applied ahead of gin so +// w is the *http.response MaxBytesReader needs to close an over-cap connection. +// Websocket upgrades hijack the connection and never read r.Body, so are unaffected. +func limitRequestBodyN(next http.Handler, limit int64) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, limit) + } + next.ServeHTTP(w, r) + }) +}
network/api/httpserver/httpserver_test.go+150 −0 added@@ -0,0 +1,150 @@ +package httpserver + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestNewHardenedServer_SetsSlowHeaderDefenses guards GHSA-w4c6-7r69-w7j9: the +// hardening fields are set, and ReadTimeout/WriteTimeout stay zero so the APIs' +// long-lived websocket streams are not severed. +func TestNewHardenedServer_SetsSlowHeaderDefenses(t *testing.T) { + t.Parallel() + + srv := NewHardenedServer(":8080", http.NewServeMux()) + + require.Equal(t, ":8080", srv.Addr) + require.NotNil(t, srv.Handler) + require.Equal(t, ReadHeaderTimeout, srv.ReadHeaderTimeout) + require.Positive(t, srv.ReadHeaderTimeout, "ReadHeaderTimeout must be set to defeat slow-header DoS") + require.Equal(t, IdleTimeout, srv.IdleTimeout) + require.Equal(t, MaxHeaderBytes, srv.MaxHeaderBytes) + + // Websocket safety: a whole-connection deadline would kill long-lived streams. + require.Zero(t, srv.ReadTimeout, "ReadTimeout must stay unset to not sever websocket streams") + require.Zero(t, srv.WriteTimeout, "WriteTimeout must stay unset to not sever websocket streams") +} + +// TestNewHardenedServer_ClosesSlowHeaderConnection proves end-to-end that a connection +// whose header never completes is dropped, not pinned. Short ReadHeaderTimeout for speed. +func TestNewHardenedServer_ClosesSlowHeaderConnection(t *testing.T) { + t.Parallel() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.Nil(t, err) + + srv := NewHardenedServer(ln.Addr().String(), http.NewServeMux()) + srv.ReadHeaderTimeout = 200 * time.Millisecond // tighten for a fast test + go func() { _ = srv.Serve(ln) }() + defer func() { _ = srv.Close() }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.Nil(t, err) + defer func() { _ = conn.Close() }() + + // Send headers but never the terminating blank line, so the header never completes. + _, err = fmt.Fprint(conn, "GET / HTTP/1.1\r\nHost: x\r\n") + require.Nil(t, err) + + // With ReadHeaderTimeout the server drops the connection ~200ms in; a sub-second + // return proves the defense (without it, the read blocks until our 3s deadline). + start := time.Now() + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _ = bufio.NewReader(conn).ReadString('\n') + require.Less(t, time.Since(start), time.Second, + "server must drop the stalled slow-header connection promptly (ReadHeaderTimeout)") +} + +// TestNewHardenedServer_DropsDrippingSlowHeader proves ReadHeaderTimeout is an absolute +// deadline, not a reset-on-read idle timer: a client actively dripping header bytes +// (never completing the header) is still dropped at ~ReadHeaderTimeout. +func TestNewHardenedServer_DropsDrippingSlowHeader(t *testing.T) { + t.Parallel() + + const headerTimeout = 400 * time.Millisecond + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.Nil(t, err) + + srv := NewHardenedServer(ln.Addr().String(), http.NewServeMux()) + srv.ReadHeaderTimeout = headerTimeout // tighten for a fast test + go func() { _ = srv.Serve(ln) }() + defer func() { _ = srv.Close() }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.Nil(t, err) + defer func() { _ = conn.Close() }() + + _, err = fmt.Fprint(conn, "GET / HTTP/1.1\r\nHost: x\r\n") + require.Nil(t, err) + + // Drip header lines well under the timeout interval, never ending the header. + dripStop := make(chan struct{}) + go func() { + ticker := time.NewTicker(headerTimeout / 8) + defer ticker.Stop() + for i := 0; ; i++ { + select { + case <-dripStop: + return + case <-ticker.C: + if _, werr := fmt.Fprintf(conn, "X-Pad-%d: y\r\n", i); werr != nil { + return // server closed the connection + } + } + } + }() + defer close(dripStop) + + start := time.Now() + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _ = bufio.NewReader(conn).ReadString('\n') + elapsed := time.Since(start) + + require.Less(t, elapsed, time.Second, + "absolute ReadHeaderTimeout must drop an actively-dripping slow-header connection") + require.GreaterOrEqual(t, elapsed, headerTimeout/2, + "connection should survive until ~the header deadline, not be dropped on the first read") +} + +// TestNewHardenedServer_CapsRequestBody guards the body-size cap: a body over the +// limit is refused, one within it is read normally. Small explicit limit for speed. +func TestNewHardenedServer_CapsRequestBody(t *testing.T) { + t.Parallel() + + const limit = 16 + + // Status carries the read outcome, so assertions read only the response code — + // no state shared across the server/test goroutines. + handler := limitRequestBodyN(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := io.ReadAll(r.Body); err != nil { + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } + w.WriteHeader(http.StatusOK) + }), limit) + + srv := httptest.NewServer(handler) + defer srv.Close() + + // Over the cap: MaxBytesReader fails the body read, so the handler replies 413. + resp, err := http.Post(srv.URL, "application/octet-stream", bytes.NewReader(make([]byte, limit+1))) + require.Nil(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode, "body over the cap must be refused") + + // Within the cap: the body reads cleanly and the handler replies 200. + resp, err = http.Post(srv.URL, "application/octet-stream", bytes.NewReader(make([]byte, limit))) + require.Nil(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "body within the cap must be accepted") +}
network/api/transaction/routes.go+18 −0 modified@@ -41,6 +41,11 @@ const ( queryParamWithResults = "withResults" ) +// maxBulkBroadcastTxs caps the transactions a single /transaction/broadcast may +// carry, bounding per-request work by intent rather than incidentally by body size. +// Clients needing to submit more should send multiple requests. +const maxBulkBroadcastTxs = 100 + // FacadeHandler interface defines methods that can be used by the gin webserver type FacadeHandler interface { CreateTransaction(txType uint32, base *transaction.TXBaseInfo, contracts []json.RawMessage, skipValidate bool) (*transaction.Transaction, []byte, error) @@ -285,6 +290,19 @@ func BroadcastTX(c *gin.Context) { return } + if len(gtx.TXs) > maxBulkBroadcastTxs { + c.JSON( + http.StatusBadRequest, + shared.GenericAPIResponse{ + Data: nil, + Error: fmt.Sprintf("%s: bulk broadcast exceeds the maximum of %d transactions per request, got %d", + errors.ErrValidation.Error(), maxBulkBroadcastTxs, len(gtx.TXs)), + Code: shared.ReturnCodeRequestError, + }, + ) + return + } + txsHashes, err := facade.SendBulkTransactions(gtx.TXs) if err != nil {
network/api/transaction/routes_test.go+68 −0 modified@@ -508,6 +508,74 @@ func TestBroadcastTX_BulkTransactions_ShouldWork(t *testing.T) { } } +func makeBroadcastTxs(n int) []*transaction.Transaction { + txs := make([]*transaction.Transaction, n) + for i := 0; i < n; i++ { + txs[i] = transaction.NewBaseTransaction([]byte("sender"), uint64(i), nil, 0, 0) + } + return txs +} + +func TestBroadcastTX_BulkTransactions_ExceedsLimitShouldError(t *testing.T) { + t.Parallel() + + facade := mock.Facade{ + SendBulkTransactionsHandler: func(txs []*transaction.Transaction) ([]string, error) { + require.Fail(t, "SendBulkTransactions must not be called when the batch exceeds the limit") + return nil, nil + }, + } + + ws := startNodeServer(&facade) + + requestData := tr.BroadcastTXRequest{ + TXs: makeBroadcastTxs(101), + } + requestBytes, _ := json.Marshal(requestData) + + req, _ := http.NewRequest("POST", "/transaction/broadcast", bytes.NewReader(requestBytes)) + resp := httptest.NewRecorder() + ws.ServeHTTP(resp, req) + + response := shared.GenericAPIResponse{} + loadResponse(resp.Body, &response) + + assert.Equal(t, http.StatusBadRequest, resp.Code) + assert.Equal(t, shared.ReturnCodeRequestError, response.Code) + assert.True(t, strings.Contains(response.Error, apiErrors.ErrValidation.Error())) + assert.True(t, strings.Contains(response.Error, "maximum of 100")) + assert.Nil(t, response.Data) +} + +func TestBroadcastTX_BulkTransactions_AtLimitShouldWork(t *testing.T) { + t.Parallel() + + facade := mock.Facade{ + SendBulkTransactionsHandler: func(txs []*transaction.Transaction) ([]string, error) { + require.Len(t, txs, 100) + return make([]string, len(txs)), nil + }, + } + + ws := startNodeServer(&facade) + + requestData := tr.BroadcastTXRequest{ + TXs: makeBroadcastTxs(100), + } + requestBytes, _ := json.Marshal(requestData) + + req, _ := http.NewRequest("POST", "/transaction/broadcast", bytes.NewReader(requestBytes)) + resp := httptest.NewRecorder() + ws.ServeHTTP(resp, req) + + response := shared.GenericAPIResponse{} + loadResponse(resp.Body, &response) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, shared.ReturnCodeSuccess, response.Code) + assert.Empty(t, response.Error) +} + func TestBroadcastTX_BulkTransactions_SendBulkTransactionsError(t *testing.T) { t.Parallel()
Vulnerability mechanics
Root cause
"The Gin engine's default http.ListenAndServe call lacks application-level timeouts for reading HTTP headers."
Attack vector
An unauthenticated client can connect to a REST listener bound to an all-interface option or published via Docker. The attacker then sends incomplete HTTP headers and holds the connection open indefinitely. This exhausts server resources, preventing legitimate clients from accessing the REST API. The proof of concept demonstrated that 120 slow connections caused legitimate probes to fail [ref_id=1].
Affected code
The vulnerability exists in the REST API startup code for both the seednode and the node. Specifically, `cmd/seednode/api/api.go` and `network/api/api.go` use `gin.Default().Run(address)`, which internally calls `http.ListenAndServe` without custom server configurations [ref_id=1]. The `develop` branch remains affected with the same code paths.
What the fix does
The suggested fix involves wrapping the Gin router within an explicit `http.Server` configuration. This configuration includes `ReadHeaderTimeout`, `ReadTimeout`, and `MaxHeaderBytes` settings. By setting these timeouts, the server will automatically close connections that do not complete their headers within the specified duration, preventing resource exhaustion [ref_id=1].
Preconditions
- configThe REST API listener must be bound to an all-interface option (e.g., `--rest-api-interface :8080`) or exposed via Docker port publishing.
- authNo authentication is required to exploit this vulnerability.
- networkThe attacker must have network access to the REST API listener.
Reproduction
The reproduction steps involve cloning the repository, checking out version `v1.7.17`, applying a provided PoC patch to `cmd/seednode/api`, and then running the test `go test ./cmd/seednode/api -run TestPoC_SeednodeAPISlowlorisDifferential -count=1 -v -timeout 60s` [ref_id=1].
Generated on Jun 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.