Files
3x-ui/internal/sub/render_budget_test.go
T
MHSanaei 2467b9d542 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.
2026-11-01 01:15:28 +01:00

72 lines
2.6 KiB
Go

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")
}