From 11e45e81b678eec9e361418e068a5cf8a2466f58 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 5 Jul 2026 20:16:57 +0200 Subject: [PATCH] fix(link): sanitize numeric quicParams taken from a share link's fm= param The fm= finalmask blob was JSON-decoded and attached to streamSettings verbatim, both by the Go parser (outbound subscriptions) and the frontend import. Some providers emit duration strings for the strictly integer quicParams fields (e.g. keepAlivePeriod "10s"), and xray-core then refuses to load the whole config at startup - one bad subscription entry took the panel's Xray down on the next refresh. Coerce numeric strings, convert duration strings to whole seconds, and drop values that cannot be represented as integers; genuinely string-typed fields (congestion, bbrProfile, brutalUp/Down, udpHop) pass through untouched. Closes #5783 --- frontend/src/lib/xray/outbound-link-parser.ts | 43 ++++++++++++++++++ .../src/test/outbound-link-parser.test.ts | 22 +++++++++ internal/util/link/outbound.go | 45 +++++++++++++++++++ internal/util/link/outbound_test.go | 39 ++++++++++++++++ 4 files changed, 149 insertions(+) diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 632e9157f..5739a9329 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -218,6 +218,7 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void { try { const parsed = JSON.parse(fm) as Record; if (parsed && typeof parsed === 'object') { + sanitizeFinalMaskQuicParams(parsed); stream.finalmask = parsed; } } catch { @@ -225,6 +226,48 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void { } } +const QUIC_PARAMS_NUMERIC_KEYS = [ + 'initStreamReceiveWindow', + 'maxStreamReceiveWindow', + 'initConnectionReceiveWindow', + 'maxConnectionReceiveWindow', + 'maxIdleTimeout', + 'keepAlivePeriod', + 'maxIncomingStreams', +] as const; + +const DURATION_SECONDS: Record = { ms: 0.001, s: 1, m: 60, h: 3600 }; + +// xray-core rejects the whole config when these quicParams fields are not +// plain integers (e.g. keepAlivePeriod "10s" from a foreign provider), so +// coerce numeric/duration strings and drop anything unparseable (#5783). +function sanitizeFinalMaskQuicParams(parsed: Record): void { + const raw = parsed.quicParams; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return; + const quic = raw as Record; + for (const key of QUIC_PARAMS_NUMERIC_KEYS) { + if (!(key in quic)) continue; + const value = quic[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + quic[key] = Math.trunc(value); + continue; + } + if (typeof value === 'string') { + const asNumber = Number(value); + if (value.trim() !== '' && Number.isFinite(asNumber)) { + quic[key] = Math.trunc(asNumber); + continue; + } + const duration = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value.trim()); + if (duration) { + quic[key] = Math.trunc(Number(duration[1]) * DURATION_SECONDS[duration[2]]); + continue; + } + } + delete quic[key]; + } +} + function applySecurityParams(stream: Raw, params: URLSearchParams): void { if (stream.security === 'tls') { const tls = stream.tlsSettings as Raw; diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 0198fa958..6683164eb 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -308,6 +308,28 @@ describe('parseHysteria2Link', () => { expect(settings.packetSize).toBe('100-200'); }); + it('coerces string quicParams numerics under fm to integers — #5783', () => { + const fm = encodeURIComponent(JSON.stringify({ + quicParams: { + keepAlivePeriod: '10s', + maxIdleTimeout: '30', + initStreamReceiveWindow: 524288, + maxIncomingStreams: true, + brutalUp: '100 mbps', + }, + })); + const link = `hysteria2://78e7795a209c4c099f896a816fc8448f@news.domain.org:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-quic`; + const out = parseHysteria2Link(link); + expect(out).not.toBeNull(); + const finalmask = (out!.streamSettings as Record).finalmask as Record; + const quic = finalmask.quicParams as Record; + expect(quic.keepAlivePeriod).toBe(10); + expect(quic.maxIdleTimeout).toBe(30); + expect(quic.initStreamReceiveWindow).toBe(524288); + expect('maxIncomingStreams' in quic).toBe(false); + expect(quic.brutalUp).toBe('100 mbps'); + }); + it('round-trips the realm tlsConfig under fm', () => { const fm = encodeURIComponent(JSON.stringify({ udp: [{ diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 8d941ac16..df1689562 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -8,10 +8,12 @@ import ( "encoding/base64" "encoding/json" "fmt" + "math" "net/url" "regexp" "strconv" "strings" + "time" ) // Outbound is the minimal shape we emit for each parsed link. @@ -657,11 +659,54 @@ func applyFinalMask(stream map[string]any, p url.Values) { if fm := p.Get("fm"); fm != "" { var parsed any if json.Unmarshal([]byte(fm), &parsed) == nil { + sanitizeFinalMaskQuicParams(parsed) stream["finalmask"] = parsed } } } +// sanitizeFinalMaskQuicParams coerces the strictly numeric quicParams fields +// of a finalmask blob taken verbatim from a share link's fm= parameter. +// Xray-core rejects the whole config at startup when e.g. keepAlivePeriod +// arrives as a duration string like "10s", so numeric strings are parsed, +// duration strings are converted to whole seconds, and anything else is +// dropped rather than passed through (#5783). +func sanitizeFinalMaskQuicParams(parsed any) { + fm, ok := parsed.(map[string]any) + if !ok { + return + } + qp, ok := fm["quicParams"].(map[string]any) + if !ok { + return + } + numericKeys := []string{ + "initStreamReceiveWindow", "maxStreamReceiveWindow", + "initConnectionReceiveWindow", "maxConnectionReceiveWindow", + "maxIdleTimeout", "keepAlivePeriod", "maxIncomingStreams", + } + for _, key := range numericKeys { + raw, exists := qp[key] + if !exists { + continue + } + switch v := raw.(type) { + case float64: + qp[key] = math.Trunc(v) + case string: + if n, err := strconv.ParseFloat(v, 64); err == nil { + qp[key] = math.Trunc(n) + } else if d, err := time.ParseDuration(v); err == nil { + qp[key] = math.Trunc(d.Seconds()) + } else { + delete(qp, key) + } + default: + delete(qp, key) + } + } +} + func firstNonEmpty(a, b string) string { if a != "" { return a diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index 43d2a6079..ebb3b9b88 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -1,6 +1,7 @@ package link import ( + "net/url" "strings" "testing" ) @@ -35,6 +36,44 @@ func TestParseVlessLink(t *testing.T) { } } +func TestParseVlessLink_FinalMaskQuicParamsSanitized(t *testing.T) { + fm := url.QueryEscape(`{"mask":"dtls","quicParams":{"keepAlivePeriod":"10s","maxIdleTimeout":"30","initStreamReceiveWindow":524288,"maxIncomingStreams":true,"brutalUp":"100 mbps"}}`) + res, err := ParseLink("vless://uuid@1.2.3.4:443?type=tcp&security=none&fm=" + fm + "#node1") + if err != nil { + t.Fatalf("parse vless with fm: %v", err) + } + stream, ok := res.Outbound["streamSettings"].(map[string]any) + if !ok { + t.Fatalf("missing streamSettings: %v", res.Outbound) + } + finalmask, ok := stream["finalmask"].(map[string]any) + if !ok { + t.Fatalf("missing finalmask: %v", stream) + } + if finalmask["mask"] != "dtls" { + t.Errorf("mask changed: %v", finalmask["mask"]) + } + qp, ok := finalmask["quicParams"].(map[string]any) + if !ok { + t.Fatalf("missing quicParams: %v", finalmask) + } + if got := qp["keepAlivePeriod"]; got != float64(10) { + t.Errorf("keepAlivePeriod: expected 10, got %v (%T)", got, got) + } + if got := qp["maxIdleTimeout"]; got != float64(30) { + t.Errorf("maxIdleTimeout: expected 30, got %v (%T)", got, got) + } + if got := qp["initStreamReceiveWindow"]; got != float64(524288) { + t.Errorf("initStreamReceiveWindow: expected 524288, got %v (%T)", got, got) + } + if _, exists := qp["maxIncomingStreams"]; exists { + t.Errorf("maxIncomingStreams should be dropped, got %v", qp["maxIncomingStreams"]) + } + if got := qp["brutalUp"]; got != "100 mbps" { + t.Errorf("brutalUp should stay a string, got %v (%T)", got, got) + } +} + func TestParseSubscriptionBody_Base64(t *testing.T) { // base64 of the two joined links: // vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B