mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-07 05:35:00 +03:00
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.
This commit is contained in:
@@ -210,7 +210,10 @@ func buildJsonRoutingSpec(object map[string]any) (jsonRoutingSpec, error) {
|
|||||||
for _, segment := range strings.Split(order, "-") {
|
for _, segment := range strings.Split(order, "-") {
|
||||||
switch segment {
|
switch segment {
|
||||||
case "block", "proxy", "direct":
|
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
|
return spec, nil
|
||||||
|
|||||||
@@ -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) {
|
func TestParseJsonRoutingSpecDeeplinks(t *testing.T) {
|
||||||
payload := mustMarshal(t, fullRoutingPayload())
|
payload := mustMarshal(t, fullRoutingPayload())
|
||||||
cases := []string{
|
cases := []string{
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ const (
|
|||||||
remoteRoutingHappMaxBody = 16 << 10 // 16 KiB; Happ emits the result in a response header
|
remoteRoutingHappMaxBody = 16 << 10 // 16 KiB; Happ emits the result in a response header
|
||||||
remoteRoutingHappMaxValue = 8 << 10 // normalized Routing header value
|
remoteRoutingHappMaxValue = 8 << 10 // normalized Routing header value
|
||||||
remoteRoutingClashMaxBody = 2 << 20 // 2 MiB
|
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:
|
// 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 {
|
if len(document) == 0 {
|
||||||
return "", nil, errors.New("Clash routing response must be a YAML map")
|
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
|
hasSupportedKey := false
|
||||||
for key := range document {
|
for key := range document {
|
||||||
if remoteClashKeyClasses[key] == remoteClashRouteGraph {
|
if remoteClashKeyClasses[key] == remoteClashRouteGraph {
|
||||||
@@ -471,6 +479,43 @@ func normalizeClashRouting(body []byte) (string, map[string]any, error) {
|
|||||||
return text, document, nil
|
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) {
|
func resolveIncyRoutingSource(raw string) (string, bool, error) {
|
||||||
source, remote, err := common.ParseRemoteRoutingURL(raw)
|
source, remote, err := common.ParseRemoteRoutingURL(raw)
|
||||||
if err != nil || !remote {
|
if err != nil || !remote {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sub
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -748,3 +749,32 @@ rules:
|
|||||||
t.Fatalf("requests=%d, want 1", requests.Load())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user