Skip to content

Commit 6db17e7

Browse files
committed
feat: 升级 VLESS xHTTP 的解析/导出兼容性,支持 session-id 表达式字段并强化范围校验
- 新增 strict-positive range 归一化逻辑,xPaddingBytes、sc-max-each-post-bytes、session-length 改为严格正数/范围处理,边界行为更准确 - parser 与 producer 同步支持 sessionIDTable/sessionIDLength,兼容 xhttp 中 session-table/session-length 的双向映射 - 将会话位置/密钥字段名统一为 sessionIDPlacement/sessionIDKey,避免与旧字段冲突 - 保留 xhttp headers 中的空字符串值,修复 URI VLESS round-trip 时空 header 被丢弃的问题 - 增强不合法值降级到 `_extra_unsupported` 的处理,并补充 parser/producers 结构化与文本导出测试(含空表名、范围合法性、非法值过滤)
1 parent 3c9a33d commit 6db17e7

7 files changed

Lines changed: 607 additions & 117 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sub-store",
3-
"version": "2.24.10",
3+
"version": "2.24.11",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"packageManager": "pnpm@11.0.9",

backend/src/core/proxy-utils/parsers/index.js

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
normalizeXhttpIntegerValue,
2323
normalizeXhttpNonNegativeRange,
2424
normalizeXhttpPositiveRange,
25-
normalizeXhttpScalarUpperBound,
25+
normalizeXhttpStrictPositiveRangeString,
26+
normalizeXhttpStrictPositiveRangeValue,
2627
} from '../xhttp-utils';
2728
import { extractPathQueryParam, getPathQueryParam } from '../transport-path';
2829
import {
@@ -952,7 +953,7 @@ function URI_VLESS() {
952953

953954
const parsedHeaders = {};
954955
for (const [key, value] of Object.entries(headers)) {
955-
if (typeof value === 'string' && value !== '') {
956+
if (typeof value === 'string') {
956957
parsedHeaders[key] = value;
957958
}
958959
}
@@ -1023,7 +1024,7 @@ function URI_VLESS() {
10231024

10241025
const unsupportedHeaders = {};
10251026
for (const [key, value] of Object.entries(headers)) {
1026-
if (typeof value === 'string' && value !== '') {
1027+
if (typeof value === 'string') {
10271028
continue;
10281029
}
10291030

@@ -1105,18 +1106,47 @@ function URI_VLESS() {
11051106
}
11061107
break;
11071108
case 'xPaddingBytes':
1109+
if (
1110+
normalizeXhttpStrictPositiveRangeString(value) ==
1111+
null
1112+
) {
1113+
setUnsupportedXhttpField(
1114+
unsupportedExtra,
1115+
key,
1116+
value,
1117+
);
1118+
}
1119+
break;
11081120
case 'xPaddingKey':
11091121
case 'xPaddingHeader':
11101122
case 'xPaddingPlacement':
11111123
case 'xPaddingMethod':
11121124
case 'uplinkHTTPMethod':
1125+
case 'sessionIDPlacement':
11131126
case 'sessionPlacement':
1127+
case 'sessionIDKey':
11141128
case 'sessionKey':
11151129
case 'seqPlacement':
11161130
case 'seqKey':
11171131
case 'uplinkDataPlacement':
11181132
case 'uplinkDataKey':
1119-
if (!isNotBlank(value)) {
1133+
if (typeof value !== 'string') {
1134+
setUnsupportedXhttpField(
1135+
unsupportedExtra,
1136+
key,
1137+
value,
1138+
);
1139+
}
1140+
break;
1141+
case 'sessionIDTable':
1142+
// NOTE: Xray-core and mihomo both validate this field
1143+
// together with sessionIDLength when the table is
1144+
// non-empty: the table must stay ASCII, the length
1145+
// range must stay > 0, and the combined ID space must
1146+
// remain large enough. We only do local shape checks
1147+
// here for now, so type-valid values are not
1148+
// automatically upstream-valid yet.
1149+
if (typeof value !== 'string') {
11201150
setUnsupportedXhttpField(
11211151
unsupportedExtra,
11221152
key,
@@ -1134,7 +1164,10 @@ function URI_VLESS() {
11341164
}
11351165
break;
11361166
case 'scMaxEachPostBytes':
1137-
if (normalizeXhttpScalarUpperBound(value) == null) {
1167+
if (
1168+
normalizeXhttpStrictPositiveRangeString(value) ==
1169+
null
1170+
) {
11381171
setUnsupportedXhttpField(
11391172
unsupportedExtra,
11401173
key,
@@ -1151,6 +1184,23 @@ function URI_VLESS() {
11511184
);
11521185
}
11531186
break;
1187+
case 'sessionIDLength':
1188+
// NOTE: Xray-core and mihomo treat this as a coupled
1189+
// session-table/session-length constraint rather than a
1190+
// standalone positive range. Keeping only the local
1191+
// range normalization here does not guarantee the pair
1192+
// will pass upstream validation.
1193+
if (
1194+
normalizeXhttpStrictPositiveRangeString(value) ==
1195+
null
1196+
) {
1197+
setUnsupportedXhttpField(
1198+
unsupportedExtra,
1199+
key,
1200+
value,
1201+
);
1202+
}
1203+
break;
11541204
case 'xmux': {
11551205
const unsupportedXmux = collectUnsupportedXmux(value);
11561206
if (unsupportedXmux !== undefined) {
@@ -1543,8 +1593,11 @@ function URI_VLESS() {
15431593
if (extra.noGRPCHeader === true) {
15441594
target['no-grpc-header'] = true;
15451595
}
1546-
if (isNotBlank(extra.xPaddingBytes)) {
1547-
target['x-padding-bytes'] = extra.xPaddingBytes;
1596+
const xPaddingBytes = normalizeXhttpStrictPositiveRangeString(
1597+
extra.xPaddingBytes,
1598+
);
1599+
if (xPaddingBytes != null) {
1600+
target['x-padding-bytes'] = xPaddingBytes;
15481601
}
15491602
if (extra.xPaddingObfsMode === true) {
15501603
target['x-padding-obfs-mode'] = true;
@@ -1564,12 +1617,27 @@ function URI_VLESS() {
15641617
if (isNotBlank(extra.uplinkHTTPMethod)) {
15651618
target['uplink-http-method'] = extra.uplinkHTTPMethod;
15661619
}
1567-
if (isNotBlank(extra.sessionPlacement)) {
1620+
if (isNotBlank(extra.sessionIDPlacement)) {
1621+
target['session-placement'] = extra.sessionIDPlacement;
1622+
} else if (isNotBlank(extra.sessionPlacement)) {
15681623
target['session-placement'] = extra.sessionPlacement;
15691624
}
1570-
if (isNotBlank(extra.sessionKey)) {
1625+
if (isNotBlank(extra.sessionIDKey)) {
1626+
target['session-key'] = extra.sessionIDKey;
1627+
} else if (isNotBlank(extra.sessionKey)) {
15711628
target['session-key'] = extra.sessionKey;
15721629
}
1630+
if (typeof extra.sessionIDTable === 'string') {
1631+
target['session-table'] = extra.sessionIDTable;
1632+
}
1633+
1634+
const sessionIDLength = normalizeXhttpStrictPositiveRangeString(
1635+
extra.sessionIDLength,
1636+
);
1637+
if (sessionIDLength != null) {
1638+
target['session-length'] = sessionIDLength;
1639+
}
1640+
15731641
if (isNotBlank(extra.seqPlacement)) {
15741642
target['seq-placement'] = extra.seqPlacement;
15751643
}
@@ -1590,7 +1658,7 @@ function URI_VLESS() {
15901658
target['uplink-chunk-size'] = uplinkChunkSize;
15911659
}
15921660

1593-
const scMaxEachPostBytes = normalizeXhttpScalarUpperBound(
1661+
const scMaxEachPostBytes = normalizeXhttpStrictPositiveRangeValue(
15941662
extra.scMaxEachPostBytes,
15951663
);
15961664
if (scMaxEachPostBytes != null) {

backend/src/core/proxy-utils/producers/uri.js

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
normalizeXhttpIntegerValue,
88
normalizeXhttpNonNegativeRange,
99
normalizeXhttpPositiveRange,
10-
normalizeXhttpScalarUpperBound,
10+
normalizeXhttpStrictPositiveRangeValue,
1111
} from '../xhttp-utils';
1212
import {
1313
extractPathQueryParam,
@@ -27,7 +27,7 @@ function toStringHeaderMap(headers, { excludeHost = false } = {}) {
2727

2828
const parsedHeaders = {};
2929
for (const [key, value] of Object.entries(headers)) {
30-
if (typeof value !== 'string' || value === '') {
30+
if (typeof value !== 'string') {
3131
continue;
3232
}
3333
if (excludeHost && /^host$/i.test(key)) {
@@ -209,10 +209,28 @@ function applyStructuredXhttpExtraFields(
209209
target.uplinkHTTPMethod = xhttpOpts['uplink-http-method'];
210210
}
211211
if (xhttpOpts['session-placement']) {
212-
target.sessionPlacement = xhttpOpts['session-placement'];
212+
target.sessionIDPlacement = xhttpOpts['session-placement'];
213213
}
214214
if (xhttpOpts['session-key']) {
215-
target.sessionKey = xhttpOpts['session-key'];
215+
target.sessionIDKey = xhttpOpts['session-key'];
216+
}
217+
if (typeof xhttpOpts['session-table'] === 'string') {
218+
// NOTE: This mirrors the current structured field mapping only.
219+
// Xray-core/mihomo still apply coupled validation with
220+
// session-length when the table is non-empty: ASCII-only table,
221+
// strictly positive length range, and enough total ID space.
222+
target.sessionIDTable = xhttpOpts['session-table'];
223+
}
224+
if (xhttpOpts['session-length'] != null) {
225+
// NOTE: The normalized range here is only a local serialization check.
226+
// Upstream compatibility still depends on the session-table/session-
227+
// length pair satisfying the extra Xray-core/mihomo constraints.
228+
const sessionIDLength = normalizeXhttpStrictPositiveRangeValue(
229+
xhttpOpts['session-length'],
230+
);
231+
if (sessionIDLength != null) {
232+
target.sessionIDLength = sessionIDLength;
233+
}
216234
}
217235
if (xhttpOpts['seq-placement']) {
218236
target.seqPlacement = xhttpOpts['seq-placement'];
@@ -235,7 +253,7 @@ function applyStructuredXhttpExtraFields(
235253
}
236254

237255
if (xhttpOpts['sc-max-each-post-bytes'] != null) {
238-
const scMaxEachPostBytes = normalizeXhttpScalarUpperBound(
256+
const scMaxEachPostBytes = normalizeXhttpStrictPositiveRangeValue(
239257
xhttpOpts['sc-max-each-post-bytes'],
240258
);
241259
if (scMaxEachPostBytes != null) {
@@ -526,9 +544,7 @@ function buildVlessExtra(proxy) {
526544
// structured Mihomo node so later edits are reflected on export, while
527545
// `_extra_unsupported` fills the holes needed for VLESS URI -> node ->
528546
// VLESS URI lossless round-trips. That also means supported-field format
529-
// conflicts are resolved by the structured emitters here, e.g.
530-
// sc-max-each-post-bytes still emits the compatibility upper bound while
531-
// sc-min-posts-interval-ms keeps range.
547+
// conflicts are resolved by the structured emitters here.
532548
const mergedExtra = mergeUnsupportedXhttpExtraObject(
533549
structuredExtra,
534550
proxy._extra_unsupported,

backend/src/core/proxy-utils/xhttp-utils.js

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
function parseNormalizedXhttpRangeBounds(
22
value,
3-
{ allowZeroUpperBound = true } = {},
3+
{ allowZeroLowerBound = true, allowZeroUpperBound = true } = {},
44
) {
55
if (typeof value !== 'string' && typeof value !== 'number') {
66
return undefined;
@@ -22,9 +22,14 @@ function parseNormalizedXhttpRangeBounds(
2222

2323
const normalizedValue = `${value}`.trim();
2424
const rangeParts = normalizedValue.split('-');
25+
const minimumAllowedLowerBound = allowZeroLowerBound ? 0 : 1;
26+
const minimumAllowedUpperBound = allowZeroUpperBound ? 0 : 1;
2527
if (rangeParts.length === 1) {
2628
const normalizedInteger = parseUnsignedIntegerToken(rangeParts[0]);
27-
const minimumAllowedValue = allowZeroUpperBound ? 0 : 1;
29+
const minimumAllowedValue = Math.max(
30+
minimumAllowedLowerBound,
31+
minimumAllowedUpperBound,
32+
);
2833
return normalizedInteger >= minimumAllowedValue
2934
? {
3035
lowerBound: normalizedInteger,
@@ -43,8 +48,9 @@ function parseNormalizedXhttpRangeBounds(
4348
return undefined;
4449
}
4550

46-
const minimumAllowedUpperBound = allowZeroUpperBound ? 0 : 1;
47-
return upperBound >= minimumAllowedUpperBound && upperBound >= lowerBound
51+
return lowerBound >= minimumAllowedLowerBound &&
52+
upperBound >= minimumAllowedUpperBound &&
53+
upperBound >= lowerBound
4854
? {
4955
lowerBound,
5056
upperBound,
@@ -58,24 +64,14 @@ function parseNormalizedXhttpPositiveRangeBounds(value) {
5864
});
5965
}
6066

61-
export function normalizeXhttpScalarUpperBound(value) {
62-
// IMPORTANT: the legacy-client compatibility reason for collapsing ranges
63-
// to an upper bound specifically applies to sc-max-each-post-bytes.
64-
// Mihomo first shipped sc-max-each-post-bytes as an int-only field, then
65-
// only later added true range support, so emitting `lower-upper` here can
66-
// still break older clients that predate that change. Once the new
67-
// official Mihomo stable release with ranged sc-max-each-post-bytes is
68-
// broadly deployed, switch that field back to preserving full ranges and
69-
// remove the upper-bound compatibility logic tied to it.
70-
const normalizedBounds = parseNormalizedXhttpPositiveRangeBounds(value);
71-
return normalizedBounds?.upperBound;
67+
function parseNormalizedXhttpStrictPositiveRangeBounds(value) {
68+
return parseNormalizedXhttpRangeBounds(value, {
69+
allowZeroLowerBound: false,
70+
allowZeroUpperBound: false,
71+
});
7272
}
7373

7474
export function normalizeXhttpPositiveRange(value) {
75-
// IMPORTANT: unlike sc-max-each-post-bytes, sc-min-posts-interval-ms does
76-
// not need an old-client compatibility shim. Mihomo introduced
77-
// sc-min-posts-interval-ms with range semantics from day one, so we should
78-
// keep exporting its full normalized range form instead of collapsing it.
7975
const normalizedBounds = parseNormalizedXhttpPositiveRangeBounds(value);
8076
if (!normalizedBounds) {
8177
return undefined;
@@ -85,6 +81,26 @@ export function normalizeXhttpPositiveRange(value) {
8581
return lowerBound === upperBound ? upperBound : `${lowerBound}-${upperBound}`;
8682
}
8783

84+
export function normalizeXhttpStrictPositiveRangeString(value) {
85+
const normalizedBounds = parseNormalizedXhttpStrictPositiveRangeBounds(value);
86+
if (!normalizedBounds) {
87+
return undefined;
88+
}
89+
90+
const { lowerBound, upperBound } = normalizedBounds;
91+
return lowerBound === upperBound ? `${upperBound}` : `${lowerBound}-${upperBound}`;
92+
}
93+
94+
export function normalizeXhttpStrictPositiveRangeValue(value) {
95+
const normalizedBounds = parseNormalizedXhttpStrictPositiveRangeBounds(value);
96+
if (!normalizedBounds) {
97+
return undefined;
98+
}
99+
100+
const { lowerBound, upperBound } = normalizedBounds;
101+
return lowerBound === upperBound ? upperBound : `${lowerBound}-${upperBound}`;
102+
}
103+
88104
export function normalizeXhttpNonNegativeRange(value) {
89105
const normalizedBounds = parseNormalizedXhttpRangeBounds(value);
90106
if (!normalizedBounds) {

0 commit comments

Comments
 (0)