From 661a16904551db2d7a97809773ecdc62605c71ed Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:59:23 +0000 Subject: [PATCH] fix(sub): bound what a remote routing profile renders to Remote routing sources are fetched documents the panel re-renders on every subscription request. A Clash YAML document stays small through aliases, which decode to one shared value but are written out in full, so it now has to fit a 4 MiB written-size budget and 16 levels of nesting when it is accepted (and when a persisted copy is loaded). A JSON routing profile could repeat route groups, each copy repeating every site list; a group now counts once, since a repeat can never match. --- internal/sub/json_routing.go | 5 +++- internal/sub/json_routing_test.go | 12 ++++++++ internal/sub/remote_routing.go | 45 +++++++++++++++++++++++++++++ internal/sub/remote_routing_test.go | 30 +++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/internal/sub/json_routing.go b/internal/sub/json_routing.go index 738a9615b..58eb73411 100644 --- a/internal/sub/json_routing.go +++ b/internal/sub/json_routing.go @@ -210,7 +210,10 @@ func buildJsonRoutingSpec(object map[string]any) (jsonRoutingSpec, error) { for _, segment := range strings.Split(order, "-") { switch segment { case "block", "proxy", "direct": - spec.RouteOrder = append(spec.RouteOrder, segment) + // A repeat can never match (the first rule wins) and would copy every list again. + if !slices.Contains(spec.RouteOrder, segment) { + spec.RouteOrder = append(spec.RouteOrder, segment) + } } } return spec, nil diff --git a/internal/sub/json_routing_test.go b/internal/sub/json_routing_test.go index 89cdcb2dd..bfdce9c8c 100644 --- a/internal/sub/json_routing_test.go +++ b/internal/sub/json_routing_test.go @@ -94,6 +94,18 @@ func TestParseJsonRoutingSpecRouteOrderUnknownSegments(t *testing.T) { } } +// Every group copy repeats the profile's whole site lists in the rendered routing, and a +// later copy can never match, so a remote profile repeating groups must not multiply them. +func TestParseJsonRoutingSpecRouteOrderKeepsEachGroupOnce(t *testing.T) { + spec, _, err := parseJsonRoutingSpec(`{"RouteOrder":"` + strings.Repeat("proxy-direct-block-", 500) + `proxy"}`) + if err != nil { + t.Fatalf("parse: %v", err) + } + if strings.Join(spec.RouteOrder, ",") != "proxy,direct,block" { + t.Fatalf("RouteOrder = %d segments, want proxy,direct,block once each", len(spec.RouteOrder)) + } +} + func TestParseJsonRoutingSpecDeeplinks(t *testing.T) { payload := mustMarshal(t, fullRoutingPayload()) cases := []string{ diff --git a/internal/sub/remote_routing.go b/internal/sub/remote_routing.go index b455e0be2..1b15b7cc6 100644 --- a/internal/sub/remote_routing.go +++ b/internal/sub/remote_routing.go @@ -39,6 +39,11 @@ const ( remoteRoutingHappMaxBody = 16 << 10 // 16 KiB; Happ emits the result in a response header remoteRoutingHappMaxValue = 8 << 10 // normalized Routing header value remoteRoutingClashMaxBody = 2 << 20 // 2 MiB + + // What a document may marshal to, aliases copied out; the encoder re-indents a node's text + // once per enclosing level, hence the depth bound. + remoteRoutingClashMaxWritten = 4 << 20 + remoteRoutingClashMaxDepth = 16 ) // isHappPayloadKind reports whether the kind carries a happ-payload source: @@ -448,6 +453,9 @@ func normalizeClashRouting(body []byte) (string, map[string]any, error) { if len(document) == 0 { return "", nil, errors.New("Clash routing response must be a YAML map") } + if budget := remoteRoutingClashMaxWritten; !clashFitsWritten(document, 0, &budget) { + return "", nil, errors.New("Clash routing response is too large or too deeply nested once written out") + } hasSupportedKey := false for key := range document { if remoteClashKeyClasses[key] == remoteClashRouteGraph { @@ -471,6 +479,43 @@ func normalizeClashRouting(body []byte) (string, map[string]any, error) { return text, document, nil } +// clashFitsWritten charges what the encoder writes for v: every string per line at its +// indentation, every node its indentation. It stops as soon as the budget is spent. +func clashFitsWritten(v any, depth int, budget *int) bool { + if depth > remoteRoutingClashMaxDepth { + return false + } + indent := 2*depth + 2 + switch v := v.(type) { + case string: + *budget -= len(v) + (strings.Count(v, "\n")+1)*indent + case []byte: + // The encoder writes !!binary values as a list with one item per byte. + *budget -= len(v) * (indent + 6) + case map[string]any: + for key, child := range v { + if *budget -= len(key) + indent; *budget < 0 || !clashFitsWritten(child, depth+1, budget) { + return false + } + } + case map[any]any: + for key, child := range v { + if *budget -= indent; *budget < 0 || !clashFitsWritten(key, depth+1, budget) || !clashFitsWritten(child, depth+1, budget) { + return false + } + } + case []any: + for _, child := range v { + if *budget -= indent; *budget < 0 || !clashFitsWritten(child, depth+1, budget) { + return false + } + } + default: + *budget -= indent + 24 + } + return *budget >= 0 +} + func resolveIncyRoutingSource(raw string) (string, bool, error) { source, remote, err := common.ParseRemoteRoutingURL(raw) if err != nil || !remote { diff --git a/internal/sub/remote_routing_test.go b/internal/sub/remote_routing_test.go index a22ad7566..f993ff71f 100644 --- a/internal/sub/remote_routing_test.go +++ b/internal/sub/remote_routing_test.go @@ -3,6 +3,7 @@ package sub import ( "encoding/base64" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -748,3 +749,32 @@ rules: t.Fatalf("requests=%d, want 1", requests.Load()) } } + +// Aliases decode to one shared value but the encoder writes every copy out, on each Clash +// request, so a small remote document must not be accepted if it marshals out of bounds. +func TestNormalizeClashRoutingRefusesWhatMarshalsOutOfBounds(t *testing.T) { + var laughs strings.Builder + laughs.WriteString("a0: &a0 [\"" + strings.Repeat("x", 64) + "\"]\n") + for i := 1; i <= 9; i++ { + fmt.Fprintf(&laughs, "a%d: &a%d [", i, i) + for j := range 10 { + if j > 0 { + laughs.WriteString(",") + } + fmt.Fprintf(&laughs, "*a%d", i-1) + } + laughs.WriteString("]\n") + } + bomb := laughs.String() + "rule-providers:\n p: {type: inline, behavior: domain, payload: *a9}\nrules:\n - RULE-SET,p,DIRECT\n - MATCH,DIRECT\n" + if _, _, err := normalizeClashRouting([]byte(bomb)); err == nil || !strings.Contains(err.Error(), "too large") { + t.Fatalf("alias bomb = %v, want it refused as too large", err) + } + deep := "rules:\n - MATCH,DIRECT\nrule-providers:\n p: {type: inline, behavior: domain, payload: " + strings.Repeat("[", 40) + strings.Repeat("]", 40) + "}\n" + if _, _, err := normalizeClashRouting([]byte(deep)); err == nil { + t.Fatal("a document nested 40 levels deep was accepted") + } + anchored := "x: &common {type: http, behavior: domain, interval: 86400}\nrule-providers:\n ads: {<<: *common, url: \"https://example.com/ads.yaml\"}\nrules:\n - RULE-SET,ads,REJECT\n - MATCH,DIRECT\n" + if _, _, err := normalizeClashRouting([]byte(anchored)); err != nil { + t.Fatalf("an ordinary anchored document was refused: %v", err) + } +}