mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-07 02:37:14 +00:00
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user