Browse Source

fix(node): cap the status body the heartbeat probe decodes

probe decoded the node status response with json.NewDecoder(resp.Body) and no
size limit. encoding/json buffers the whole value before decoding, so the
allocation was dictated by the peer regardless of how few fields the envelope
declares — and the heartbeat job probes up to 32 nodes concurrently on a 4s
budget with no client-level timeout.

The sibling RPC path already caps every node response at 64 MiB
(readCappedBody in internal/web/runtime), so this was the one uncapped read
of node-controlled data. A status envelope holds a handful of scalars, so the
cap here is 1 MiB rather than the RPC figure.

The peer is untrusted in the skip and pin TLS modes, and the same decode is
reachable from the nodes test and probe endpoints.
Sanaei 20 hours ago
parent
commit
ab4229534e
2 changed files with 53 additions and 1 deletions
  1. 6 1
      internal/web/service/node.go
  2. 47 0
      internal/web/service/node_probe_body_cap_test.go

+ 6 - 1
internal/web/service/node.go

@@ -8,6 +8,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"net"
 	"net/http"
 	"net/url"
@@ -1203,6 +1204,10 @@ func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func
 	fn(proxyURL)
 }
 
+// A status envelope holds a handful of scalars; the cap keeps a hostile or
+// broken node from dictating the master's allocation on every heartbeat.
+const maxProbeBodyBytes = 1 << 20 // 1 MiB
+
 func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) {
 	patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
 
@@ -1285,7 +1290,7 @@ func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string)
 			} `json:"netIO"`
 		} `json:"obj"`
 	}
-	if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
+	if err := json.NewDecoder(io.LimitReader(resp.Body, maxProbeBodyBytes)).Decode(&envelope); err != nil {
 		patch.LastError = "decode response: " + err.Error()
 		return patch, err
 	}

+ 47 - 0
internal/web/service/node_probe_body_cap_test.go

@@ -0,0 +1,47 @@
+package service
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// A node answers the probe over a connection the master does not control in the
+// skip/pin TLS modes, so an oversized status body must be rejected rather than
+// buffered whole by encoding/json.
+func TestProbeRejectsOversizedStatusBody(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true,"obj":{"cpuPct":1,"panelVersion":"`))
+		pad := strings.Repeat("x", 1<<20)
+		for i := 0; i < 3; i++ {
+			_, _ = w.Write([]byte(pad))
+		}
+		_, _ = w.Write([]byte(`"}}`))
+	}))
+	defer srv.Close()
+
+	u, err := url.Parse(srv.URL)
+	if err != nil {
+		t.Fatalf("parse url: %v", err)
+	}
+	port, err := strconv.Atoi(u.Port())
+	if err != nil {
+		t.Fatalf("parse port: %v", err)
+	}
+	n := &model.Node{
+		Id: 1, Name: "big", Scheme: "http", Address: u.Hostname(), Port: port,
+		BasePath: "/", Enable: true, AllowPrivateAddress: true, TlsVerifyMode: "skip",
+	}
+
+	svc := &NodeService{}
+	if _, err := svc.Probe(context.Background(), n); err == nil {
+		t.Fatal("Probe accepted a 3 MiB status body, want an error")
+	}
+}