From 539bcc897cb28d7f3d6a6c5572ee52c427109e05 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 1 Jul 2026 23:11:58 +0200 Subject: [PATCH] fix(inbounds): apply the legacy xhttp session-key migration when editing rawInboundToFormValues injected the stored xhttpSettings blob into the form store without running it through XHttpStreamSettingsSchema, so the sessionPlacement/sessionKey -> sessionIDPlacement/sessionIDKey rename from xray-core v26.6.22 (and the v3.4.0 field defaults) never applied on the edit path. Inbounds saved before the rename opened with blank session fields, and the stale keys could ride back on save even though the core no longer reads them. Parse the sub-object through the schema on load, and lift any stale legacy keys in normalizeXhttpForWire as a backstop. Closes #5621 --- frontend/src/lib/xray/inbound-form-adapter.ts | 6 ++- .../src/lib/xray/stream-wire-normalize.ts | 13 +++++ .../src/test/inbound-form-adapter.test.ts | 49 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 86151c5ff..dc053b828 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -13,7 +13,7 @@ import type { Sniffing } from '@/schemas/primitives'; import type { z } from 'zod'; import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize'; import { canEnableSniffing } from '@/lib/xray/protocol-capabilities'; -import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; +import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); @@ -164,7 +164,9 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { const streamRecord = streamSettings as unknown as Record; const xh = streamRecord.xhttpSettings; if (xh && typeof xh === 'object' && !Array.isArray(xh)) { - const xhttp = xh as Record; + const parsed = XHttpStreamSettingsSchema.safeParse(xh); + const xhttp = (parsed.success ? parsed.data : xh) as Record; + streamRecord.xhttpSettings = xhttp; const xmux = xhttp.xmux; if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) { xhttp.enableXmux = true; diff --git a/frontend/src/lib/xray/stream-wire-normalize.ts b/frontend/src/lib/xray/stream-wire-normalize.ts index e74e450fb..fddb1c6a4 100644 --- a/frontend/src/lib/xray/stream-wire-normalize.ts +++ b/frontend/src/lib/xray/stream-wire-normalize.ts @@ -108,6 +108,18 @@ export function validateRealityTarget(target: string): string | undefined { return undefined; } +function liftLegacyXhttpSessionKeys(obj: Record): void { + const lift = (legacy: string, renamed: string) => { + const v = obj[legacy]; + if ((obj[renamed] === undefined || obj[renamed] === '') && typeof v === 'string' && v !== '') { + obj[renamed] = v; + } + delete obj[legacy]; + }; + lift('sessionPlacement', 'sessionIDPlacement'); + lift('sessionKey', 'sessionIDKey'); +} + function dropEmptyStrings(obj: Record, keys: readonly string[]): void { for (const key of keys) { const v = obj[key]; @@ -160,6 +172,7 @@ export function normalizeXhttpForWire( side: StreamWireSide, ): Record { const out: Record = { ...raw }; + liftLegacyXhttpSessionKeys(out); const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto'; const enableXmux = out.enableXmux === true; delete out.enableXmux; diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts index 31a687d89..99750d8d6 100644 --- a/frontend/src/test/inbound-form-adapter.test.ts +++ b/frontend/src/test/inbound-form-adapter.test.ts @@ -7,6 +7,7 @@ import { type RawInboundRow, } from '@/lib/xray/inbound-form-adapter'; import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form'; +import { normalizeXhttpForWire } from '@/lib/xray/stream-wire-normalize'; import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; // Round-trip: raw DB row → InboundFormValues → wire payload, asserting @@ -304,3 +305,51 @@ describe('subSortIndex', () => { expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1); }); }); + +describe('legacy xhttp session keys on edit (#5621)', () => { + const legacyXhttpRow: RawInboundRow = { + ...vlessRow, + streamSettings: { + network: 'xhttp', + security: 'none', + xhttpSettings: { + path: '/xh', + mode: 'packet-up', + sessionPlacement: 'cookie', + sessionKey: 'x_session', + }, + }, + }; + + it('rawInboundToFormValues lifts sessionPlacement/sessionKey onto the renamed keys', () => { + const values = rawInboundToFormValues(legacyXhttpRow); + const xhttp = (values.streamSettings as unknown as Record>).xhttpSettings; + expect(xhttp.sessionIDPlacement).toBe('cookie'); + expect(xhttp.sessionIDKey).toBe('x_session'); + expect(xhttp.sessionPlacement).toBeUndefined(); + expect(xhttp.sessionKey).toBeUndefined(); + expect(xhttp.path).toBe('/xh'); + expect(xhttp.xPaddingBytes).toBe('100-1000'); + }); + + it('formValuesToWirePayload never emits the legacy key names', () => { + const values = rawInboundToFormValues(legacyXhttpRow); + const payload = formValuesToWirePayload(values); + const stream = JSON.parse(payload.streamSettings) as Record>; + expect(stream.xhttpSettings.sessionPlacement).toBeUndefined(); + expect(stream.xhttpSettings.sessionKey).toBeUndefined(); + expect(stream.xhttpSettings.sessionIDPlacement).toBe('cookie'); + expect(stream.xhttpSettings.sessionIDKey).toBe('x_session'); + }); + + it('normalizeXhttpForWire lifts stale legacy keys that bypassed the schema', () => { + const out = normalizeXhttpForWire( + { sessionPlacement: 'header', sessionKey: 'x_raw' }, + 'inbound', + ); + expect(out.sessionIDPlacement).toBe('header'); + expect(out.sessionIDKey).toBe('x_raw'); + expect(out.sessionPlacement).toBeUndefined(); + expect(out.sessionKey).toBeUndefined(); + }); +});