mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 14:18:26 +00:00
refactor(net): consolidate IP checks with ipaddr.js
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user