refactor(net): consolidate IP checks with ipaddr.js

This commit is contained in:
Peter Steinberger
2026-02-22 17:02:34 +01:00
parent 337eef55d7
commit 333fbb8634
9 changed files with 418 additions and 480 deletions

View File

@@ -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);
}
/**