fix(outbounds): prevent freedom save crash, complete its fields (#4686)
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run

freedomToWire called Object.entries(s.fragment), but getFieldsValue(true)
returns freedom settings without a fragment object when the Fragment switch
is off (its sub-fields never register). That threw 'Cannot convert undefined
or null to object' and silently killed the save. Guard fragment with a
fallback so an unset value is treated as empty.

While verifying against xray-core's freedom config, also:
- add the missing userLevel field (schema, form schema, adapter, UI)
- fix noise applyTo enum to ip/ipv4/ipv6 (xray rejects the old host/all)

Closes #4686
This commit is contained in:
MHSanaei 2026-05-31 19:50:50 +02:00
parent c20ee00fa3
commit f02018cfb7
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 39 additions and 4 deletions

View file

@ -266,6 +266,7 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy'];
})(),
redirect: asString(raw.redirect),
userLevel: asNumber(raw.userLevel, 0),
proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
const n = asNumber(raw.proxyProtocol, 0);
return (n === 1 || n === 2) ? n : 0;
@ -506,11 +507,15 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
// Legacy semantics: emit fragment only when the user actually populated
// at least one of the four sub-fields. Defaults like packets='1-3' alone
// are not enough — the modal's Fragment Switch sets all four together.
const fragmentEntries = Object.entries(s.fragment).filter(([, v]) => v !== '' && v != null);
const fragmentEnabled = !!s.fragment.length || !!s.fragment.interval || !!s.fragment.maxSplit;
// getFieldsValue(true) may omit `fragment` when the switch is off, so the
// fallback keeps Object.entries from throwing on undefined (issue #4686).
const fragment: Partial<FreedomOutboundFormSettings['fragment']> = s.fragment ?? {};
const fragmentEntries = Object.entries(fragment).filter(([, v]) => v !== '' && v != null);
const fragmentEnabled = !!fragment.length || !!fragment.interval || !!fragment.maxSplit;
return {
domainStrategy: s.domainStrategy || undefined,
redirect: s.redirect || undefined,
userLevel: s.userLevel || undefined,
proxyProtocol: s.proxyProtocol || undefined,
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
noises: s.noises.length > 0 ? s.noises : undefined,

View file

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Select, Switch, type FormInstance } from 'antd';
import { Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives';
@ -20,6 +20,9 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
<Form.Item label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
<Select
options={[

View file

@ -166,6 +166,7 @@ export type FreedomFinalRuleForm = z.infer<typeof FreedomFinalRuleFormSchema>;
export const FreedomOutboundFormSettingsSchema = z.object({
domainStrategy: z.union([OutboundDomainStrategySchema, z.literal('')]).default(''),
redirect: z.string().default(''),
userLevel: z.number().int().min(0).default(0),
proxyProtocol: z.number().int().min(0).max(2).default(0),
fragment: FreedomFragmentSchema.default({
packets: '1-3',

View file

@ -26,7 +26,7 @@ export const FreedomFragmentSchema = z.object({
export type FreedomFragment = z.infer<typeof FreedomFragmentSchema>;
export const FreedomNoiseTypeSchema = z.enum(['rand', 'str', 'base64', 'hex']);
export const FreedomNoiseApplyToSchema = z.enum(['ip', 'host', 'all']);
export const FreedomNoiseApplyToSchema = z.enum(['ip', 'ipv4', 'ipv6']);
export const FreedomNoiseSchema = z.object({
type: FreedomNoiseTypeSchema.default('rand'),
@ -52,6 +52,7 @@ export type FreedomFinalRule = z.infer<typeof FreedomFinalRuleSchema>;
export const FreedomOutboundSettingsSchema = z.object({
domainStrategy: OutboundDomainStrategySchema.optional(),
redirect: z.string().optional(),
userLevel: z.number().int().min(0).optional(),
proxyProtocol: z.number().optional(),
fragment: FreedomFragmentSchema.optional(),
noises: z.array(FreedomNoiseSchema).optional(),

View file

@ -235,18 +235,43 @@ describe('outbound-form-adapter: round-trip', () => {
settings: {
domainStrategy: 'UseIPv4',
redirect: '1.1.1.1',
userLevel: 3,
proxyProtocol: 2,
fragment: { packets: 'tlshello', length: '100-200' },
noises: [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ipv4' }],
},
}));
expect(filled.settings).toMatchObject({
domainStrategy: 'UseIPv4',
redirect: '1.1.1.1',
userLevel: 3,
proxyProtocol: 2,
fragment: { packets: 'tlshello', length: '100-200' },
noises: [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ipv4' }],
});
});
it('freedom tolerates settings without a fragment object (issue #4686)', () => {
const values = {
protocol: 'freedom',
tag: 'direct',
settings: {
domainStrategy: '',
redirect: '',
proxyProtocol: 0,
noises: [],
finalRules: [
{ action: 'block', network: '', port: '', ip: ['geoip:private'], blockDelay: '' },
],
},
} as unknown as Parameters<typeof formValuesToWirePayload>[0];
expect(() => formValuesToWirePayload(values)).not.toThrow();
const back = formValuesToWirePayload(values);
expect((back.settings as { fragment?: unknown }).fragment).toBeUndefined();
expect((back.settings as { finalRules?: unknown[] }).finalRules).toHaveLength(1);
});
it('freedom omits proxyProtocol when disabled (0)', () => {
const round = formValuesToWirePayload(rawOutboundToFormValues({
protocol: 'freedom',