mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 17:40:23 +00:00
refactor(net): consolidate IP checks with ipaddr.js
This commit is contained in:
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