feat(tun): validate route-exclude-address as CIDR (#6440)

* feat(tun): validate route-exclude-address as CIDR

* refactor(network): replace ipaddr.js helpers with cidr-block and validator

* docs: Changelog
This commit is contained in:
Slinetrac 2026-03-07 17:18:35 +08:00 committed by GitHub
parent c429632d80
commit 0bbf9407d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 204 additions and 169 deletions

View File

@ -22,5 +22,6 @@
- 优化托盘退出快捷键显示
- 优化首次启动节点信息刷新
- Linux 默认使用内置窗口控件
- 实现排除自定义网段的校验
</details>

View File

@ -56,11 +56,11 @@
"@tauri-apps/plugin-updater": "2.10.0",
"ahooks": "^3.9.6",
"axios": "^1.13.6",
"cidr-block": "^2.3.0",
"dayjs": "1.11.19",
"foxact": "^0.2.54",
"foxts": "^5.3.0",
"i18next": "^25.8.13",
"ipaddr.js": "^2.3.0",
"js-yaml": "^4.1.1",
"lodash-es": "^4.17.23",
"monaco-editor": "^0.55.1",
@ -77,7 +77,8 @@
"rehype-raw": "^7.0.0",
"swr": "^2.4.1",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3"
"types-pac": "^1.0.3",
"validator": "^13.15.26"
},
"devDependencies": {
"@actions/github": "^9.0.0",
@ -89,6 +90,7 @@
"@types/node": "^24.11.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-react-swc": "^4.2.3",
"adm-zip": "^0.5.16",

35
pnpm-lock.yaml generated
View File

@ -74,6 +74,9 @@ importers:
axios:
specifier: ^1.13.6
version: 1.13.6
cidr-block:
specifier: ^2.3.0
version: 2.3.0
dayjs:
specifier: 1.11.19
version: 1.11.19
@ -86,9 +89,6 @@ importers:
i18next:
specifier: ^25.8.13
version: 25.8.13(typescript@5.9.3)
ipaddr.js:
specifier: ^2.3.0
version: 2.3.0
js-yaml:
specifier: ^4.1.1
version: 4.1.1
@ -140,6 +140,9 @@ importers:
types-pac:
specifier: ^1.0.3
version: 1.0.3
validator:
specifier: ^13.15.26
version: 13.15.26
devDependencies:
'@actions/github':
specifier: ^9.0.0
@ -168,6 +171,9 @@ importers:
'@types/react-dom':
specifier: 19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/validator':
specifier: ^13.15.10
version: 13.15.10
'@vitejs/plugin-legacy':
specifier: ^7.2.1
version: 7.2.1(terser@5.46.0)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))
@ -1882,6 +1888,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@typescript-eslint/eslint-plugin@8.56.1':
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -2211,6 +2220,10 @@ packages:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
cidr-block@2.3.0:
resolution: {integrity: sha512-U5NE6QJKNOhIsVrI6sicbwGtEmEORbrz9B5Mbl+Mi05sz1Ic4egoLGV4DkJp56VWva61WxOf+vzIxTKEmRazqg==}
engines: {node: '>=16'}
cli-color@2.0.4:
resolution: {integrity: sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==}
engines: {node: '>=0.10'}
@ -2821,10 +2834,6 @@ packages:
resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==}
deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.
ipaddr.js@2.3.0:
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
engines: {node: '>= 10'}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
@ -3672,6 +3681,10 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
validator@13.15.26:
resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
engines: {node: '>= 0.10'}
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@ -5443,6 +5456,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/validator@13.15.10': {}
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -5772,6 +5787,8 @@ snapshots:
chownr@3.0.0: {}
cidr-block@2.3.0: {}
cli-color@2.0.4:
dependencies:
d: 1.0.2
@ -6512,8 +6529,6 @@ snapshots:
intersection-observer@0.12.2: {}
ipaddr.js@2.3.0: {}
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
@ -7526,6 +7541,8 @@ snapshots:
dependencies:
react: 19.2.4
validator@13.15.26: {}
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3

View File

