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:
MHSanaei
2026-10-31 08:59:23 +00:00
parent 8359a0f72e
commit 661a169045
4 changed files with 91 additions and 1 deletions
+45
View File
@@ -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 {