fix(sub): bound what one subscription response renders

A subscription's size is the product of its clients and inbounds with
each one's remark and endpoints, and nothing bounded that product: data
adopted from a node could make one unauthenticated request render
gigabytes and run the panel out of memory.

Each request now carries an 8 MiB budget that the raw, JSON and Clash
outputs charge as they materialize a client's links, configs or
proxies. Rendering stops once it is spent, with one warning, and
anything below the budget is unchanged.
This commit is contained in:
MHSanaei
2026-11-01 01:15:28 +01:00
parent fc8efc0745
commit 2467b9d542
5 changed files with 161 additions and 6 deletions
+4 -2
View File
@@ -90,7 +90,9 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
variants, closed := subReq.clientHostVariants(inbound, client, "clash")
hasBoundClosed = hasBoundClosed || closed
for _, variant := range variants {
render := func() []map[string]any { return s.getProxies(subReq, variant.inbound(subReq, inbound), client, host) }
render := func() []map[string]any {
return s.budgetedProxies(subReq, variant.inbound(subReq, inbound), client, host)
}
ordered = append(ordered, orderedSubEntry[func() []map[string]any]{variant.order, render})
}
continue
@@ -106,7 +108,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
injectExternalProxy(&clone, effectiveEps)
effective = &clone
}
proxies = append(proxies, s.getProxies(subReq, effective, client, host)...)
proxies = append(proxies, s.budgetedProxies(subReq, effective, client, host)...)
}
}
if subReq.hostsFirst {
+2 -2
View File
@@ -173,7 +173,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
hasBoundClosed = hasBoundClosed || closed
for _, variant := range variants {
render := func() subConfigEntry {
configs := s.getConfig(subReq, variant.inbound(subReq, inbound), client, host)
configs := s.budgetedConfig(subReq, variant.inbound(subReq, inbound), client, host)
return subConfigEntry{sortIndex: inbound.SubSortIndex, id: inbound.Id, configs: configs, hostOrder: variant.order}
}
ordered = append(ordered, orderedSubEntry[func() subConfigEntry]{variant.order, render})
@@ -191,7 +191,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
injectExternalProxy(&clone, effectiveEps)
effective = &clone
}
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, effective, client, host)...)
inboundConfigs = append(inboundConfigs, s.budgetedConfig(subReq, effective, client, host)...)
}
if len(inboundConfigs) > 0 {
entries = append(entries, subConfigEntry{
+70
View File
@@ -0,0 +1,70 @@
package sub
import (
"encoding/json"
"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"
)
// maxRenderedBytes bounds what one subscription response renders: every client and
// inbound of the subId repeats the remark and endpoints, so the size is a product.
const maxRenderedBytes = 8 << 20
// renderBudget counts rendered bytes against maxRenderedBytes. The zero value
// is a fresh budget.
type renderBudget int
// take books n more bytes and reports whether they still fit. The caller drops
// what does not fit and stops rendering: a spent budget admits nothing else.
func (b *renderBudget) take(n int) bool {
if b.spent() {
return false
}
*b += renderBudget(n)
if b.spent() {
logger.Warningf("sub: rendering stopped at %d bytes, the remaining links are dropped", maxRenderedBytes)
return false
}
return true
}
func (b *renderBudget) spent() bool {
return *b > maxRenderedBytes
}
// keepLink books a rendered raw link, and its line break, against the request's budget.
func (s *SubService) keepLink(link string) bool {
return s.rendered.take(len(link) + 1)
}
// budgetedConfig renders a client's JSON configs, keeping those the request's budget admits.
func (s *SubJsonService) budgetedConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
if subReq.rendered.spent() {
return nil
}
configs := s.getConfig(subReq, inbound, client, host)
for i, config := range configs {
if !subReq.rendered.take(len(config)) {
return configs[:i]
}
}
return configs
}
// budgetedProxies renders a client's Clash proxies, keeping those the request's budget admits;
// the YAML is written only at the end, and its JSON form is about as long.
func (s *SubClashService) budgetedProxies(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []map[string]any {
if subReq.rendered.spent() {
return nil
}
proxies := s.getProxies(subReq, inbound, client, host)
for i, proxy := range proxies {
text, _ := json.Marshal(proxy)
if !subReq.rendered.take(len(text)) {
return proxies[:i]
}
}
return proxies
}
+71
View File
@@ -0,0 +1,71 @@
package sub
import (
"fmt"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// budgetRepeats copies of budgetRemark are well past maxRenderedBytes.
var budgetRemark = strings.Repeat("r", 100_000)
const budgetRepeats = 200
func renderedSubscriptionSizes(t *testing.T, subId string) map[string]int {
t.Helper()
base := NewSubService("")
links, _, _, _, err := base.GetSubs(subId, "req.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
jsonBody, _, err := NewSubJsonService("", "", "", "", base).GetJson(subId, "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
clashBody, _, err := NewSubClashService(false, "", false, base).GetClash(subId, "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
return map[string]int{"raw": len(strings.Join(links, "\n")), "json": len(jsonBody), "clash": len(clashBody)}
}
// The final documents indent the booked JSON configs again and list every Clash
// proxy name a second time in the PROXY group, hence the factor of two.
func assertBoundedSubscription(t *testing.T, subId string) {
t.Helper()
for format, size := range renderedSubscriptionSizes(t, subId) {
if size < len(budgetRemark) || size > 2*maxRenderedBytes {
t.Errorf("%s subscription is %d bytes, want %d..%d", format, size, len(budgetRemark), 2*maxRenderedBytes)
}
}
}
// Clients sharing a subId repeat the remark just like endpoints do, each in a
// fan-out of its own, so the response as a whole needs the bound too.
func TestSubscriptionBoundsRenderedSizeAcrossClients(t *testing.T) {
seedSubDB(t)
db := database.GetDB()
ib := seedSubInbound(t, "s1", "many", 4431, 1, `{"network":"tcp","security":"none"}`)
var clients strings.Builder
for i := range budgetRepeats {
uuid, email := fmt.Sprintf("11111111-2222-4333-8444-%012d", i), fmt.Sprintf("c%d@e", i)
fmt.Fprintf(&clients, `{"id":%q,"email":%q,"subId":"s1","enable":true},`, uuid, email)
record := &model.ClientRecord{Email: email, SubID: "s1", UUID: uuid, Enable: true}
if err := db.Create(record).Error; err != nil {
t.Fatalf("seed client %s: %v", email, err)
}
if err := db.Create(&model.ClientInbound{ClientId: record.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatalf("seed client_inbound %s: %v", email, err)
}
}
if err := db.Model(ib).Updates(map[string]any{
"remark": budgetRemark,
"settings": `{"clients":[` + strings.TrimSuffix(clients.String(), ",") + `],"decryption":"none"}`,
}).Error; err != nil {
t.Fatalf("update inbound: %v", err)
}
assertBoundedSubscription(t, "s1")
}
+14 -2
View File
@@ -79,6 +79,8 @@ type SubService struct {
// with the clients array left out; generators read only inbound-level
// fields (encryption, method, version, …) from it.
settingsByInbound map[int]map[string]any
// rendered is what this request has put into its response so far.
rendered renderBudget
// settingsCache is shared by this service's requests (ForRequest copies the pointer); only
// subscription bodies read through it, never panel link rendering.
settingsCache *inboundSettingsCache
@@ -131,6 +133,7 @@ func (s *SubService) PrepareForRequest(host string) {
s.hostsByInbound = nil
s.hostGroupOrders = nil
s.sharedGroupSni = nil
s.rendered = 0
mode, _ := s.settingService.GetSubHostSortMode()
s.hostsFirst = mode == "hosts-first"
s.loadNodes()
@@ -525,6 +528,10 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
if client.Enable {
hasEnabledClient = true
}
if s.rendered.spent() {
seenEmails[client.Email] = struct{}{}
continue
}
var link string
if s.hostsFirst {
variants, closed := s.clientHostVariants(inbound, client, "raw")
@@ -552,7 +559,7 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
} else {
link = s.GetLink(inbound, client.Email)
}
if !s.hostsFirst {
if !s.hostsFirst && s.keepLink(link) {
result = append(result, link)
}
emails = append(emails, client.Email)
@@ -561,7 +568,12 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
}
if s.hostsFirst {
for _, render := range orderedSubValues(ordered) {
result = append(result, render())
if s.rendered.spent() {
break
}
if link := render(); s.keepLink(link) {
result = append(result, link)
}
}
}
for _, ext := range externalLinks {