mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(net): consolidate IP checks with ipaddr.js
This commit is contained in:
@@ -170,6 +170,7 @@
|
||||
"file-type": "^21.3.0",
|
||||
"grammy": "^1.40.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
"jiti": "^2.6.1",
|
||||
"json5": "^2.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -119,6 +119,9 @@ importers:
|
||||
https-proxy-agent:
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.6
|
||||
ipaddr.js:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
jiti:
|
||||
specifier: ^2.6.1
|
||||
version: 2.6.1
|
||||
@@ -4076,6 +4079,10 @@ packages:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
ipaddr.js@2.3.0:
|
||||
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
ipull@3.9.3:
|
||||
resolution: {integrity: sha512-ZMkxaopfwKHwmEuGDYx7giNBdLxbHbRCWcQVA1D2eqE4crUguupfxej6s7UqbidYEwT69dkyumYkY8DPHIxF9g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -6873,7 +6880,7 @@ snapshots:
|
||||
|
||||
'@larksuiteoapi/node-sdk@1.59.0':
|
||||
dependencies:
|
||||
axios: 1.13.5(debug@4.4.3)
|
||||
axios: 1.13.5
|
||||
lodash.identity: 3.0.0
|
||||
lodash.merge: 4.6.2
|
||||
lodash.pickby: 4.6.0
|
||||
@@ -6889,7 +6896,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.10.13
|
||||
optionalDependencies:
|
||||
axios: 1.13.5(debug@4.4.3)
|
||||
axios: 1.13.5
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
@@ -7078,7 +7085,7 @@ snapshots:
|
||||
'@azure/core-auth': 1.10.1
|
||||
'@azure/msal-node': 5.0.4
|
||||
'@microsoft/agents-activity': 1.3.1
|
||||
axios: 1.13.5(debug@4.4.3)
|
||||
axios: 1.13.5
|
||||
jsonwebtoken: 9.0.3
|
||||
jwks-rsa: 3.2.2
|
||||
object-path: 0.11.8
|
||||
@@ -7980,7 +7987,7 @@ snapshots:
|
||||
'@slack/types': 2.20.0
|
||||
'@slack/web-api': 7.14.1
|
||||
'@types/express': 5.0.6
|
||||
axios: 1.13.5(debug@4.4.3)
|
||||
axios: 1.13.5
|
||||
express: 5.2.1
|
||||
path-to-regexp: 8.3.0
|
||||
raw-body: 3.0.2
|
||||
@@ -8026,7 +8033,7 @@ snapshots:
|
||||
'@slack/types': 2.20.0
|
||||
'@types/node': 25.3.0
|
||||
'@types/retry': 0.12.0
|
||||
axios: 1.13.5(debug@4.4.3)
|
||||
axios: 1.13.5
|
||||
eventemitter3: 5.0.4
|
||||
form-data: 2.5.4
|
||||
is-electron: 2.2.2
|
||||
@@ -8915,6 +8922,14 @@ snapshots:
|
||||
|
||||
aws4@1.13.2: {}
|
||||
|
||||
axios@1.13.5:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 2.5.4
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
axios@1.13.5(debug@4.4.3):
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11(debug@4.4.3)
|
||||
@@ -9484,6 +9499,8 @@ snapshots:
|
||||
|
||||
flatbuffers@24.12.23: {}
|
||||
|
||||
follow-redirects@1.15.11: {}
|
||||
|
||||
follow-redirects@1.15.11(debug@4.4.3):
|
||||
optionalDependencies:
|
||||
debug: 4.4.3
|
||||
@@ -9808,6 +9825,8 @@ snapshots:
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
ipaddr.js@2.3.0: {}
|
||||
|
||||
ipull@3.9.3:
|
||||
dependencies:
|
||||
'@tinyhttp/content-disposition': 2.2.4
|
||||
|
||||
@@ -81,6 +81,11 @@ describe("isTrustedProxyAddress", () => {
|
||||
expect(isTrustedProxyAddress("172.19.5.100", proxies)).toBe(true); // CIDR match
|
||||
expect(isTrustedProxyAddress("10.43.0.1", proxies)).toBe(false); // no match
|
||||
});
|
||||
|
||||
it("supports IPv6 CIDR notation", () => {
|
||||
expect(isTrustedProxyAddress("2001:db8::1234", ["2001:db8::/32"])).toBe(true);
|
||||
expect(isTrustedProxyAddress("2001:db9::1234", ["2001:db8::/32"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backward compatibility", () => {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
||||
import {
|
||||
isCanonicalDottedDecimalIPv4,
|
||||
isIpInCidr,
|
||||
isLoopbackIpAddress,
|
||||
isPrivateOrLoopbackIpAddress,
|
||||
normalizeIpAddress,
|
||||
} from "../shared/net/ip.js";
|
||||
|
||||
/**
|
||||
* Pick the primary non-internal IPv4 address (LAN IP).
|
||||
@@ -49,22 +56,7 @@ export function resolveHostName(hostHeader?: string): string {
|
||||
}
|
||||
|
||||
export function isLoopbackAddress(ip: string | undefined): boolean {
|
||||
if (!ip) {
|
||||
return false;
|
||||
}
|
||||
if (ip === "127.0.0.1") {
|
||||
return true;
|
||||
}
|
||||
if (ip.startsWith("127.")) {
|
||||
return true;
|
||||
}
|
||||
if (ip === "::1") {
|
||||
return true;
|
||||
}
|
||||
if (ip.startsWith("::ffff:127.")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return isLoopbackIpAddress(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,58 +64,11 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
|
||||
* Private ranges: RFC1918, link-local, ULA IPv6, and CGNAT (100.64/10), plus loopback.
|
||||
*/
|
||||
export function isPrivateOrLoopbackAddress(ip: string | undefined): boolean {
|
||||
if (!ip) {
|
||||
return false;
|
||||
}
|
||||
if (isLoopbackAddress(ip)) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase());
|
||||
const family = net.isIP(normalized);
|
||||
if (!family) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (family === 4) {
|
||||
const octets = normalized.split(".").map((value) => Number.parseInt(value, 10));
|
||||
if (octets.length !== 4 || octets.some((value) => Number.isNaN(value))) {
|
||||
return false;
|
||||
}
|
||||
const [o1, o2] = octets;
|
||||
// RFC1918 IPv4 private ranges.
|
||||
if (o1 === 10 || (o1 === 172 && o2 >= 16 && o2 <= 31) || (o1 === 192 && o2 === 168)) {
|
||||
return true;
|
||||
}
|
||||
// IPv4 link-local and CGNAT (commonly used by Tailnet-like networks).
|
||||
if ((o1 === 169 && o2 === 254) || (o1 === 100 && o2 >= 64 && o2 <= 127)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv6 unique-local and link-local ranges.
|
||||
if (normalized.startsWith("fc") || normalized.startsWith("fd")) {
|
||||
return true;
|
||||
}
|
||||
if (/^fe[89ab]/.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeIPv4MappedAddress(ip: string): string {
|
||||
if (ip.startsWith("::ffff:")) {
|
||||
return ip.slice("::ffff:".length);
|
||||
}
|
||||
return ip;
|
||||
return isPrivateOrLoopbackIpAddress(ip);
|
||||
}
|
||||
|
||||
function normalizeIp(ip: string | undefined): string | undefined {
|
||||
const trimmed = ip?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeIPv4MappedAddress(trimmed.toLowerCase());
|
||||
return normalizeIpAddress(ip);
|
||||
}
|
||||
|
||||
function stripOptionalPort(ip: string): string {
|
||||
@@ -193,51 +138,6 @@ function resolveForwardedClientIp(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address matches a CIDR block.
|
||||
* Supports IPv4 CIDR notation (e.g., "10.42.0.0/24").
|
||||
*
|
||||
* @param ip - The IP address to check (e.g., "10.42.0.59")
|
||||
* @param cidr - The CIDR block (e.g., "10.42.0.0/24")
|
||||
* @returns True if the IP is within the CIDR block
|
||||
*/
|
||||
function ipMatchesCIDR(ip: string, cidr: string): boolean {
|
||||
// Handle exact IP match (no CIDR notation)
|
||||
if (!cidr.includes("/")) {
|
||||
return ip === cidr;
|
||||
}
|
||||
|
||||
const [subnet, prefixLenStr] = cidr.split("/");
|
||||
const prefixLen = parseInt(prefixLenStr, 10);
|
||||
|
||||
// Validate prefix length
|
||||
if (Number.isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert IPs to 32-bit integers
|
||||
const ipParts = ip.split(".").map((p) => parseInt(p, 10));
|
||||
const subnetParts = subnet.split(".").map((p) => parseInt(p, 10));
|
||||
|
||||
// Validate IP format
|
||||
if (
|
||||
ipParts.length !== 4 ||
|
||||
subnetParts.length !== 4 ||
|
||||
ipParts.some((p) => Number.isNaN(p) || p < 0 || p > 255) ||
|
||||
subnetParts.some((p) => Number.isNaN(p) || p < 0 || p > 255)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ipInt = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
||||
const subnetInt =
|
||||
(subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3];
|
||||
|
||||
// Create mask and compare
|
||||
const mask = prefixLen === 0 ? 0 : (-1 >>> (32 - prefixLen)) << (32 - prefixLen);
|
||||
return (ipInt & mask) === (subnetInt & mask);
|
||||
}
|
||||
|
||||
export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
|
||||
const normalized = normalizeIp(ip);
|
||||
if (!normalized || !trustedProxies || trustedProxies.length === 0) {
|
||||
@@ -249,12 +149,7 @@ export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: s
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
// Handle CIDR notation
|
||||
if (candidate.includes("/")) {
|
||||
return ipMatchesCIDR(normalized, candidate);
|
||||
}
|
||||
// Exact IP match
|
||||
return normalizeIp(candidate) === normalized;
|
||||
return isIpInCidr(normalized, candidate);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,7 +191,10 @@ export function isLocalGatewayAddress(ip: string | undefined): boolean {
|
||||
if (!ip) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase());
|
||||
const normalized = normalizeIp(ip);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||
if (tailnetIPv4 && normalized === tailnetIPv4.toLowerCase()) {
|
||||
return true;
|
||||
@@ -415,14 +313,7 @@ export async function resolveGatewayListenHosts(
|
||||
* @returns True if valid IPv4 format
|
||||
*/
|
||||
export function isValidIPv4(host: string): boolean {
|
||||
const parts = host.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return false;
|
||||
}
|
||||
return parts.every((part) => {
|
||||
const n = parseInt(part, 10);
|
||||
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
|
||||
});
|
||||
return isCanonicalDottedDecimalIPv4(host);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { lookup as dnsLookupCb, type LookupAddress } from "node:dns";
|
||||
import { lookup as dnsLookup } from "node:dns/promises";
|
||||
import { Agent, type Dispatcher } from "undici";
|
||||
import {
|
||||
extractEmbeddedIpv4FromIpv6,
|
||||
isBlockedSpecialUseIpv4Address,
|
||||
isCanonicalDottedDecimalIPv4,
|
||||
isIpv4Address,
|
||||
isLegacyIpv4Literal,
|
||||
isPrivateOrLoopbackIpAddress,
|
||||
parseCanonicalIpAddress,
|
||||
parseLooseIpAddress,
|
||||
} from "../../shared/net/ip.js";
|
||||
import { normalizeHostname } from "./hostname.js";
|
||||
|
||||
type LookupCallback = (
|
||||
@@ -68,48 +78,7 @@ function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolea
|
||||
return allowlist.some((pattern) => isHostnameAllowedByPattern(hostname, pattern));
|
||||
}
|
||||
|
||||
function parseStrictIpv4Octet(part: string): number | null {
|
||||
if (!/^[0-9]+$/.test(part)) {
|
||||
return null;
|
||||
}
|
||||
const value = Number.parseInt(part, 10);
|
||||
if (Number.isNaN(value) || value < 0 || value > 255) {
|
||||
return null;
|
||||
}
|
||||
// Accept only canonical decimal octets (no leading zeros, no alternate radices).
|
||||
if (part !== String(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseIpv4(address: string): number[] | null {
|
||||
const parts = address.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
for (const part of parts) {
|
||||
if (parseStrictIpv4Octet(part) === null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parts.map((part) => Number.parseInt(part, 10));
|
||||
}
|
||||
|
||||
function classifyIpv4Part(part: string): "decimal" | "hex" | "invalid-hex" | "non-numeric" {
|
||||
if (/^0x[0-9a-f]+$/i.test(part)) {
|
||||
return "hex";
|
||||
}
|
||||
if (/^0x/i.test(part)) {
|
||||
return "invalid-hex";
|
||||
}
|
||||
if (/^[0-9]+$/.test(part)) {
|
||||
return "decimal";
|
||||
}
|
||||
return "non-numeric";
|
||||
}
|
||||
|
||||
function isUnsupportedLegacyIpv4Literal(address: string): boolean {
|
||||
function looksLikeUnsupportedIpv4Literal(address: string): boolean {
|
||||
const parts = address.split(".");
|
||||
if (parts.length === 0 || parts.length > 4) {
|
||||
return false;
|
||||
@@ -117,220 +86,9 @@ function isUnsupportedLegacyIpv4Literal(address: string): boolean {
|
||||
if (parts.some((part) => part.length === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const partKinds = parts.map(classifyIpv4Part);
|
||||
if (partKinds.some((kind) => kind === "non-numeric")) {
|
||||
return false;
|
||||
}
|
||||
if (partKinds.some((kind) => kind === "invalid-hex")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parts.length !== 4) {
|
||||
return true;
|
||||
}
|
||||
for (const part of parts) {
|
||||
if (/^0x/i.test(part)) {
|
||||
return true;
|
||||
}
|
||||
const value = Number.parseInt(part, 10);
|
||||
if (Number.isNaN(value) || value > 255 || part !== String(value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripIpv6ZoneId(address: string): string {
|
||||
const index = address.indexOf("%");
|
||||
return index >= 0 ? address.slice(0, index) : address;
|
||||
}
|
||||
|
||||
function parseIpv6Hextets(address: string): number[] | null {
|
||||
let input = stripIpv6ZoneId(address.trim().toLowerCase());
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle IPv4-embedded IPv6 like ::ffff:127.0.0.1 by converting the tail to 2 hextets.
|
||||
if (input.includes(".")) {
|
||||
const lastColon = input.lastIndexOf(":");
|
||||
if (lastColon < 0) {
|
||||
return null;
|
||||
}
|
||||
const ipv4 = parseIpv4(input.slice(lastColon + 1));
|
||||
if (!ipv4) {
|
||||
return null;
|
||||
}
|
||||
const high = (ipv4[0] << 8) + ipv4[1];
|
||||
const low = (ipv4[2] << 8) + ipv4[3];
|
||||
input = `${input.slice(0, lastColon)}:${high.toString(16)}:${low.toString(16)}`;
|
||||
}
|
||||
|
||||
const doubleColonParts = input.split("::");
|
||||
if (doubleColonParts.length > 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headParts =
|
||||
doubleColonParts[0]?.length > 0 ? doubleColonParts[0].split(":").filter(Boolean) : [];
|
||||
const tailParts =
|
||||
doubleColonParts.length === 2 && doubleColonParts[1]?.length > 0
|
||||
? doubleColonParts[1].split(":").filter(Boolean)
|
||||
: [];
|
||||
|
||||
const missingParts = 8 - headParts.length - tailParts.length;
|
||||
if (missingParts < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fullParts =
|
||||
doubleColonParts.length === 1
|
||||
? input.split(":")
|
||||
: [...headParts, ...Array.from({ length: missingParts }, () => "0"), ...tailParts];
|
||||
|
||||
if (fullParts.length !== 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hextets: number[] = [];
|
||||
for (const part of fullParts) {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
const value = Number.parseInt(part, 16);
|
||||
if (Number.isNaN(value) || value < 0 || value > 0xffff) {
|
||||
return null;
|
||||
}
|
||||
hextets.push(value);
|
||||
}
|
||||
return hextets;
|
||||
}
|
||||
|
||||
function decodeIpv4FromHextets(high: number, low: number): number[] {
|
||||
return [(high >>> 8) & 0xff, high & 0xff, (low >>> 8) & 0xff, low & 0xff];
|
||||
}
|
||||
|
||||
type EmbeddedIpv4Rule = {
|
||||
matches: (hextets: number[]) => boolean;
|
||||
extract: (hextets: number[]) => [high: number, low: number];
|
||||
};
|
||||
|
||||
const EMBEDDED_IPV4_RULES: EmbeddedIpv4Rule[] = [
|
||||
{
|
||||
// IPv4-mapped: ::ffff:a.b.c.d and IPv4-compatible ::a.b.c.d.
|
||||
matches: (hextets) =>
|
||||
hextets[0] === 0 &&
|
||||
hextets[1] === 0 &&
|
||||
hextets[2] === 0 &&
|
||||
hextets[3] === 0 &&
|
||||
hextets[4] === 0 &&
|
||||
(hextets[5] === 0xffff || hextets[5] === 0),
|
||||
extract: (hextets) => [hextets[6], hextets[7]],
|
||||
},
|
||||
{
|
||||
// NAT64 well-known prefix: 64:ff9b::/96.
|
||||
matches: (hextets) =>
|
||||
hextets[0] === 0x0064 &&
|
||||
hextets[1] === 0xff9b &&
|
||||
hextets[2] === 0 &&
|
||||
hextets[3] === 0 &&
|
||||
hextets[4] === 0 &&
|
||||
hextets[5] === 0,
|
||||
extract: (hextets) => [hextets[6], hextets[7]],
|
||||
},
|
||||
{
|
||||
// NAT64 local-use prefix: 64:ff9b:1::/48.
|
||||
matches: (hextets) =>
|
||||
hextets[0] === 0x0064 &&
|
||||
hextets[1] === 0xff9b &&
|
||||
hextets[2] === 0x0001 &&
|
||||
hextets[3] === 0 &&
|
||||
hextets[4] === 0 &&
|
||||
hextets[5] === 0,
|
||||
extract: (hextets) => [hextets[6], hextets[7]],
|
||||
},
|
||||
{
|
||||
// 6to4 prefix: 2002::/16 where hextets[1..2] carry IPv4.
|
||||
matches: (hextets) => hextets[0] === 0x2002,
|
||||
extract: (hextets) => [hextets[1], hextets[2]],
|
||||
},
|
||||
{
|
||||
// Teredo prefix: 2001:0000::/32 with client IPv4 obfuscated via XOR 0xffff.
|
||||
matches: (hextets) => hextets[0] === 0x2001 && hextets[1] === 0x0000,
|
||||
extract: (hextets) => [hextets[6] ^ 0xffff, hextets[7] ^ 0xffff],
|
||||
},
|
||||
{
|
||||
// ISATAP IID format: 000000ug00000000:5efe:w.x.y.z (RFC 5214 section 6.1).
|
||||
// Match only the IID marker bits to avoid over-broad :5efe: detection.
|
||||
matches: (hextets) => (hextets[4] & 0xfcff) === 0 && hextets[5] === 0x5efe,
|
||||
extract: (hextets) => [hextets[6], hextets[7]],
|
||||
},
|
||||
];
|
||||
|
||||
function extractIpv4FromEmbeddedIpv6(hextets: number[]): number[] | null {
|
||||
for (const rule of EMBEDDED_IPV4_RULES) {
|
||||
if (!rule.matches(hextets)) {
|
||||
continue;
|
||||
}
|
||||
const [high, low] = rule.extract(hextets);
|
||||
return decodeIpv4FromHextets(high, low);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type Ipv4Cidr = {
|
||||
base: readonly [number, number, number, number];
|
||||
prefixLength: number;
|
||||
};
|
||||
|
||||
function ipv4ToUint(parts: readonly number[]): number {
|
||||
const [a, b, c, d] = parts;
|
||||
return (((a << 24) >>> 0) | (b << 16) | (c << 8) | d) >>> 0;
|
||||
}
|
||||
|
||||
function ipv4RangeFromCidr(cidr: Ipv4Cidr): readonly [start: number, end: number] {
|
||||
const base = ipv4ToUint(cidr.base);
|
||||
const hostBits = 32 - cidr.prefixLength;
|
||||
const mask = cidr.prefixLength === 0 ? 0 : (0xffffffff << hostBits) >>> 0;
|
||||
const start = (base & mask) >>> 0;
|
||||
const end = (start | (~mask >>> 0)) >>> 0;
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
const BLOCKED_IPV4_SPECIAL_USE_CIDRS: readonly Ipv4Cidr[] = [
|
||||
{ base: [0, 0, 0, 0], prefixLength: 8 },
|
||||
{ base: [10, 0, 0, 0], prefixLength: 8 },
|
||||
{ base: [100, 64, 0, 0], prefixLength: 10 },
|
||||
{ base: [127, 0, 0, 0], prefixLength: 8 },
|
||||
{ base: [169, 254, 0, 0], prefixLength: 16 },
|
||||
{ base: [172, 16, 0, 0], prefixLength: 12 },
|
||||
{ base: [192, 0, 0, 0], prefixLength: 24 },
|
||||
{ base: [192, 0, 2, 0], prefixLength: 24 },
|
||||
{ base: [192, 88, 99, 0], prefixLength: 24 },
|
||||
{ base: [192, 168, 0, 0], prefixLength: 16 },
|
||||
{ base: [198, 18, 0, 0], prefixLength: 15 },
|
||||
{ base: [198, 51, 100, 0], prefixLength: 24 },
|
||||
{ base: [203, 0, 113, 0], prefixLength: 24 },
|
||||
{ base: [224, 0, 0, 0], prefixLength: 4 },
|
||||
{ base: [240, 0, 0, 0], prefixLength: 4 },
|
||||
];
|
||||
|
||||
// Keep this table as the single source of IPv4 non-global policy.
|
||||
// Both plain IPv4 literals and IPv6-embedded IPv4 forms flow through it.
|
||||
const BLOCKED_IPV4_SPECIAL_USE_RANGES = BLOCKED_IPV4_SPECIAL_USE_CIDRS.map(ipv4RangeFromCidr);
|
||||
|
||||
function isBlockedIpv4SpecialUse(parts: number[]): boolean {
|
||||
if (parts.length !== 4) {
|
||||
return false;
|
||||
}
|
||||
const value = ipv4ToUint(parts);
|
||||
for (const [start, end] of BLOCKED_IPV4_SPECIAL_USE_RANGES) {
|
||||
if (value >= start && value <= end) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
// Tighten only "ipv4-ish" literals (numbers + optional 0x prefix). Hostnames like
|
||||
// "example.com" must stay in hostname policy handling and not be treated as malformed IPs.
|
||||
return parts.every((part) => /^[0-9]+$/.test(part) || /^0x/i.test(part));
|
||||
}
|
||||
|
||||
// Returns true for private/internal and special-use non-global addresses.
|
||||
@@ -343,63 +101,30 @@ export function isPrivateIpAddress(address: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.includes(":")) {
|
||||
const hextets = parseIpv6Hextets(normalized);
|
||||
if (!hextets) {
|
||||
// Security-critical parse failures should fail closed.
|
||||
const strictIp = parseCanonicalIpAddress(normalized);
|
||||
if (strictIp) {
|
||||
if (isIpv4Address(strictIp)) {
|
||||
return isBlockedSpecialUseIpv4Address(strictIp);
|
||||
}
|
||||
if (isPrivateOrLoopbackIpAddress(strictIp.toString())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isUnspecified =
|
||||
hextets[0] === 0 &&
|
||||
hextets[1] === 0 &&
|
||||
hextets[2] === 0 &&
|
||||
hextets[3] === 0 &&
|
||||
hextets[4] === 0 &&
|
||||
hextets[5] === 0 &&
|
||||
hextets[6] === 0 &&
|
||||
hextets[7] === 0;
|
||||
const isLoopback =
|
||||
hextets[0] === 0 &&
|
||||
hextets[1] === 0 &&
|
||||
hextets[2] === 0 &&
|
||||
hextets[3] === 0 &&
|
||||
hextets[4] === 0 &&
|
||||
hextets[5] === 0 &&
|
||||
hextets[6] === 0 &&
|
||||
hextets[7] === 1;
|
||||
if (isUnspecified || isLoopback) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const embeddedIpv4 = extractIpv4FromEmbeddedIpv6(hextets);
|
||||
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp);
|
||||
if (embeddedIpv4) {
|
||||
return isBlockedIpv4SpecialUse(embeddedIpv4);
|
||||
}
|
||||
|
||||
// IPv6 private/internal ranges
|
||||
// - link-local: fe80::/10
|
||||
// - site-local (deprecated, but internal): fec0::/10
|
||||
// - unique local: fc00::/7
|
||||
const first = hextets[0];
|
||||
if ((first & 0xffc0) === 0xfe80) {
|
||||
return true;
|
||||
}
|
||||
if ((first & 0xffc0) === 0xfec0) {
|
||||
return true;
|
||||
}
|
||||
if ((first & 0xfe00) === 0xfc00) {
|
||||
return true;
|
||||
return isBlockedSpecialUseIpv4Address(embeddedIpv4);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const ipv4 = parseIpv4(normalized);
|
||||
if (ipv4) {
|
||||
return isBlockedIpv4SpecialUse(ipv4);
|
||||
// Security-critical parse failures should fail closed for any malformed IPv6 literal.
|
||||
if (normalized.includes(":") && !parseLooseIpAddress(normalized)) {
|
||||
return true;
|
||||
}
|
||||
// Reject non-canonical IPv4 literal forms (octal/hex/short/packed) by default.
|
||||
if (isUnsupportedLegacyIpv4Literal(normalized)) {
|
||||
|
||||
if (!isCanonicalDottedDecimalIPv4(normalized) && isLegacyIpv4Literal(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (looksLikeUnsupportedIpv4Literal(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os from "node:os";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js";
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = 18789;
|
||||
|
||||
@@ -113,43 +114,12 @@ function resolveScheme(
|
||||
return cfg.gateway?.tls?.enabled === true ? "wss" : "ws";
|
||||
}
|
||||
|
||||
function parseIPv4Octets(address: string): [number, number, number, number] | null {
|
||||
const parts = address.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
const octets = parts.map((part) => Number.parseInt(part, 10));
|
||||
if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) {
|
||||
return null;
|
||||
}
|
||||
return [octets[0], octets[1], octets[2], octets[3]];
|
||||
}
|
||||
|
||||
function isPrivateIPv4(address: string): boolean {
|
||||
const octets = parseIPv4Octets(address);
|
||||
if (!octets) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = octets;
|
||||
if (a === 10) {
|
||||
return true;
|
||||
}
|
||||
if (a === 172 && b >= 16 && b <= 31) {
|
||||
return true;
|
||||
}
|
||||
if (a === 192 && b === 168) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return isRfc1918Ipv4Address(address);
|
||||
}
|
||||
|
||||
function isTailnetIPv4(address: string): boolean {
|
||||
const octets = parseIPv4Octets(address);
|
||||
if (!octets) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = octets;
|
||||
return a === 100 && b >= 64 && b <= 127;
|
||||
return isCarrierGradeNatIpv4Address(address);
|
||||
}
|
||||
|
||||
function pickIPv4Matching(
|
||||
|
||||
52
src/shared/net/ip.test.ts
Normal file
52
src/shared/net/ip.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractEmbeddedIpv4FromIpv6,
|
||||
isCanonicalDottedDecimalIPv4,
|
||||
isIpInCidr,
|
||||
isIpv6Address,
|
||||
isLegacyIpv4Literal,
|
||||
isPrivateOrLoopbackIpAddress,
|
||||
parseCanonicalIpAddress,
|
||||
} from "./ip.js";
|
||||
|
||||
describe("shared ip helpers", () => {
|
||||
it("distinguishes canonical dotted IPv4 from legacy forms", () => {
|
||||
expect(isCanonicalDottedDecimalIPv4("127.0.0.1")).toBe(true);
|
||||
expect(isCanonicalDottedDecimalIPv4("0177.0.0.1")).toBe(false);
|
||||
expect(isLegacyIpv4Literal("0177.0.0.1")).toBe(true);
|
||||
expect(isLegacyIpv4Literal("127.1")).toBe(true);
|
||||
expect(isLegacyIpv4Literal("example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches both IPv4 and IPv6 CIDRs", () => {
|
||||
expect(isIpInCidr("10.42.0.59", "10.42.0.0/24")).toBe(true);
|
||||
expect(isIpInCidr("10.43.0.59", "10.42.0.0/24")).toBe(false);
|
||||
expect(isIpInCidr("2001:db8::1234", "2001:db8::/32")).toBe(true);
|
||||
expect(isIpInCidr("2001:db9::1234", "2001:db8::/32")).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts embedded IPv4 for transition prefixes", () => {
|
||||
const cases = [
|
||||
["::ffff:127.0.0.1", "127.0.0.1"],
|
||||
["::127.0.0.1", "127.0.0.1"],
|
||||
["64:ff9b::8.8.8.8", "8.8.8.8"],
|
||||
["64:ff9b:1::10.0.0.1", "10.0.0.1"],
|
||||
["2002:0808:0808::", "8.8.8.8"],
|
||||
["2001::f7f7:f7f7", "8.8.8.8"],
|
||||
["2001:4860:1::5efe:7f00:1", "127.0.0.1"],
|
||||
] as const;
|
||||
for (const [ipv6Literal, expectedIpv4] of cases) {
|
||||
const parsed = parseCanonicalIpAddress(ipv6Literal);
|
||||
expect(parsed?.kind(), ipv6Literal).toBe("ipv6");
|
||||
if (!parsed || !isIpv6Address(parsed)) {
|
||||
continue;
|
||||
}
|
||||
expect(extractEmbeddedIpv4FromIpv6(parsed)?.toString(), ipv6Literal).toBe(expectedIpv4);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats deprecated site-local IPv6 as private/internal", () => {
|
||||
expect(isPrivateOrLoopbackIpAddress("fec0::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackIpAddress("2001:4860:4860::8888")).toBe(false);
|
||||
});
|
||||
});
|
||||
283
src/shared/net/ip.ts
Normal file
283
src/shared/net/ip.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import ipaddr from "ipaddr.js";
|
||||
|
||||
export type ParsedIpAddress = ipaddr.IPv4 | ipaddr.IPv6;
|
||||
type Ipv4Range = ReturnType<ipaddr.IPv4["range"]>;
|
||||
type Ipv6Range = ReturnType<ipaddr.IPv6["range"]>;
|
||||
|
||||
const BLOCKED_IPV4_SPECIAL_USE_RANGES = new Set<Ipv4Range>([
|
||||
"unspecified",
|
||||
"broadcast",
|
||||
"multicast",
|
||||
"linkLocal",
|
||||
"loopback",
|
||||
"carrierGradeNat",
|
||||
"private",
|
||||
"reserved",
|
||||
]);
|
||||
|
||||
const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set<Ipv4Range>([
|
||||
"loopback",
|
||||
"private",
|
||||
"linkLocal",
|
||||
"carrierGradeNat",
|
||||
]);
|
||||
|
||||
const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set<Ipv6Range>([
|
||||
"unspecified",
|
||||
"loopback",
|
||||
"linkLocal",
|
||||
"uniqueLocal",
|
||||
]);
|
||||
|
||||
const EMBEDDED_IPV4_SENTINEL_RULES: Array<{
|
||||
matches: (parts: number[]) => boolean;
|
||||
toHextets: (parts: number[]) => [high: number, low: number];
|
||||
}> = [
|
||||
{
|
||||
// IPv4-compatible form ::w.x.y.z (deprecated, but still seen in parser edge-cases).
|
||||
matches: (parts) =>
|
||||
parts[0] === 0 &&
|
||||
parts[1] === 0 &&
|
||||
parts[2] === 0 &&
|
||||
parts[3] === 0 &&
|
||||
parts[4] === 0 &&
|
||||
parts[5] === 0,
|
||||
toHextets: (parts) => [parts[6], parts[7]],
|
||||
},
|
||||
{
|
||||
// NAT64 local-use prefix: 64:ff9b:1::/48.
|
||||
matches: (parts) =>
|
||||
parts[0] === 0x0064 &&
|
||||
parts[1] === 0xff9b &&
|
||||
parts[2] === 0x0001 &&
|
||||
parts[3] === 0 &&
|
||||
parts[4] === 0 &&
|
||||
parts[5] === 0,
|
||||
toHextets: (parts) => [parts[6], parts[7]],
|
||||
},
|
||||
{
|
||||
// 6to4 prefix: 2002::/16 (IPv4 lives in hextets 1..2).
|
||||
matches: (parts) => parts[0] === 0x2002,
|
||||
toHextets: (parts) => [parts[1], parts[2]],
|
||||
},
|
||||
{
|
||||
// Teredo prefix: 2001:0000::/32 (client IPv4 XOR 0xffff in hextets 6..7).
|
||||
matches: (parts) => parts[0] === 0x2001 && parts[1] === 0x0000,
|
||||
toHextets: (parts) => [parts[6] ^ 0xffff, parts[7] ^ 0xffff],
|
||||
},
|
||||
{
|
||||
// ISATAP IID marker: ....:0000:5efe:w.x.y.z with u/g bits allowed in hextet 4.
|
||||
matches: (parts) => (parts[4] & 0xfcff) === 0 && parts[5] === 0x5efe,
|
||||
toHextets: (parts) => [parts[6], parts[7]],
|
||||
},
|
||||
];
|
||||
|
||||
function stripIpv6Brackets(value: string): string {
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isIpv4Address(address: ParsedIpAddress): address is ipaddr.IPv4 {
|
||||
return address.kind() === "ipv4";
|
||||
}
|
||||
|
||||
export function isIpv6Address(address: ParsedIpAddress): address is ipaddr.IPv6 {
|
||||
return address.kind() === "ipv6";
|
||||
}
|
||||
|
||||
function normalizeIpv4MappedAddress(address: ParsedIpAddress): ParsedIpAddress {
|
||||
if (!isIpv6Address(address)) {
|
||||
return address;
|
||||
}
|
||||
if (!address.isIPv4MappedAddress()) {
|
||||
return address;
|
||||
}
|
||||
return address.toIPv4Address();
|
||||
}
|
||||
|
||||
export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddress | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = stripIpv6Brackets(trimmed);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (ipaddr.IPv4.isValid(normalized)) {
|
||||
if (!ipaddr.IPv4.isValidFourPartDecimal(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return ipaddr.IPv4.parse(normalized);
|
||||
}
|
||||
if (ipaddr.IPv6.isValid(normalized)) {
|
||||
return ipaddr.IPv6.parse(normalized);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseLooseIpAddress(raw: string | undefined): ParsedIpAddress | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = stripIpv6Brackets(trimmed);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (!ipaddr.isValid(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return ipaddr.parse(normalized);
|
||||
}
|
||||
|
||||
export function normalizeIpAddress(raw: string | undefined): string | undefined {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
return normalized.toString().toLowerCase();
|
||||
}
|
||||
|
||||
export function isCanonicalDottedDecimalIPv4(raw: string | undefined): boolean {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = stripIpv6Brackets(trimmed);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return ipaddr.IPv4.isValidFourPartDecimal(normalized);
|
||||
}
|
||||
|
||||
export function isLegacyIpv4Literal(raw: string | undefined): boolean {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = stripIpv6Brackets(trimmed);
|
||||
if (!normalized || normalized.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
return ipaddr.IPv4.isValid(normalized) && !ipaddr.IPv4.isValidFourPartDecimal(normalized);
|
||||
}
|
||||
|
||||
export function isLoopbackIpAddress(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
return normalized.range() === "loopback";
|
||||
}
|
||||
|
||||
export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
if (isIpv4Address(normalized)) {
|
||||
return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range());
|
||||
}
|
||||
if (PRIVATE_OR_LOOPBACK_IPV6_RANGES.has(normalized.range())) {
|
||||
return true;
|
||||
}
|
||||
// ipaddr.js does not classify deprecated site-local fec0::/10 as private.
|
||||
return (normalized.parts[0] & 0xffc0) === 0xfec0;
|
||||
}
|
||||
|
||||
export function isRfc1918Ipv4Address(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed || !isIpv4Address(parsed)) {
|
||||
return false;
|
||||
}
|
||||
return parsed.range() === "private";
|
||||
}
|
||||
|
||||
export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed || !isIpv4Address(parsed)) {
|
||||
return false;
|
||||
}
|
||||
return parsed.range() === "carrierGradeNat";
|
||||
}
|
||||
|
||||
export function isBlockedSpecialUseIpv4Address(address: ipaddr.IPv4): boolean {
|
||||
return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range());
|
||||
}
|
||||
|
||||
function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 {
|
||||
const octets: [number, number, number, number] = [
|
||||
(high >>> 8) & 0xff,
|
||||
high & 0xff,
|
||||
(low >>> 8) & 0xff,
|
||||
low & 0xff,
|
||||
];
|
||||
return ipaddr.IPv4.parse(octets.join("."));
|
||||
}
|
||||
|
||||
export function extractEmbeddedIpv4FromIpv6(address: ipaddr.IPv6): ipaddr.IPv4 | undefined {
|
||||
if (address.isIPv4MappedAddress()) {
|
||||
return address.toIPv4Address();
|
||||
}
|
||||
if (address.range() === "rfc6145") {
|
||||
return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
|
||||
}
|
||||
if (address.range() === "rfc6052") {
|
||||
return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
|
||||
}
|
||||
for (const rule of EMBEDDED_IPV4_SENTINEL_RULES) {
|
||||
if (!rule.matches(address.parts)) {
|
||||
continue;
|
||||
}
|
||||
const [high, low] = rule.toHextets(address.parts);
|
||||
return decodeIpv4FromHextets(high, low);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||
const normalizedIp = parseCanonicalIpAddress(ip);
|
||||
if (!normalizedIp) {
|
||||
return false;
|
||||
}
|
||||
const candidate = cidr.trim();
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const comparableIp = normalizeIpv4MappedAddress(normalizedIp);
|
||||
if (!candidate.includes("/")) {
|
||||
const exact = parseCanonicalIpAddress(candidate);
|
||||
if (!exact) {
|
||||
return false;
|
||||
}
|
||||
const comparableExact = normalizeIpv4MappedAddress(exact);
|
||||
return (
|
||||
comparableIp.kind() === comparableExact.kind() &&
|
||||
comparableIp.toString() === comparableExact.toString()
|
||||
);
|
||||
}
|
||||
|
||||
let parsedCidr: [ParsedIpAddress, number];
|
||||
try {
|
||||
parsedCidr = ipaddr.parseCIDR(candidate);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [baseAddress, prefixLength] = parsedCidr;
|
||||
const comparableBase = normalizeIpv4MappedAddress(baseAddress);
|
||||
if (comparableIp.kind() !== comparableBase.kind()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return comparableIp.match([comparableBase, prefixLength]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,13 @@
|
||||
import { isCanonicalDottedDecimalIPv4 } from "./ip.js";
|
||||
|
||||
export function validateDottedDecimalIPv4Input(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return "IP address is required for custom bind mode";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
const parts = trimmed.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return "Invalid IPv4 address (e.g., 192.168.1.100)";
|
||||
}
|
||||
if (
|
||||
parts.every((part) => {
|
||||
const n = parseInt(part, 10);
|
||||
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
|
||||
})
|
||||
) {
|
||||
if (isCanonicalDottedDecimalIPv4(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return "Invalid IPv4 address (each octet must be 0-255)";
|
||||
return "Invalid IPv4 address (e.g., 192.168.1.100)";
|
||||
}
|
||||
|
||||
// Backward-compatible alias for callers using the old helper name.
|
||||
|
||||
Reference in New Issue
Block a user