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