Quellcode durchsuchen

feat(frontend): block invalid settings saves with Zod pre-save check

Tighten AllSettingSchema with the actual valid ranges and patterns:

- webPort / subPort / ldapPort: integer 1-65535
- pageSize: integer 1-1000
- sessionMaxAge: integer >= 1
- tgCpu: integer 0-100 (percentage)
- subUpdates: integer 1-168 (hours)
- expireDiff / trafficDiff / ldapDefault*: non-negative integers
- webBasePath / subPath / subJsonPath / subClashPath: must start with /

The existing useAllSettings save path runs AllSettingSchema.partial()
through safeParse and logs drift without blocking. SettingsPage now
adds a stronger gate before the mutation: run the full schema against
the draft and, on failure, surface the first issue (field path +
message) via the existing messageApi.error so the user actually sees
what's wrong instead of silently sending bad data to the backend.

Use cases caught: port out of range, negative quota, sub path missing
leading slash, page size set to 0, tgCpu > 100.
MHSanaei vor 16 Stunden
Ursprung
Commit
4ecbb0e55f
2 geänderte Dateien mit 34 neuen und 17 gelöschten Zeilen
  1. 14 1
      frontend/src/pages/settings/SettingsPage.tsx
  2. 20 16
      frontend/src/schemas/setting.ts

+ 14 - 1
frontend/src/pages/settings/SettingsPage.tsx

@@ -29,6 +29,7 @@ import { setMessageInstance } from '@/utils/messageBus';
 import { useTheme } from '@/hooks/useTheme';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useAllSettings } from '@/api/queries/useAllSettings';
+import { AllSettingSchema } from '@/schemas/setting';
 import AppSidebar from '@/components/AppSidebar';
 import GeneralTab from './GeneralTab';
 import SecurityTab from './SecurityTab';
@@ -148,6 +149,18 @@ export default function SettingsPage() {
     return url.toString();
   }
 
+  async function onSave() {
+    const result = AllSettingSchema.safeParse(allSetting);
+    if (!result.success) {
+      const issue = result.error.issues[0];
+      const fieldPath = issue?.path.join('.') ?? 'value';
+      const msgKey = issue?.message ?? 'somethingWentWrong';
+      messageApi.error(`${fieldPath}: ${t(msgKey, { defaultValue: msgKey })}`);
+      return;
+    }
+    await saveAll();
+  }
+
   function restartPanel() {
     modal.confirm({
       title: t('pages.settings.restartPanel'),
@@ -301,7 +314,7 @@ export default function SettingsPage() {
                         <Row className="header-row">
                           <Col xs={24} sm={10} className="header-actions">
                             <Space>
-                              <Button type="primary" disabled={saveDisabled} onClick={saveAll}>
+                              <Button type="primary" disabled={saveDisabled} onClick={onSave}>
                                 {t('pages.settings.save')}
                               </Button>
                               <Button type="primary" danger disabled={!saveDisabled} onClick={restartPanel}>

+ 20 - 16
frontend/src/schemas/setting.ts

@@ -1,17 +1,21 @@
 import { z } from 'zod';
 
+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 AllSettingSchema = z.object({
   webListen: z.string().optional(),
   webDomain: z.string().optional(),
-  webPort: z.number().optional(),
+  webPort: port.optional(),
   webCertFile: z.string().optional(),
   webKeyFile: z.string().optional(),
-  webBasePath: z.string().optional(),
-  sessionMaxAge: z.number().optional(),
+  webBasePath: absolutePath.optional(),
+  sessionMaxAge: z.number().int().min(1).optional(),
   trustedProxyCIDRs: z.string().optional(),
-  pageSize: z.number().optional(),
-  expireDiff: z.number().optional(),
-  trafficDiff: z.number().optional(),
+  pageSize: z.number().int().min(1).max(1000).optional(),
+  expireDiff: nonNegativeInt.optional(),
+  trafficDiff: nonNegativeInt.optional(),
   remarkModel: z.string().optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),
   tgBotEnable: z.boolean().optional(),
@@ -22,7 +26,7 @@ export const AllSettingSchema = z.object({
   tgRunTime: z.string().optional(),
   tgBotBackup: z.boolean().optional(),
   tgBotLoginNotify: z.boolean().optional(),
-  tgCpu: z.number().optional(),
+  tgCpu: z.number().int().min(0).max(100).optional(),
   tgLang: z.string().optional(),
   twoFactorEnable: z.boolean().optional(),
   twoFactorToken: z.string().optional(),
@@ -36,18 +40,18 @@ export const AllSettingSchema = z.object({
   subEnableRouting: z.boolean().optional(),
   subRoutingRules: z.string().optional(),
   subListen: z.string().optional(),
-  subPort: z.number().optional(),
-  subPath: z.string().optional(),
-  subJsonPath: z.string().optional(),
+  subPort: port.optional(),
+  subPath: absolutePath.optional(),
+  subJsonPath: absolutePath.optional(),
   subClashEnable: z.boolean().optional(),
-  subClashPath: z.string().optional(),
+  subClashPath: absolutePath.optional(),
   subDomain: z.string().optional(),
   externalTrafficInformEnable: z.boolean().optional(),
   externalTrafficInformURI: z.string().optional(),
   restartXrayOnClientDisable: z.boolean().optional(),
   subCertFile: z.string().optional(),
   subKeyFile: z.string().optional(),
-  subUpdates: z.number().optional(),
+  subUpdates: z.number().int().min(1).max(168).optional(),
   subEncrypt: z.boolean().optional(),
   subShowInfo: z.boolean().optional(),
   subEmailInRemark: z.boolean().optional(),
@@ -61,7 +65,7 @@ export const AllSettingSchema = z.object({
   timeLocation: z.string().optional(),
   ldapEnable: z.boolean().optional(),
   ldapHost: z.string().optional(),
-  ldapPort: z.number().optional(),
+  ldapPort: port.optional(),
   ldapUseTLS: z.boolean().optional(),
   ldapBindDN: z.string().optional(),
   ldapPassword: z.string().optional(),
@@ -76,9 +80,9 @@ export const AllSettingSchema = z.object({
   ldapInboundTags: z.string().optional(),
   ldapAutoCreate: z.boolean().optional(),
   ldapAutoDelete: z.boolean().optional(),
-  ldapDefaultTotalGB: z.number().optional(),
-  ldapDefaultExpiryDays: z.number().optional(),
-  ldapDefaultLimitIP: z.number().optional(),
+  ldapDefaultTotalGB: nonNegativeInt.optional(),
+  ldapDefaultExpiryDays: nonNegativeInt.optional(),
+  ldapDefaultLimitIP: nonNegativeInt.optional(),
   hasTgBotToken: z.boolean().optional(),
   hasTwoFactorToken: z.boolean().optional(),
   hasLdapPassword: z.boolean().optional(),