@ -49,6 +49,7 @@ import { showNotice } from "@/services/notice-service";
import { useThemeMode } from "@/services/states";
import type { TranslationKey } from "@/types/generated/i18n-keys";
import getSystem from "@/utils/get-system";
import { isValidIpCidr } from "@/utils/network";
interface Props {
groupsUid: string;
@ -65,16 +66,6 @@ const portValidator = (value: string): boolean => {
"^(?:[1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$",
).test(value);
};
const ipv4CIDRValidator = (value: string): boolean => {
return new RegExp(
"^(?:(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))\\.){3}(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))(?:\\/(?:[12]?[0-9]|3[0-2]))$",
).test(value);
};
const ipv6CIDRValidator = (value: string): boolean => {
return new RegExp(
"^([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){7}|::|:(?::[0-9a-fA-F]{1,4}){1,6}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,5}|(?:[0-9a-fA-F]{1,4}:){2}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){3}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){4}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){5}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,6}:)\\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])$",
).test(value);
};
const rules: {
name: string;
@ -127,29 +118,29 @@ const rules: {
name: "IP-CIDR",
example: "127.0.0.0/8",
noResolve: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
validator: isValidIpCidr,
},
{
name: "IP-CIDR6",
example: "2620:0:2d0:200::7/32",
noResolve: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
validator: isValidIpCidr,
},
{
name: "SRC-IP-CIDR",
example: "192.168.1.201/32",
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
validator: isValidIpCidr,
},
{
name: "IP-SUFFIX",
example: "8.8.8.8/24",
noResolve: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
validator: isValidIpCidr,
},
{
name: "SRC-IP-SUFFIX",
example: "192.168.1.201/8",
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
validator: isValidIpCidr,
},
{
name: "SRC-PORT",

View File

@ -21,7 +21,7 @@ import {
getWebdavStatus,
setWebdavStatus,
} from "@/services/webdav-status";
import { isValidUrl } from "@/utils/helper";
import { isValidUrl } from "@/utils/network";
interface BackupConfigViewerProps {
onBackupSuccess: () => Promise<void>;

View File

@ -23,6 +23,7 @@ import { useClash } from "@/hooks/use-clash";
import { enhanceProfiles } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import getSystem from "@/utils/get-system";
import { areValidIpCidrs } from "@/utils/network";
import { StackModeSwitch } from "./stack-mode-switch";
@ -52,6 +53,17 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
mtu: 1500,
});
const routeExcludeAddressItems = splitRouteExcludeAddress(
values.routeExcludeAddress,
);
const routeExcludeAddressError =
values.autoRoute &&
routeExcludeAddressItems.length > 0 &&
!areValidIpCidrs(routeExcludeAddressItems);
const routeExcludeAddressHelperText = routeExcludeAddressError
? t("settings.modals.tun.messages.invalidRouteExcludeAddress")
: t("settings.modals.tun.messages.routeExcludeAddressHint");
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
@ -78,9 +90,15 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
const onSave = useLockFn(async () => {
try {
const routeExcludeAddress = splitRouteExcludeAddress(
values.routeExcludeAddress,
);
const routeExcludeAddress = routeExcludeAddressItems;
if (routeExcludeAddressError) {
showNotice.error(
"settings.modals.tun.messages.invalidRouteExcludeAddress",
);
return;
}
const tun: IConfigData["tun"] = {
stack: values.stack,
device:
@ -312,6 +330,8 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
placeholder="192.168.0.0/16"
ariaLabel={t("settings.modals.tun.fields.routeExcludeAddress")}
disabled={!values.autoRoute}
error={routeExcludeAddressError}
helperText={routeExcludeAddressHelperText}
onChange={(nextValue) =>
setValues((v) => ({ ...v, routeExcludeAddress: nextValue }))
}

View File

@ -19,7 +19,12 @@ import { useClash } from "@/hooks/use-clash";
import { useAppData } from "@/providers/app-data-context";
import { isPortInUse } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import { parseHost, parsedLocalhost, isValidPort } from "@/utils/helper";
import {
formatHostPort,
isValidPort,
normalizeHost,
normalizeListenHost,
} from "@/utils/network";
interface TunnelsViewerRef {
open: () => void;
@ -127,8 +132,8 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
}
// 本地地址校验host
const parsedLocal = parsedLocalhost(localAddr);
if (!parsedLocal) {
const localHost = normalizeListenHost(localAddr);
if (!localHost) {
showNotice.error(
"settings.sections.clash.form.fields.tunnels.messages.invalidLocalAddr",
);
@ -151,8 +156,8 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
}
// 目标地址校验 (host)
const parsedTarget = parseHost(targetAddr);
if (!parsedTarget) {
const targetHost = normalizeHost(targetAddr);
if (!targetHost) {
showNotice.error(
"settings.sections.clash.form.fields.tunnels.messages.invalidTargetAddr",
);
@ -170,14 +175,8 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
// 构造新 entry
const entry: TunnelEntry = {
network: network === "tcp+udp" ? ["tcp", "udp"] : [network],
address:
parsedLocal.kind === "ipv6"
? `[${parsedLocal.host}]:${localPort}`
: `${parsedLocal.host}:${localPort}`,
target:
parsedTarget.kind === "ipv6"
? `[${parsedTarget.host}]:${targetPort}`
: `${parsedTarget.host}:${targetPort}`,
address: formatHostPort(localHost, localPort),
target: formatHostPort(targetHost, targetPort),
...(proxy ? { proxy } : {}),
};

View File

@ -481,7 +481,9 @@
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
"applied": "تم تطبيق الإعدادات"
"applied": "تم تطبيق الإعدادات",
"invalidRouteExcludeAddress": "يرجى إدخال نطاق CIDR صالح",
"routeExcludeAddressHint": "يتم دعم CIDR لـ IPv4/IPv6 فقط، مثل 192.168.0.0/16 أو fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
"applied": "Einstellungen angewendet"
"applied": "Einstellungen angewendet",
"invalidRouteExcludeAddress": "Bitte geben Sie einen gültigen CIDR-Block ein",
"routeExcludeAddressHint": "Es werden nur IPv4-/IPv6-CIDR-Blöcke unterstützt, z. B. 192.168.0.0/16 oder fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
"applied": "Settings Applied"
"applied": "Settings Applied",
"invalidRouteExcludeAddress": "Please enter a valid CIDR block",
"routeExcludeAddressHint": "Only IPv4/IPv6 CIDR is supported, such as 192.168.0.0/16 or fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
"applied": "Ajustes aplicados"
"applied": "Ajustes aplicados",
"invalidRouteExcludeAddress": "Introduce un bloque CIDR válido",
"routeExcludeAddressHint": "Solo se admiten bloques CIDR de IPv4/IPv6, como 192.168.0.0/16 o fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "پیکربندی خودکار ریدایرکت‌های TCP در nftables/iptables"
},
"messages": {
"applied": "تنظیمات اعمال شد"
"applied": "تنظیمات اعمال شد",
"invalidRouteExcludeAddress": "لطفاً یک محدوده CIDR معتبر وارد کنید",
"routeExcludeAddressHint": "فقط CIDRهای IPv4/IPv6 پشتیبانی می‌شوند، مانند 192.168.0.0/16 یا fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
"applied": "Pengaturan Diterapkan"
"applied": "Pengaturan Diterapkan",
"invalidRouteExcludeAddress": "Masukkan blok CIDR yang valid",
"routeExcludeAddressHint": "Hanya CIDR IPv4/IPv6 yang didukung, seperti 192.168.0.0/16 atau fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
"applied": "設定が適用されました。"
"applied": "設定が適用されました",
"invalidRouteExcludeAddress": "有効な CIDR ブロックを入力してください",
"routeExcludeAddressHint": "IPv4/IPv6 の CIDR のみ対応しています。例: 192.168.0.0/16、fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
"applied": "설정이 적용되었습니다"
"applied": "설정이 적용되었습니다",
"invalidRouteExcludeAddress": "유효한 CIDR 대역을 입력하세요",
"routeExcludeAddressHint": "IPv4/IPv6 CIDR만 지원합니다. 예: 192.168.0.0/16 또는 fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
"applied": "Настройки применены"
"applied": "Настройки применены",
"invalidRouteExcludeAddress": "Введите корректный блок CIDR",
"routeExcludeAddressHint": "Поддерживаются только блоки CIDR IPv4/IPv6, например 192.168.0.0/16 или fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
"applied": "Ayarlar Uygulandı"
"applied": "Ayarlar Uygulandı",
"invalidRouteExcludeAddress": "Lütfen geçerli bir CIDR bloğu girin",
"routeExcludeAddressHint": "Yalnızca IPv4/IPv6 CIDR blokları desteklenir; örneğin 192.168.0.0/16 veya fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
"applied": "Көйләүләр кулланылды"
"applied": "Көйләүләр кулланылды",
"invalidRouteExcludeAddress": "Дөрес CIDR блогын кертегез",
"routeExcludeAddressHint": "IPv4/IPv6 CIDR блоклары гына хуплана, мәсәлән 192.168.0.0/16 яки fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "自动配置 nftables/iptables 的 TCP 重定向"
},
"messages": {
"applied": "设置已应用"
"applied": "设置已应用",
"invalidRouteExcludeAddress": "请输入有效的 CIDR 网段",
"routeExcludeAddressHint": "仅支持 IPv4/IPv6 CIDR例如 192.168.0.0/16 或 fd00::/8"
}
},
"dns": {

View File

@ -481,7 +481,9 @@
"autoRedirect": "自動配置 nftables/iptables 的 TCP 重導"
},
"messages": {
"applied": "設定已套用"
"applied": "設定已套用",
"invalidRouteExcludeAddress": "請輸入有效的 CIDR 網段",
"routeExcludeAddressHint": "僅支援 IPv4/IPv6 CIDR例如 192.168.0.0/16 或 fd00::/8"
}
},
"dns": {

View File

@ -602,6 +602,8 @@ export const translationKeys = [
"settings.modals.tun.tooltips.dnsHijack",
"settings.modals.tun.tooltips.autoRedirect",
"settings.modals.tun.messages.applied",
"settings.modals.tun.messages.invalidRouteExcludeAddress",
"settings.modals.tun.messages.routeExcludeAddressHint",
"settings.modals.dns.dialog.title",
"settings.modals.dns.dialog.warning",
"settings.modals.dns.sections.general",

View File

@ -1024,6 +1024,8 @@ export interface TranslationResources {
};
messages: {
applied: string;
invalidRouteExcludeAddress: string;
routeExcludeAddressHint: string;
};
title: string;
tooltips: {
@ -1318,10 +1320,10 @@ export interface TranslationResources {
usedTotal: string;
};
placeholders: {
resetInput: string;
filter: string;
matchCase: string;
matchWholeWord: string;
resetInput: string;
useRegex: string;
};
statuses: {

View File

@ -1,112 +0,0 @@
import ipaddr from "ipaddr.js";
import { debugLog } from "@/utils/debug";
export type HostKind = "localhost" | "domain" | "ipv4" | "ipv6";
export type ParsedHost = {
kind: HostKind;
host: string;
};
export const isValidUrl = (url: string) => {
try {
new URL(url);
return true;
} catch (e) {
debugLog(e);
return false;
}
};
export const isValidPort = (port: string) => {
const portNumber = Number(port);
return Number.isInteger(portNumber) && portNumber > 0 && portNumber < 65536;
};
export const isValidDomain = (domain: string) => {
try {
const url = new URL(`http://${domain}`);
return url.hostname.toLowerCase() === domain.toLowerCase();
} catch {
return false;
}
};
const isLocalhostString = (host: string) => {
const lowerHost = host.toLowerCase();
return lowerHost === "localhost";
};
const stripBrackets = (host: string): string =>
host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
export const parseHost = (host: string): ParsedHost | null => {
// 去除前后空白
host = host.trim();
// 优先检测 localhost
if (isLocalhostString(host)) {
return { kind: "localhost", host: "localhost" };
}
// 除去方括号后检测 IP
const strippedHost = stripBrackets(host);
// IPv4
if (ipaddr.IPv4.isValidFourPartDecimal(strippedHost)) {
return { kind: "ipv4", host: strippedHost };
}
// IPv6
if (ipaddr.IPv6.isValid(strippedHost)) {
return { kind: "ipv6", host: strippedHost };
}
// 再检测 domain, 防止像 [::1] 这样的合法 IPv6 地址被误判为域名
if (isValidDomain(host)) {
return { kind: "domain", host };
}
// 都不是
return null;
};
const LISTEN_CIDRS_V4 = [
"0.0.0.0/32", // any
"127.0.0.0/8", // loopback
"10.0.0.0/8", // RFC1918
"172.16.0.0/12", // RFC1918
"192.168.0.0/16", // RFC1918
];
const LISTEN_CIDRS_V6 = [
"::/128", // any
"::1/128", // loopback
"fc00::/7", // ULALAN
"fe80::/10", // link-local
];
export const parsedLocalhost = (host: string): ParsedHost | null => {
// 先 parse 一下
const parsed = parseHost(host);
if (!parsed) return null;
// localhost 直接通过
if (parsed.kind === "localhost") {
return parsed;
}
// IP 则检查是否在允许的 CIDR 列表内
if (parsed.kind === "ipv4") {
// IPv4
const addr = ipaddr.IPv4.parse(parsed.host);
for (const cidr of LISTEN_CIDRS_V4) {
if (addr.match(ipaddr.IPv4.parseCIDR(cidr))) {
return parsed;
}
}
} else if (parsed.kind === "ipv6") {
// IPv6
const addr = ipaddr.IPv6.parse(parsed.host);
for (const cidr of LISTEN_CIDRS_V6) {
if (addr.match(ipaddr.IPv6.parseCIDR(cidr))) {
return parsed;
}
}
}
// domain和都不符合则返回 null
return null;
};

87
src/utils/network.ts Normal file
View File

@ -0,0 +1,87 @@
import { ipv4, ipv6 } from "cidr-block";
import validator from "validator";
const stripBrackets = (value: string) =>
value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
const isIpv4 = (value: string) => validator.isIP(value, 4);
const isIpv6 = (value: string) => validator.isIP(value, 6);
const isHostname = (value: string) =>
validator.isFQDN(value, { require_tld: false });
export const isValidUrl = (value: string) =>
validator.isURL(value.trim(), {
protocols: ["http", "https"],
require_protocol: true,
require_valid_protocol: true,
require_host: true,
require_tld: false,
});
export const isValidPort = (value: string) => validator.isPort(value.trim());
export const normalizeHost = (value: string): string | null => {
const host = value.trim();
if (!host) {
return null;
}
if (host.toLowerCase() === "localhost") {
return "localhost";
}
const normalizedHost = stripBrackets(host);
if (isIpv4(normalizedHost) || isIpv6(normalizedHost)) {
return normalizedHost;
}
return isHostname(host) ? host : null;
};
export const normalizeListenHost = (value: string): string | null => {
const host = normalizeHost(value);
if (!host || host === "localhost") {
return host;
}
if (isIpv4(host)) {
const address = ipv4.address(host);
return address.equals("0.0.0.0") ||
address.isLoopbackAddress() ||
address.isPrivateAddress()
? host
: null;
}
if (!isIpv6(host)) {
return null;
}
const address = ipv6.address(host);
return address.isUnspecifiedAddress() ||
address.isLoopbackAddress() ||
address.isUniqueLocalAddress() ||
address.isLinkLocalAddress()
? host
: null;
};
export const formatHostPort = (host: string, port: string | number) =>
isIpv6(host) ? `[${host}]:${port}` : `${host}:${port}`;
export const isValidIpCidr = (value: string): boolean => {
const cidr = value.trim();
const slashIndex = cidr.lastIndexOf("/");
if (slashIndex <= 0) {
return false;
}
const address = cidr.slice(0, slashIndex);
return (
(isIpv4(address) && ipv4.isValidCIDR(cidr)) ||
(isIpv6(address) && ipv6.isValidCIDR(cidr))
);
};
export const areValidIpCidrs = (values: string[]) =>
values.every(isValidIpCidr);