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.
This commit is contained in:
MHSanaei 2026-05-25 18:10:24 +02:00
parent 4ecbb0e55f
commit 9cf35234a5
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 38 additions and 3 deletions

View file

@ -60,6 +60,7 @@ import FinalMaskForm from '@/components/FinalMaskForm';
import DateTimePicker from '@/components/DateTimePicker'; import DateTimePicker from '@/components/DateTimePicker';
import JsonEditor from '@/components/JsonEditor'; import JsonEditor from '@/components/JsonEditor';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { InboundFormSchema } from '@/schemas/inbound';
import './InboundFormModal.css'; import './InboundFormModal.css';
const { TextArea } = Input; const { TextArea } = Input;
@ -931,6 +932,19 @@ export default function InboundFormModal({
settings = compactAdvancedJson(advancedTextRef.current.settings, ib.settings.toString(), t('pages.inbounds.advanced.settings')); settings = compactAdvancedJson(advancedTextRef.current.settings, ib.settings.toString(), t('pages.inbounds.advanced.settings'));
} catch { return; } } 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> = { const payload: Record<string, unknown> = {
up: form.up || 0, up: form.up || 0,
down: form.down || 0, down: form.down || 0,
@ -967,7 +981,7 @@ export default function InboundFormModal({
} finally { } finally {
setSaving(false); 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 protocolSnapshot = inboundRef.current?.protocol;
const streamSnapshot = JSON.stringify(inboundRef.current?.stream?.toJson?.() || {}); const streamSnapshot = JSON.stringify(inboundRef.current?.stream?.toJson?.() || {});

View file

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

View file

@ -14,6 +14,19 @@ export const InboundDetailSchema = z.object({
export const LastOnlineMapSchema = z.record(z.string(), z.number()); 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 SlimInbound = z.infer<typeof SlimInboundSchema>;
export type InboundDetail = z.infer<typeof InboundDetailSchema>; export type InboundDetail = z.infer<typeof InboundDetailSchema>;
export type LastOnlineMap = z.infer<typeof LastOnlineMapSchema>; export type LastOnlineMap = z.infer<typeof LastOnlineMapSchema>;
export type InboundFormValues = z.infer<typeof InboundFormSchema>;

View file

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