diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 0de499a13..fa9e6f607 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -18,6 +19,24 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/util/link" ) +// maxOutboundSubscriptionBytes caps a single outbound subscription response. +// It is larger than the 2 MiB user-facing subscription cap because an outbound +// subscription may aggregate many upstream outbounds into one document. +const maxOutboundSubscriptionBytes int64 = 8 << 20 + +var errOutboundSubscriptionBodyTooLarge = errors.New("outbound subscription response body exceeds size limit") + +func readBoundedOutboundSubscriptionBody(r io.Reader) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(r, maxOutboundSubscriptionBytes+1)) + if err != nil { + return nil, err + } + if int64(len(body)) > maxOutboundSubscriptionBytes { + return nil, fmt.Errorf("%w (limit: %d bytes)", errOutboundSubscriptionBodyTooLarge, maxOutboundSubscriptionBytes) + } + return body, nil +} + // OutboundSubscriptionService manages remote outbound subscriptions. type OutboundSubscriptionService struct { settingService SettingService @@ -281,7 +300,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript s.recordError(sub, err) return nil, err } - body, err := io.ReadAll(resp.Body) + body, err := readBoundedOutboundSubscriptionBody(resp.Body) if err != nil { s.recordError(sub, err) return nil, err diff --git a/internal/web/service/outbound_subscription_test.go b/internal/web/service/outbound_subscription_test.go index 7469ca39d..2868c2b78 100644 --- a/internal/web/service/outbound_subscription_test.go +++ b/internal/web/service/outbound_subscription_test.go @@ -1,12 +1,38 @@ package service import ( + "bytes" + "errors" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/link" ) +func TestReadBoundedOutboundSubscriptionBody(t *testing.T) { + t.Run("accepts body at the limit", func(t *testing.T) { + want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes)) + got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(want)) + if err != nil { + t.Fatalf("readBoundedOutboundSubscriptionBody: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("body mismatch: got %d bytes, want %d", len(got), len(want)) + } + }) + + t.Run("rejects body over the limit", func(t *testing.T) { + body := bytes.Repeat([]byte("b"), int(maxOutboundSubscriptionBytes)+1) + got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(body)) + if !errors.Is(err, errOutboundSubscriptionBodyTooLarge) { + t.Fatalf("error = %v, want errOutboundSubscriptionBodyTooLarge", err) + } + if got != nil { + t.Fatalf("oversized body returned %d bytes, want nil", len(got)) + } + }) +} + func TestDefaultPrefixNumber(t *testing.T) { mk := func(id int, prefix string) *model.OutboundSubscription { return &model.OutboundSubscription{Id: id, TagPrefix: prefix}