diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 5739a9329..e396f7fcc 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -238,33 +238,53 @@ const QUIC_PARAMS_NUMERIC_KEYS = [ const DURATION_SECONDS: Record = { ms: 0.001, s: 1, m: 60, h: 3600 }; +const QUIC_NUMERIC_MAX = 1e15; + +function coerceQuicNumeric(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value === 'string') { + const asNumber = Number(value); + if (value.trim() !== '' && Number.isFinite(asNumber)) { + return Math.trunc(asNumber); + } + const duration = /^(-?\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value.trim()); + if (duration) { + return Math.trunc(Number(duration[1]) * DURATION_SECONDS[duration[2]]); + } + } + return null; +} + +function clampQuicNumeric(key: string, n: number): number | null { + if (n < 0 || n > QUIC_NUMERIC_MAX) return null; + if (n === 0) return 0; + if (key === 'keepAlivePeriod') return Math.min(Math.max(n, 2), 60); + if (key === 'maxIdleTimeout') return Math.min(Math.max(n, 4), 120); + if (key === 'maxIncomingStreams') return Math.max(n, 8); + return n; +} + // 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). +// plain integers within its accepted ranges (keepAlivePeriod 0 or 2-60, +// maxIdleTimeout 0 or 4-120, maxIncomingStreams 0 or >= 8), so coerce +// numeric/duration strings, clamp the ranged fields, and drop anything +// unparseable, negative, or absurdly large (#5783). Mirrors the Go parser in +// internal/util/link/outbound.go. 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); + const coerced = coerceQuicNumeric(quic[key]); + const clamped = coerced === null ? null : clampQuicNumeric(key, coerced); + if (clamped === null) { + delete quic[key]; 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]; + quic[key] = clamped; } } diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 6683164eb..ef1fe455b 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -330,6 +330,30 @@ describe('parseHysteria2Link', () => { expect(quic.brutalUp).toBe('100 mbps'); }); + it('clamps quicParams to the ranges xray accepts and drops junk — #5783', () => { + const fm = encodeURIComponent(JSON.stringify({ + quicParams: { + keepAlivePeriod: '1s', + maxIdleTimeout: '10m', + maxIncomingStreams: 4, + initStreamReceiveWindow: 'inf', + maxStreamReceiveWindow: -5, + initConnectionReceiveWindow: 1e30, + }, + })); + const link = `hysteria2://78e7795a209c4c099f896a816fc8448f@news.domain.org:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-clamp`; + 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(2); + expect(quic.maxIdleTimeout).toBe(120); + expect(quic.maxIncomingStreams).toBe(8); + expect('initStreamReceiveWindow' in quic).toBe(false); + expect('maxStreamReceiveWindow' in quic).toBe(false); + expect('initConnectionReceiveWindow' in quic).toBe(false); + }); + 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 df1689562..f89c84231 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -668,9 +668,11 @@ func applyFinalMask(stream map[string]any, p url.Values) { // 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). +// arrives as a duration string like "10s" or an out-of-range integer, so +// numeric strings are parsed, duration strings are converted to whole +// seconds, the ranged fields are clamped to what xray accepts, and anything +// non-finite, negative, absurdly large, or unparseable is dropped so a bad +// value falls back to xray's default instead of killing the config (#5783). func sanitizeFinalMaskQuicParams(parsed any) { fm, ok := parsed.(map[string]any) if !ok { @@ -690,21 +692,56 @@ func sanitizeFinalMaskQuicParams(parsed any) { 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: + n, ok := coerceQuicNumeric(raw) + if ok { + n, ok = clampQuicNumeric(key, n) + } + if !ok { delete(qp, key) + continue + } + qp[key] = int64(n) + } +} + +func coerceQuicNumeric(raw any) (float64, bool) { + switch v := raw.(type) { + case float64: + return math.Trunc(v), true + case string: + if n, err := strconv.ParseFloat(v, 64); err == nil && !math.IsInf(n, 0) && !math.IsNaN(n) { + return math.Trunc(n), true + } + if d, err := time.ParseDuration(v); err == nil { + return math.Trunc(d.Seconds()), true } } + return 0, false +} + +// clampQuicNumeric enforces xray-core's QuicParamsConfig validation so a +// coerced value cannot still fail the config load: keepAlivePeriod is 0 or +// 2-60, maxIdleTimeout is 0 or 4-120, maxIncomingStreams is 0 or >= 8. +// quicNumericMax keeps values in plain-integer JSON territory and far below +// the uint64 window fields' range. +const quicNumericMax = float64(1e15) + +func clampQuicNumeric(key string, n float64) (float64, bool) { + if n < 0 || n > quicNumericMax { + return 0, false + } + if n == 0 { + return 0, true + } + switch key { + case "keepAlivePeriod": + return math.Min(math.Max(n, 2), 60), true + case "maxIdleTimeout": + return math.Min(math.Max(n, 4), 120), true + case "maxIncomingStreams": + return math.Max(n, 8), true + } + return n, true } func firstNonEmpty(a, b string) string { diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index ebb3b9b88..dacebd4ed 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -57,13 +57,13 @@ func TestParseVlessLink_FinalMaskQuicParamsSanitized(t *testing.T) { if !ok { t.Fatalf("missing quicParams: %v", finalmask) } - if got := qp["keepAlivePeriod"]; got != float64(10) { + if got := qp["keepAlivePeriod"]; got != int64(10) { t.Errorf("keepAlivePeriod: expected 10, got %v (%T)", got, got) } - if got := qp["maxIdleTimeout"]; got != float64(30) { + if got := qp["maxIdleTimeout"]; got != int64(30) { t.Errorf("maxIdleTimeout: expected 30, got %v (%T)", got, got) } - if got := qp["initStreamReceiveWindow"]; got != float64(524288) { + if got := qp["initStreamReceiveWindow"]; got != int64(524288) { t.Errorf("initStreamReceiveWindow: expected 524288, got %v (%T)", got, got) } if _, exists := qp["maxIncomingStreams"]; exists { @@ -74,6 +74,45 @@ func TestParseVlessLink_FinalMaskQuicParamsSanitized(t *testing.T) { } } +func TestSanitizeFinalMaskQuicParams_ClampsAndRejects(t *testing.T) { + cases := []struct { + name string + key string + in any + want any + }{ + {"infinite string dropped", "keepAlivePeriod", "inf", nil}, + {"nan string dropped", "keepAlivePeriod", "NaN", nil}, + {"negative dropped", "maxStreamReceiveWindow", float64(-5), nil}, + {"negative duration dropped", "keepAlivePeriod", "-10s", nil}, + {"absurd magnitude dropped", "initConnectionReceiveWindow", float64(1e30), nil}, + {"keepAlive clamped up", "keepAlivePeriod", "1s", int64(2)}, + {"keepAlive clamped down", "keepAlivePeriod", "90s", int64(60)}, + {"idle clamped up", "maxIdleTimeout", float64(1), int64(4)}, + {"idle clamped down", "maxIdleTimeout", "10m", int64(120)}, + {"streams clamped up", "maxIncomingStreams", float64(4), int64(8)}, + {"zero means unset and survives", "maxIdleTimeout", float64(0), int64(0)}, + {"window passes through", "initStreamReceiveWindow", float64(524288), int64(524288)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + parsed := map[string]any{"quicParams": map[string]any{c.key: c.in}} + sanitizeFinalMaskQuicParams(parsed) + qp := parsed["quicParams"].(map[string]any) + got, exists := qp[c.key] + if c.want == nil { + if exists { + t.Fatalf("%s: expected key dropped, got %v (%T)", c.key, got, got) + } + return + } + if !exists || got != c.want { + t.Fatalf("%s: expected %v, got %v (%T)", c.key, c.want, 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