Summary
Klever-Go: Unauthenticated WebSocket /subscribe: no read-size limit, no connection cap, permissive origin -> remote node memory/goroutine exhaustion (DoS)
Advisory details
Summary
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:
- Permissive origin —
upgrader.CheckOriginalways returnstrue(network/api/websocket/routes.go), so any web origin can complete the handshake. - No read-size limit — the connection never calls
conn.SetReadLimit(...). gorilla's default is UNLIMITED, so a singleconn.ReadJSON(processSubscription) orconn.ReadMessage(client.loopIn) can be forced to allocate an arbitrarily large buffer from ONE frame. - No connection / fan-out cap — the gin global throttler (
simultaneousRequests: 100) releases its slot as soon ashandleSubscribereturns, which it does immediately aftergo 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, andreq.Addresseshas no length cap, so the hub'saddressSubscriptionmap grows 1:1 with attacker-supplied strings.
Affected Component / Code Path
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
...
}
Preconditions
- The node's REST API must be reachable by the attacker. Two realistic deployment shapes:
- (a) Operator-exposed API —
--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 atnode.klever.org/api.klever.org). Here the attacker reaches/subscribedirectly over the network with no further conditions. - (b) Cross-origin browser drive-by — default bind is
localhost:8080(common/facade/nodeFacade.goDefaultRestInterface = "localhost:8080"). BecauseCheckOriginreturnstrue(GAP #1), any website an operator visits can openws://localhost:8080/subscribefrom the victim's browser and drive GAP #2 (single oversized frame) and GAP #3 (many connections) without the API being network-exposed at all.
- (a) Operator-exposed API —
/subscribeisopen: truein the defaultconfig/node/api.yaml;isSubscriptionRouteEnabledreturns true and the route + hub are wired unconditionally inRegisterRoutes./subscribeis NOT listed inendpointsThrottlers(config/node/config.yaml), so it has no per-endpoint goroutine cap.- No authentication, no on-chain account, no stake, no attacker-created asset is required.
Impact (distributed by gap and by blast radius)
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.
Impact A — Single-frame heap exhaustion (GAP #2, the cleanest primitive)
- One unauthenticated connection sends ONE WebSocket frame; with no
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. - Observed amplification: an 8 MiB frame grows the server heap by
32 MiB (4x) while buffering ONE attacker frame (decode/UTF-8/scratch overhead on top of the raw bytes). - No flood and no rate-limit interaction is needed: the source throttler is a per-IP RATE cap on HTTP handshakes, not a size or memory cap, so a single slow connection streaming one oversized message is not meaningfully throttled.
- Result: OOM-kill of the node process from a single connection.
Impact B — Connection / goroutine exhaustion (GAP #3, fan-out)
- Live WS connections are not counted by the gin global throttler (its slot is freed at the HTTP→WS upgrade), and there is no per-IP or hub-level connection cap.
- Each accepted connection costs 2 goroutines + a 500-entry buffered channel. Connection count grows linearly with attacker effort from a single source, with no ceiling.
- Result: goroutine/descriptor/scheduler exhaustion → node slowdown then OOM/crash.
Impact C — Unbounded subscription-map growth (GAP #3, per-connection memory)
req.Addressesis uncapped, andHandleClientInsertioninserts every entry into the hub'saddressSubscriptionmap. 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.- Result: heap growth driven purely by attacker-chosen strings on a single connection; combinable with Impact B (many connections × many addresses) for multiplicative memory pressure.
Impact D — Cross-origin reach to localhost-bound nodes (GAP #1, exposure amplifier)
- Because
CheckOriginis alwaystrue, Impacts A–C are reachable from a victim's browser even when the API is bound tolocalhostand 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. - Result: the
localhost-bind "mitigation" does not hold against a web-drive-by at
References
- https://github.com/advisories/GHSA-4fwh-wrm6-97xm
- https://github.com/klever-io/klever-go/security/advisories/GHSA-4fwh-wrm6-97xm
- https://github.com/klever-io/klever-go/pull/76
- https://github.com/klever-io/klever-go/commit/b8af922e749ec1fa7451b2c6e7c9f634d8603be2
- https://github.com/klever-io/klever-go/releases/tag/v1.7.20
Related vulnerabilities
All Supply chain →- MEDIUMCVE-2026-102682
Pipecat: Public exposure of Pipecat’s development runner can allow unauthenticated bot session creation
- MEDIUMCVE-2026-100350
kin-openapi deepObject query decoding repeatedly compiles an invariant regular expression
- MEDIUMCVE-2026-59951
MCP Python SDK: Unbounded Session Accumulation in StreamableHTTP Transport Enables Resource Exhaustion
- HIGHCVE-2026-104827
react/http: Unbounded HTTP Client Response Header Buffering Leads to Memory Exhaustion DoS
- HIGHCVE-2026-77622
Sliver: Unauthenticated Memory Exhaustion Denial of Service in Sliver HTTP(S) Listener PNG Decoder
- HIGHCVE-2026-72923
Microsoft.OpenApi.YamlReader vulnerable to denial of service via YAML alias expansion