mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-09 06:06:08 +00:00
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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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: [{
|
||||
|
||||
Reference in New Issue
Block a user