mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
fix(sub): prevent default profile page URL disclosure (#6538)
* fix(sub): prevent default profile page URL disclosure Add explicit none, builtin, and custom profile page modes. Preserve existing custom URLs and warn before exposing the built-in page. Cover mode selection, legacy settings, and subscription response headers. * fix(subscription): add profile page link options and upgrade notes
This commit is contained in:
@@ -395,6 +395,9 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -618,6 +621,7 @@
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
@@ -1046,6 +1050,9 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1277,6 +1284,7 @@
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
|
||||
@@ -115,6 +115,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
"subPort": 1,
|
||||
"subProfileMode": "",
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
@@ -271,6 +272,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
"subPort": 1,
|
||||
"subProfileMode": "",
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
|
||||
@@ -369,6 +369,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -592,6 +595,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
@@ -1020,6 +1024,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1251,6 +1258,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
|
||||
@@ -122,6 +122,7 @@ export interface AllSetting {
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileMode: string;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
@@ -279,6 +280,7 @@ export interface AllSettingView {
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileMode: string;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
|
||||
@@ -136,6 +136,7 @@ export const AllSettingSchema = z.object({
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileMode: z.string(),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
@@ -294,6 +295,7 @@ export const AllSettingViewSchema = z.object({
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileMode: z.string(),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ObjectUtil } from '@/utils';
|
||||
import type { SubProfileMode } from '@/schemas/setting';
|
||||
|
||||
export class AllSetting {
|
||||
webListen = '';
|
||||
@@ -46,6 +47,7 @@ export class AllSetting {
|
||||
subClashUserAgentRegex = '';
|
||||
subTitle = '';
|
||||
subSupportUrl = '';
|
||||
subProfileMode: SubProfileMode = 'none';
|
||||
subProfileUrl = '';
|
||||
subAnnounce = '';
|
||||
subEnableRouting = false;
|
||||
@@ -167,6 +169,15 @@ export class AllSetting {
|
||||
if (data != null) {
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
// Legacy settings with a custom URL retain it until an explicit mode is saved.
|
||||
if (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
(!('subProfileMode' in data) || data.subProfileMode === undefined) &&
|
||||
this.subProfileUrl.trim() !== ''
|
||||
) {
|
||||
this.subProfileMode = 'custom';
|
||||
}
|
||||
const cpu = Math.round(Number(this.tgCpu));
|
||||
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||
const threshold = Math.round(Number(this.outboundDownThreshold));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
|
||||
import { Alert, Button, Input, InputNumber, Select, Switch, Tabs } from 'antd';
|
||||
import {
|
||||
BranchesOutlined,
|
||||
CompassOutlined,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import type { SubProfileMode } from '@/schemas/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||
import { RemarkTemplateField } from '@/components/form';
|
||||
@@ -279,16 +280,44 @@ export default function SubscriptionGeneralTab({
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subProfileUrl')}
|
||||
description={t('pages.settings.subProfileUrlDesc')}
|
||||
title={t('pages.settings.subProfileMode')}
|
||||
description={t('pages.settings.subProfileModeDesc')}
|
||||
>
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subProfileUrl}
|
||||
placeholder="https://example.com"
|
||||
onChange={(v) => updateSetting({ subProfileUrl: v })}
|
||||
metadataOnly
|
||||
<Select<SubProfileMode>
|
||||
id="sub-profile-mode"
|
||||
aria-label={t('pages.settings.subProfileMode')}
|
||||
value={allSetting.subProfileMode}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => updateSetting({ subProfileMode: value })}
|
||||
options={[
|
||||
{ value: 'none', label: t('pages.settings.subProfileModeNone') },
|
||||
{ value: 'builtin', label: t('pages.settings.subProfileModeBuiltin') },
|
||||
{ value: 'custom', label: t('pages.settings.subProfileModeCustom') },
|
||||
]}
|
||||
/>
|
||||
</SettingListItem>
|
||||
{allSetting.subProfileMode === 'builtin' ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ margin: '12px 20px' }}
|
||||
title={t('pages.settings.subProfileBuiltinWarning')}
|
||||
/>
|
||||
) : null}
|
||||
{allSetting.subProfileMode === 'custom' ? (
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subProfileUrl')}
|
||||
description={t('pages.settings.subProfileUrlDesc')}
|
||||
>
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subProfileUrl}
|
||||
placeholder="https://example.com"
|
||||
onChange={(v) => updateSetting({ subProfileUrl: v })}
|
||||
metadataOnly
|
||||
/>
|
||||
</SettingListItem>
|
||||
) : null}
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subAnnounce')}
|
||||
|
||||
@@ -4,6 +4,9 @@ const port = z.number().int().min(1).max(65535);
|
||||
const nonNegativeInt = z.number().int().min(0);
|
||||
const absolutePath = z.string().regex(/^\//, 'pages.settings.validation.pathLeadingSlash');
|
||||
|
||||
export const SubProfileModeSchema = z.enum(['none', 'builtin', 'custom']);
|
||||
export type SubProfileMode = z.infer<typeof SubProfileModeSchema>;
|
||||
|
||||
export const AllSettingSchema = z
|
||||
.object({
|
||||
webListen: z.string().optional(),
|
||||
@@ -50,6 +53,7 @@ export const AllSettingSchema = z
|
||||
subClashUserAgentRegex: z.string().max(2048).optional(),
|
||||
subTitle: z.string().optional(),
|
||||
subSupportUrl: z.string().optional(),
|
||||
subProfileMode: SubProfileModeSchema.optional(),
|
||||
subProfileUrl: z.string().optional(),
|
||||
subAnnounce: z.string().optional(),
|
||||
subEnableRouting: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import { AllSettingSchema } from '@/schemas/setting';
|
||||
|
||||
describe('subscription profile mode', () => {
|
||||
it.each([undefined, {}, { subProfileUrl: '' }, { subProfileUrl: ' ' }])(
|
||||
'defaults to no link without a legacy URL: %j',
|
||||
(data) => {
|
||||
expect(new AllSetting(data).subProfileMode).toBe('none');
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves a legacy custom URL when its mode is missing', () => {
|
||||
const setting = new AllSetting({ subProfileUrl: 'https://example.com/profile' });
|
||||
expect(setting.subProfileMode).toBe('custom');
|
||||
expect(setting.subProfileUrl).toBe('https://example.com/profile');
|
||||
});
|
||||
|
||||
it.each(['none', 'builtin', 'custom'])(
|
||||
'honors the explicit %s mode with a stored URL',
|
||||
(mode) => {
|
||||
const result = AllSettingSchema.safeParse({
|
||||
subProfileMode: mode,
|
||||
subProfileUrl: 'https://example.com/profile',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(new AllSetting(result.data).subProfileMode).toBe(mode);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['auto', '', null, true])('rejects an invalid mode: %j', (mode) => {
|
||||
expect(AllSettingSchema.safeParse({ subProfileMode: mode }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, useLocation } from 'react-router';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
import { chooseSelectOption, renderWithProviders } from './test-utils';
|
||||
|
||||
function ProfileSettingsHarness({ initial }: { initial?: unknown }) {
|
||||
const [allSetting, setAllSetting] = useState(() => new AllSetting(initial));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubscriptionGeneralTab
|
||||
allSetting={allSetting}
|
||||
updateSetting={(patch) =>
|
||||
setAllSetting((current) => new AllSetting({ ...current, ...patch }))
|
||||
}
|
||||
/>
|
||||
<output data-testid="profile-settings">
|
||||
{allSetting.subProfileMode}|{allSetting.subProfileUrl}
|
||||
</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
@@ -18,6 +37,60 @@ function LocationProbe() {
|
||||
}
|
||||
|
||||
describe('SubscriptionGeneralTab', () => {
|
||||
it('switches profile modes without losing the custom URL and warns only for the built-in page', () => {
|
||||
const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
|
||||
const editedUrl = 'https://example.com/account/{{SUB_ID}}';
|
||||
const warning =
|
||||
'This page exposes subscription URLs and node configurations, including for Happ encrypted subscriptions.';
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||
<ProfileSettingsHarness initial={{ subProfileMode: 'none', subProfileUrl: storedUrl }} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Profile/ }));
|
||||
expect(screen.getByRole('combobox', { name: 'Profile page' })).toBeTruthy();
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`none|${storedUrl}`);
|
||||
expect(screen.queryByDisplayValue(storedUrl)).toBeNull();
|
||||
expect(screen.queryByText(warning)).toBeNull();
|
||||
|
||||
chooseSelectOption('sub-profile-mode', 'Built-in subscription page');
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`builtin|${storedUrl}`);
|
||||
expect(screen.getByRole('alert').textContent).toContain(warning);
|
||||
expect(screen.queryByDisplayValue(storedUrl)).toBeNull();
|
||||
|
||||
chooseSelectOption('sub-profile-mode', 'Custom website');
|
||||
expect(screen.queryByText(warning)).toBeNull();
|
||||
fireEvent.change(screen.getByDisplayValue(storedUrl), { target: { value: editedUrl } });
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${editedUrl}`);
|
||||
|
||||
chooseSelectOption('sub-profile-mode', 'No link');
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`none|${editedUrl}`);
|
||||
expect(screen.queryByDisplayValue(editedUrl)).toBeNull();
|
||||
expect(screen.queryByText(warning)).toBeNull();
|
||||
|
||||
chooseSelectOption('sub-profile-mode', 'Custom website');
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${editedUrl}`);
|
||||
expect(screen.getByDisplayValue(editedUrl)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens a legacy custom profile URL with custom mode selected', () => {
|
||||
const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||
<ProfileSettingsHarness initial={{ subProfileUrl: storedUrl }} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Profile/ }));
|
||||
expect(screen.getByRole('combobox', { name: 'Profile page' })).toBeTruthy();
|
||||
expect(screen.getByText('Custom website')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue(storedUrl)).toBeTruthy();
|
||||
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${storedUrl}`);
|
||||
});
|
||||
|
||||
it('keeps the stored subscription port when the field is cleared', () => {
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user