Просмотр исходного кода

fix(link): reject non-finite and clamp out-of-range quicParams from fm=

Follow-up hardening of the fm= sanitizer found in review. ParseFloat
accepts "inf"/"NaN", and a non-finite float64 makes json.Marshal fail
later - the subscription refresh discards that error and blanks the
stored outbound set, so one poisoned link could wipe a subscription's
outbounds. Values that coerce fine but sit outside xray-core's accepted
ranges (keepAlivePeriod 0 or 2-60, maxIdleTimeout 0 or 4-120,
maxIncomingStreams 0 or >= 8) still killed the config load, and huge
magnitudes serialize in exponent notation that xray's integer fields
reject. Coerced values are now stored as integers, clamped into the
accepted ranges, and dropped when negative, non-finite, or absurdly
large; the TS import parser mirrors the same rules.

Refs #5783
MHSanaei 18 часов назад
Родитель
Сommit
0753f5ee83

+ 38 - 18
frontend/src/lib/xray/outbound-link-parser.ts

@@ -238,33 +238,53 @@ const QUIC_PARAMS_NUMERIC_KEYS = [
 
 const DURATION_SECONDS: Record<string, number> = { 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<string, unknown>): void {
   const raw = parsed.quicParams;
   if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
   const quic = raw as Record<string, unknown>;
   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;
   }
 }
 

+ 24 - 0
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://[email protected]: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<string, unknown>).finalmask as Record<string, unknown>;
+    const quic = finalmask.quicParams as Record<string, unknown>;
+    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: [{

+ 52 - 15
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 {

+ 42 - 3
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