diff --git a/internal/util/json_util/list.go b/internal/util/json_util/list.go new file mode 100644 index 000000000..0e5edb112 --- /dev/null +++ b/internal/util/json_util/list.go @@ -0,0 +1,38 @@ +package json_util + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" +) + +// ErrListTooLong is returned by DecodeCappedList for a list past its entry limit. +var ErrListTooLong = errors.New("list exceeds entry limit") + +// DecodeCappedList decodes a JSON array entry by entry and stops at the first entry past +// maxEntries: each "{}" becomes a whole T, so a byte cap alone does not bound the result. +func DecodeCappedList[T any](raw []byte, maxEntries int) ([]T, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, nil + } + dec := json.NewDecoder(bytes.NewReader(raw)) + if tok, err := dec.Token(); err != nil { + return nil, err + } else if delim, ok := tok.(json.Delim); !ok || delim != '[' { + return nil, fmt.Errorf("expected a JSON array, got %v", tok) + } + var out []T + for dec.More() { + if len(out) >= maxEntries { + return nil, fmt.Errorf("%w (%d)", ErrListTooLong, maxEntries) + } + var item T + if err := dec.Decode(&item); err != nil { + return nil, err + } + out = append(out, item) + } + return out, nil +} diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go index 7db982adb..da438fbab 100644 --- a/internal/web/controller/inbound.go +++ b/internal/web/controller/inbound.go @@ -8,6 +8,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" "github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/service" @@ -395,18 +396,26 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil) } +// maxPushedClientTraffics bounds one push: each entry decodes into a whole traffic row. +const maxPushedClientTraffics = 1 << 18 + // pushClientTraffics receives a master panel's aggregated per-client usage // (see InboundService.AcceptGlobalTraffic for the storage semantics). func (a *InboundController) pushClientTraffics(c *gin.Context) { var req struct { - MasterGuid string `json:"masterGuid"` - Traffics []*xray.ClientTraffic `json:"traffics"` + MasterGuid string `json:"masterGuid"` + Traffics json.RawMessage `json:"traffics"` } if err := c.ShouldBindJSON(&req); err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return } - if err := a.inboundService.AcceptGlobalTraffic(req.MasterGuid, req.Traffics); err != nil { + traffics, err := json_util.DecodeCappedList[*xray.ClientTraffic](req.Traffics, maxPushedClientTraffics) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + if err := a.inboundService.AcceptGlobalTraffic(req.MasterGuid, traffics); err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return } diff --git a/internal/web/controller/inbound_node_sync_test.go b/internal/web/controller/inbound_node_sync_test.go index c90fe92fc..2b316ccc4 100644 --- a/internal/web/controller/inbound_node_sync_test.go +++ b/internal/web/controller/inbound_node_sync_test.go @@ -15,6 +15,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" ) @@ -103,3 +104,24 @@ func TestNodeSyncPushSkipsOperatorTLSGuard(t *testing.T) { } }) } + +// Each "{}" of the pushed list decodes into a whole traffic row, so a body under the size +// cap could still cost the panel close to a gigabyte; the list is capped by entry count. +func TestPushClientTrafficsCapsTheListByEntryCount(t *testing.T) { + gin.SetMode(gin.TestMode) + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key }) + c.Next() + }) + NewInboundController(engine.Group("/panel/api/inbounds")) + body := `{"masterGuid":"m","traffics":[` + strings.TrimSuffix(strings.Repeat("{},", maxPushedClientTraffics+1), ",") + `]}` + req := httptest.NewRequest(http.MethodPost, "/panel/api/inbounds/pushClientTraffics", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if !strings.Contains(w.Body.String(), `"success":false`) { + t.Fatalf("an over-long traffic list was accepted: %.200s", w.Body.String()) + } +} diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index f65aa47c2..90c0dd1eb 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -22,6 +22,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" "github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec" "github.com/mhsanaei/3x-ui/v3/internal/web/entity" @@ -423,7 +424,7 @@ func (r *Remote) ListInboundOptions(ctx context.Context) ([]RemoteInboundOption, if err != nil { return nil, err } - list, err := decodeCappedList[RemoteInboundOption](env.Obj, maxNodeInbounds) + list, err := json_util.DecodeCappedList[RemoteInboundOption](env.Obj, maxNodeInbounds) if err != nil { return nil, fmt.Errorf("decode inbound list: %w", err) } @@ -435,7 +436,7 @@ func (r *Remote) refreshRemoteIDs(ctx context.Context) error { if err != nil { return err } - list, err := decodeCappedList[struct { + list, err := json_util.DecodeCappedList[struct { Id int `json:"id"` Tag string `json:"tag"` }](env.Obj, maxNodeInbounds) @@ -768,7 +769,7 @@ func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error if err != nil { return nil, err } - out, err := decodeCappedList[model.NodeSummary](env.Obj, maxNodeDescendants) + out, err := json_util.DecodeCappedList[model.NodeSummary](env.Obj, maxNodeDescendants) if err != nil { return nil, fmt.Errorf("decode descendants: %w", err) } @@ -829,7 +830,7 @@ func (r *Remote) FetchHostGroups(ctx context.Context) ([]*entity.HostGroup, erro if err != nil { return nil, err } - groups, err := decodeCappedList[*entity.HostGroup](env.Obj, maxNodeHostGroups) + groups, err := json_util.DecodeCappedList[*entity.HostGroup](env.Obj, maxNodeHostGroups) if err != nil { return nil, fmt.Errorf("decode host groups: %w", err) } @@ -843,7 +844,7 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er if err != nil { return nil, err } - if snap.Inbounds, err = decodeCappedList[*model.Inbound](envList.Obj, maxNodeInbounds); err != nil { + if snap.Inbounds, err = json_util.DecodeCappedList[*model.Inbound](envList.Obj, maxNodeInbounds); err != nil { return nil, fmt.Errorf("decode inbound list: %w", err) } @@ -1003,7 +1004,7 @@ func (r *Remote) FetchAllClientIps(ctx context.Context) ([]model.InboundClientIp if err != nil { return nil, err } - ips, err := decodeCappedList[model.InboundClientIps](env.Obj, maxNodeClientIPs) + ips, err := json_util.DecodeCappedList[model.InboundClientIps](env.Obj, maxNodeClientIPs) if err != nil { return nil, fmt.Errorf("decode client ips: %w", err) } diff --git a/internal/web/runtime/remote_decode.go b/internal/web/runtime/remote_decode.go index 2bd2be435..ec4c92c78 100644 --- a/internal/web/runtime/remote_decode.go +++ b/internal/web/runtime/remote_decode.go @@ -3,9 +3,10 @@ package runtime import ( "bytes" "encoding/json" - "errors" "fmt" "unicode/utf8" + + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" ) // Per-endpoint caps on node-reported lists: each "{}" decodes into a whole struct, so the @@ -19,34 +20,7 @@ const ( maxNodeMapValues = 1 << 20 ) -var errRemoteListTooLong = errors.New("remote list exceeds entry limit") - -// decodeCappedList decodes a JSON array entry by entry and stops at the first entry -// past maxEntries; null decodes to a nil slice. -func decodeCappedList[T any](raw json.RawMessage, maxEntries int) ([]T, error) { - raw = bytes.TrimSpace(raw) - if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { - return nil, nil - } - dec := json.NewDecoder(bytes.NewReader(raw)) - if tok, err := dec.Token(); err != nil { - return nil, err - } else if delim, ok := tok.(json.Delim); !ok || delim != '[' { - return nil, fmt.Errorf("expected a JSON array, got %v", tok) - } - var out []T - for dec.More() { - if len(out) >= maxEntries { - return nil, fmt.Errorf("%w (%d)", errRemoteListTooLong, maxEntries) - } - var item T - if err := dec.Decode(&item); err != nil { - return nil, err - } - out = append(out, item) - } - return out, nil -} +var errRemoteListTooLong = json_util.ErrListTooLong // decodeCappedValues unmarshals raw into v after checking it holds at most maxValues // JSON values in all, nested ones included.