Selaa lähdekoodia

feat(frontend): schema-guard Inbound and Outbound form submits

The two largest forms in the panel send to the backend without ever
checking their own port range or required-ness. Schema-gate the
top-level fields so obviously bad payloads stop at the client.

InboundFormModal: InboundFormSchema (port 1-65535 int, non-empty
protocol, the rest of the keys present) runs as a safeParse just
before the HttpUtil.post in submit(). The 2000+ lines of protocol-
specific subform code stay untouched - that's a separate effort and
the existing per-protocol logic (e.g. canEnableStream, isFallbackHost)
already gates most of the structural correctness.

OutboundFormModal: OutboundTagSchema (trim + min 1) replaces the
hand-rolled `if (!ob.tag?.trim()) messageApi.error('Tag is required')`
check. The duplicateTag check stays inline because it needs the
existingTags prop.

Both schemas emit i18n keys for messages with a defaultValue fallback,
matching the pattern in BalancerFormModal and SettingsPage.
MHSanaei 14 tuntia sitten
vanhempi
sitoutus
9cf35234a5

+ 15 - 1
frontend/src/pages/inbounds/InboundFormModal.tsx

@@ -60,6 +60,7 @@ import FinalMaskForm from '@/components/FinalMaskForm';
 import DateTimePicker from '@/components/DateTimePicker';
 import JsonEditor from '@/components/JsonEditor';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
+import { InboundFormSchema } from '@/schemas/inbound';
 import './InboundFormModal.css';
 
 const { TextArea } = Input;
@@ -931,6 +932,19 @@ export default function InboundFormModal({
         settings = compactAdvancedJson(advancedTextRef.current.settings, ib.settings.toString(), t('pages.inbounds.advanced.settings'));
       } catch { return; }
 
+      const baseCheck = InboundFormSchema.safeParse({
+        remark: form.remark ?? '',
+        enable: !!form.enable,
+        port: Number(ib.port),
+        listen: ib.listen ?? '',
+        protocol: ib.protocol ?? '',
+      });
+      if (!baseCheck.success) {
+        const issue = baseCheck.error.issues[0];
+        messageApi.error(t(issue?.message ?? 'somethingWentWrong', { defaultValue: issue?.message ?? 'invalid' }));
+        return;
+      }
+
       const payload: Record<string, unknown> = {
         up: form.up || 0,
         down: form.down || 0,
@@ -967,7 +981,7 @@ export default function InboundFormModal({
     } finally {
       setSaving(false);
     }
-  }, [canEnableStream, compactAdvancedJson, t, mode, dbInbound, isFallbackHost, saveFallbacks, onSaved, onClose]);
+  }, [canEnableStream, compactAdvancedJson, t, messageApi, mode, dbInbound, isFallbackHost, saveFallbacks, onSaved, onClose]);
 
   const protocolSnapshot = inboundRef.current?.protocol;
   const streamSnapshot = JSON.stringify(inboundRef.current?.stream?.toJson?.() || {});

+ 5 - 2
frontend/src/pages/xray/OutboundFormModal.tsx

@@ -35,6 +35,7 @@ import {
 } from '@/models/outbound';
 import FinalMaskForm from '@/components/FinalMaskForm';
 import JsonEditor from '@/components/JsonEditor';
+import { OutboundTagSchema } from '@/schemas/xray';
 import './OutboundFormModal.css';
 
 interface OutboundFormModalProps {
@@ -223,8 +224,10 @@ export default function OutboundFormModal({
   function onOk() {
     if (!ob) return;
     if (activeKey === '2' && !applyAdvancedJsonToForm()) return;
-    if (!ob.tag?.trim()) {
-      messageApi.error('Tag is required');
+    const tagOk = OutboundTagSchema.safeParse(ob.tag);
+    if (!tagOk.success) {
+      const msgKey = tagOk.error.issues[0]?.message ?? 'Tag is required';
+      messageApi.error(t(msgKey, { defaultValue: 'Tag is required' }));
       return;
     }
     if (duplicateTag) {

+ 13 - 0
frontend/src/schemas/inbound.ts

@@ -14,6 +14,19 @@ export const InboundDetailSchema = z.object({
 
 export const LastOnlineMapSchema = z.record(z.string(), z.number());
 
+export const InboundFormSchema = z.object({
+  remark: z.string(),
+  enable: z.boolean(),
+  port: z
+    .number({ error: 'pages.inbounds.toasts.portRequired' })
+    .int()
+    .min(1, 'pages.inbounds.toasts.portRange')
+    .max(65535, 'pages.inbounds.toasts.portRange'),
+  listen: z.string(),
+  protocol: z.string().min(1, 'pages.inbounds.toasts.protocolRequired'),
+});
+
 export type SlimInbound = z.infer<typeof SlimInboundSchema>;
 export type InboundDetail = z.infer<typeof InboundDetailSchema>;
 export type LastOnlineMap = z.infer<typeof LastOnlineMapSchema>;
+export type InboundFormValues = z.infer<typeof InboundFormSchema>;

+ 5 - 0
frontend/src/schemas/xray.ts

@@ -114,6 +114,11 @@ export const BalancerFormSchema = z.object({
   fallbackTag: z.string(),
 });
 
+export const OutboundTagSchema = z
+  .string()
+  .trim()
+  .min(1, 'pages.xray.outboundTagRequired');
+
 export type BalancerFormValues = z.infer<typeof BalancerFormSchema>;
 export type RuleFormValues = z.infer<typeof RuleFormSchema>;
 export type CustomGeoFormValues = z.infer<typeof CustomGeoFormSchema>;