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:
NgaiYeanCoi
2026-09-16 03:13:29 +08:00
committed by GitHub
parent 3fa44915c1
commit 1d85ef138e
36 changed files with 747 additions and 47 deletions
+8
View File
@@ -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",
+2
View File
@@ -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,
+8
View File
@@ -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",
+2
View File
@@ -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;
+2
View File
@@ -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(),
+11
View File
@@ -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
View File
@@ -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();