mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-21 03:56:07 +00:00
fix(runtime): cap remote node response size to bound master memory (#5361)
Remote node HTTP responses were read with an unbounded io.ReadAll, so a
broken or hostile node could force the master panel to buffer an arbitrarily
large body. The single Remote.do choke point that all node calls funnel
through now:
- validates the HTTP status before reading any success payload (a non-OK
body is only read up to a small bounded diagnostic snippet, so a node
cannot make the master buffer a large body just to return an error);
- fast-fails on an honestly-declared oversize Content-Length;
- reads the success body through readCappedBody, an io.LimitReader cap
(64 MiB) that rejects oversize with a typed error.
The 64 MiB cap bounds one response's wire/decompressed size; it is documented
as not a process-wide memory bound (endpoint-specific caps and a concurrency
budget remain follow-ups).
Tests cover the cap+1 boundary, an oversize streamed body, a normal envelope,
and non-OK status precedence.
This commit is contained in:
@@ -1,16 +1,112 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// TestRemoteDo_RejectsOversizeResponse: a node streaming a body larger than
|
||||
// maxRemoteResponseBytes must error out instead of the master buffering it
|
||||
// unbounded.
|
||||
func TestRemoteDo_RejectsOversizeResponse(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
chunk := bytes.Repeat([]byte("a"), 1<<20) // 1 MiB
|
||||
for written := 0; written <= maxRemoteResponseBytes; written += len(chunk) {
|
||||
if _, err := w.Write(chunk); err != nil {
|
||||
return // client stopped reading at the cap
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
||||
if _, err := r.do(context.Background(), http.MethodGet, "/probe", nil); !errors.Is(err, errRemoteResponseTooLarge) {
|
||||
t.Fatalf("do() error = %v, want errRemoteResponseTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteDo_AcceptsNormalResponse confirms the cap does not break a normal
|
||||
// under-limit envelope.
|
||||
func TestRemoteDo_AcceptsNormalResponse(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"msg":"ok","obj":{"x":1}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
||||
env, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("do() unexpected error: %v", err)
|
||||
}
|
||||
if env == nil || !env.Success {
|
||||
t.Fatalf("env = %+v, want Success=true", env)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadCappedBody_Boundary pins the cap+1 contract cheaply (no large allocs):
|
||||
// a body of exactly limit is accepted; limit+1 and beyond are rejected.
|
||||
func TestReadCappedBody_Boundary(t *testing.T) {
|
||||
const limit = 8
|
||||
cases := []struct {
|
||||
name string
|
||||
n int
|
||||
wantErr bool
|
||||
}{
|
||||
{"under", limit - 1, false},
|
||||
{"exact", limit, false},
|
||||
{"over-by-one", limit + 1, true},
|
||||
{"way-over", limit * 4, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
raw, err := readCappedBody(bytes.NewReader(bytes.Repeat([]byte("x"), c.n)), limit)
|
||||
if c.wantErr {
|
||||
if !errors.Is(err, errRemoteResponseTooLarge) {
|
||||
t.Fatalf("n=%d: err=%v, want errRemoteResponseTooLarge", c.n, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("n=%d: unexpected err %v", c.n, err)
|
||||
}
|
||||
if len(raw) != c.n {
|
||||
t.Fatalf("n=%d: read %d bytes, want %d", c.n, len(raw), c.n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteDo_NonOKStatusReturnsHTTPError confirms a non-OK status is reported
|
||||
// as an HTTP error (with a bounded diagnostic snippet) rather than being read as
|
||||
// a success payload — i.e. status precedence over the body.
|
||||
func TestRemoteDo_NonOKStatusReturnsHTTPError(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
||||
_, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
|
||||
if err == nil {
|
||||
t.Fatal("do() error = nil, want HTTP 500 error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 500") || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("error = %q, want it to mention HTTP 500 and the body snippet", err)
|
||||
}
|
||||
}
|
||||
|
||||
type stubEgress struct{ url string }
|
||||
|
||||
func (s stubEgress) NodeEgressProxyURL(int) string { return s.url }
|
||||
|
||||
Reference in New Issue
Block a user