2025-05-09 17:00:27 +00:00
|
|
|
import * as plugins from '../../plugins.js';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Utility class for IP address operations
|
|
|
|
|
*/
|
|
|
|
|
export class IpUtils {
|
|
|
|
|
/**
|
|
|
|
|
* Check if the IP matches any of the glob patterns
|
|
|
|
|
*
|
|
|
|
|
* This method checks IP addresses against glob patterns and handles IPv4/IPv6 normalization.
|
|
|
|
|
* It's used to implement IP filtering based on security configurations.
|
|
|
|
|
*
|
|
|
|
|
* @param ip - The IP address to check
|
|
|
|
|
* @param patterns - Array of glob patterns
|
|
|
|
|
* @returns true if IP matches any pattern, false otherwise
|
|
|
|
|
*/
|
|
|
|
|
public static isGlobIPMatch(ip: string, patterns: string[]): boolean {
|
|
|
|
|
if (!ip || !patterns || patterns.length === 0) return false;
|
|
|
|
|
|
|
|
|
|
// Normalize the IP being checked
|
|
|
|
|
const normalizedIPVariants = this.normalizeIP(ip);
|
|
|
|
|
if (normalizedIPVariants.length === 0) return false;
|
|
|
|
|
|
2025-08-19 11:38:20 +00:00
|
|
|
// Check each pattern
|
|
|
|
|
for (const pattern of patterns) {
|
|
|
|
|
// Handle CIDR notation
|
|
|
|
|
if (pattern.includes('/')) {
|
|
|
|
|
if (this.matchCIDR(ip, pattern)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2025-05-09 17:00:27 +00:00
|
|
|
|
2025-08-19 11:38:20 +00:00
|
|
|
// Handle range notation
|
|
|
|
|
if (pattern.includes('-') && !pattern.includes('*')) {
|
|
|
|
|
if (this.matchIPRange(ip, pattern)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Expand shorthand patterns for glob matching
|
|
|
|
|
let expandedPattern = pattern;
|
|
|
|
|
if (pattern.includes('*') && !pattern.includes(':')) {
|
|
|
|
|
const parts = pattern.split('.');
|
|
|
|
|
while (parts.length < 4) {
|
|
|
|
|
parts.push('*');
|
|
|
|
|
}
|
|
|
|
|
expandedPattern = parts.join('.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normalize and check with minimatch
|
|
|
|
|
const normalizedPatterns = this.normalizeIP(expandedPattern);
|
|
|
|
|
|
|
|
|
|
for (const ipVariant of normalizedIPVariants) {
|
|
|
|
|
for (const normalizedPattern of normalizedPatterns) {
|
|
|
|
|
if (plugins.minimatch(ipVariant, normalizedPattern)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
2025-05-09 17:00:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Normalize IP addresses for consistent comparison
|
|
|
|
|
*
|
|
|
|
|
* @param ip The IP address to normalize
|
|
|
|
|
* @returns Array of normalized IP forms
|
|
|
|
|
*/
|
|
|
|
|
public static normalizeIP(ip: string): string[] {
|
|
|
|
|
if (!ip) return [];
|
|
|
|
|
|
|
|
|
|
// Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1)
|
|
|
|
|
if (ip.startsWith('::ffff:')) {
|
|
|
|
|
const ipv4 = ip.slice(7);
|
|
|
|
|
return [ip, ipv4];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle IPv4 addresses by also checking IPv4-mapped form
|
|
|
|
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
|
|
|
|
|
return [ip, `::ffff:${ip}`];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [ip];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if an IP is authorized using security rules
|
|
|
|
|
*
|
|
|
|
|
* @param ip - The IP address to check
|
|
|
|
|
* @param allowedIPs - Array of allowed IP patterns
|
|
|
|
|
* @param blockedIPs - Array of blocked IP patterns
|
|
|
|
|
* @returns true if IP is authorized, false if blocked
|
|
|
|
|
*/
|
|
|
|
|
public static isIPAuthorized(ip: string, allowedIPs: string[] = [], blockedIPs: string[] = []): boolean {
|
|
|
|
|
// Skip IP validation if no rules are defined
|
|
|
|
|
if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// First check if IP is blocked - blocked IPs take precedence
|
|
|
|
|
if (blockedIPs.length > 0 && this.isGlobIPMatch(ip, blockedIPs)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Then check if IP is allowed (if no allowed IPs are specified, all non-blocked IPs are allowed)
|
|
|
|
|
return allowedIPs.length === 0 || this.isGlobIPMatch(ip, allowedIPs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if an IP address is a private network address
|
|
|
|
|
*
|
|
|
|
|
* @param ip The IP address to check
|
|
|
|
|
* @returns true if the IP is a private network address, false otherwise
|
|
|
|
|
*/
|
|
|
|
|
public static isPrivateIP(ip: string): boolean {
|
|
|
|
|
if (!ip) return false;
|
|
|
|
|
|
|
|
|
|
// Handle IPv4-mapped IPv6 addresses
|
|
|
|
|
if (ip.startsWith('::ffff:')) {
|
|
|
|
|
ip = ip.slice(7);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check IPv4 private ranges
|
|
|
|
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
|
|
|
|
|
const parts = ip.split('.').map(Number);
|
|
|
|
|
|
|
|
|
|
// Check common private ranges
|
|
|
|
|
// 10.0.0.0/8
|
|
|
|
|
if (parts[0] === 10) return true;
|
|
|
|
|
|
|
|
|
|
// 172.16.0.0/12
|
|
|
|
|
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
|
|
|
|
|
|
|
|
// 192.168.0.0/16
|
|
|
|
|
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
|
|
|
|
|
|
|
|
// 127.0.0.0/8 (localhost)
|
|
|
|
|
if (parts[0] === 127) return true;
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IPv6 local addresses
|
|
|
|
|
return ip === '::1' || ip.startsWith('fc00:') || ip.startsWith('fd00:') || ip.startsWith('fe80:');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if an IP address is a public network address
|
|
|
|
|
*
|
|
|
|
|
* @param ip The IP address to check
|
|
|
|
|
* @returns true if the IP is a public network address, false otherwise
|
|
|
|
|
*/
|
|
|
|
|
public static isPublicIP(ip: string): boolean {
|
|
|
|
|
return !this.isPrivateIP(ip);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-19 11:38:20 +00:00
|
|
|
/**
|
|
|
|
|
* Check if an IP matches a CIDR notation
|
|
|
|
|
*
|
|
|
|
|
* @param ip The IP address to check
|
|
|
|
|
* @param cidr The CIDR notation (e.g., "192.168.1.0/24")
|
|
|
|
|
* @returns true if IP is within the CIDR range
|
|
|
|
|
*/
|
|
|
|
|
private static matchCIDR(ip: string, cidr: string): boolean {
|
|
|
|
|
if (!cidr.includes('/')) return false;
|
|
|
|
|
|
|
|
|
|
const [networkAddr, prefixStr] = cidr.split('/');
|
|
|
|
|
const prefix = parseInt(prefixStr, 10);
|
|
|
|
|
|
|
|
|
|
// Handle IPv4-mapped IPv6 in the IP being checked
|
|
|
|
|
let checkIP = ip;
|
|
|
|
|
if (checkIP.startsWith('::ffff:')) {
|
|
|
|
|
checkIP = checkIP.slice(7);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle IPv6 CIDR
|
|
|
|
|
if (networkAddr.includes(':')) {
|
|
|
|
|
// TODO: Implement IPv6 CIDR matching
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IPv4 CIDR matching
|
|
|
|
|
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(checkIP)) return false;
|
|
|
|
|
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(networkAddr)) return false;
|
|
|
|
|
if (isNaN(prefix) || prefix < 0 || prefix > 32) return false;
|
|
|
|
|
|
|
|
|
|
const ipParts = checkIP.split('.').map(Number);
|
|
|
|
|
const netParts = networkAddr.split('.').map(Number);
|
|
|
|
|
|
|
|
|
|
// Validate IP parts
|
|
|
|
|
for (const part of [...ipParts, ...netParts]) {
|
|
|
|
|
if (part < 0 || part > 255) return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert to 32-bit integers
|
|
|
|
|
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
|
|
|
|
const netNum = (netParts[0] << 24) | (netParts[1] << 16) | (netParts[2] << 8) | netParts[3];
|
|
|
|
|
|
|
|
|
|
// Create mask
|
|
|
|
|
const mask = (-1 << (32 - prefix)) >>> 0;
|
|
|
|
|
|
|
|
|
|
// Check if IP is in network range
|
|
|
|
|
return (ipNum & mask) === (netNum & mask);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if an IP matches a range notation
|
|
|
|
|
*
|
|
|
|
|
* @param ip The IP address to check
|
|
|
|
|
* @param range The range notation (e.g., "192.168.1.1-192.168.1.100")
|
|
|
|
|
* @returns true if IP is within the range
|
|
|
|
|
*/
|
|
|
|
|
private static matchIPRange(ip: string, range: string): boolean {
|
|
|
|
|
if (!range.includes('-')) return false;
|
|
|
|
|
|
|
|
|
|
const [startIP, endIP] = range.split('-').map(s => s.trim());
|
|
|
|
|
|
|
|
|
|
// Handle IPv4-mapped IPv6 in the IP being checked
|
|
|
|
|
let checkIP = ip;
|
|
|
|
|
if (checkIP.startsWith('::ffff:')) {
|
|
|
|
|
checkIP = checkIP.slice(7);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only handle IPv4 for now
|
|
|
|
|
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(checkIP)) return false;
|
|
|
|
|
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(startIP)) return false;
|
|
|
|
|
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(endIP)) return false;
|
|
|
|
|
|
|
|
|
|
const ipParts = checkIP.split('.').map(Number);
|
|
|
|
|
const startParts = startIP.split('.').map(Number);
|
|
|
|
|
const endParts = endIP.split('.').map(Number);
|
|
|
|
|
|
|
|
|
|
// Validate parts
|
|
|
|
|
for (const part of [...ipParts, ...startParts, ...endParts]) {
|
|
|
|
|
if (part < 0 || part > 255) return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert to 32-bit integers for comparison
|
|
|
|
|
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
|
|
|
|
const startNum = (startParts[0] << 24) | (startParts[1] << 16) | (startParts[2] << 8) | startParts[3];
|
|
|
|
|
const endNum = (endParts[0] << 24) | (endParts[1] << 16) | (endParts[2] << 8) | endParts[3];
|
|
|
|
|
|
|
|
|
|
// Convert to unsigned for proper comparison
|
|
|
|
|
const ipUnsigned = ipNum >>> 0;
|
|
|
|
|
const startUnsigned = startNum >>> 0;
|
|
|
|
|
const endUnsigned = endNum >>> 0;
|
|
|
|
|
|
|
|
|
|
return ipUnsigned >= startUnsigned && ipUnsigned <= endUnsigned;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-09 17:00:27 +00:00
|
|
|
/**
|
|
|
|
|
* Convert a subnet CIDR to an IP range for filtering
|
|
|
|
|
*
|
|
|
|
|
* @param cidr The CIDR notation (e.g., "192.168.1.0/24")
|
|
|
|
|
* @returns Array of glob patterns that match the CIDR range
|
|
|
|
|
*/
|
|
|
|
|
public static cidrToGlobPatterns(cidr: string): string[] {
|
|
|
|
|
if (!cidr || !cidr.includes('/')) return [];
|
|
|
|
|
|
|
|
|
|
const [ipPart, prefixPart] = cidr.split('/');
|
|
|
|
|
const prefix = parseInt(prefixPart, 10);
|
|
|
|
|
|
|
|
|
|
if (isNaN(prefix) || prefix < 0 || prefix > 32) return [];
|
|
|
|
|
|
|
|
|
|
// For IPv4 only for now
|
|
|
|
|
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ipPart)) return [];
|
|
|
|
|
|
|
|
|
|
const ipParts = ipPart.split('.').map(Number);
|
|
|
|
|
const fullMask = Math.pow(2, 32 - prefix) - 1;
|
|
|
|
|
|
|
|
|
|
// Convert IP to a numeric value
|
|
|
|
|
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
|
|
|
|
|
|
|
|
|
// Calculate network address (IP & ~fullMask)
|
|
|
|
|
const networkNum = ipNum & ~fullMask;
|
|
|
|
|
|
|
|
|
|
// For large ranges, return wildcard patterns
|
|
|
|
|
if (prefix <= 8) {
|
|
|
|
|
return [`${(networkNum >>> 24) & 255}.*.*.*`];
|
|
|
|
|
} else if (prefix <= 16) {
|
|
|
|
|
return [`${(networkNum >>> 24) & 255}.${(networkNum >>> 16) & 255}.*.*`];
|
|
|
|
|
} else if (prefix <= 24) {
|
|
|
|
|
return [`${(networkNum >>> 24) & 255}.${(networkNum >>> 16) & 255}.${(networkNum >>> 8) & 255}.*`];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For small ranges, create individual IP patterns
|
|
|
|
|
const patterns = [];
|
|
|
|
|
const maxAddresses = Math.min(256, Math.pow(2, 32 - prefix));
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < maxAddresses; i++) {
|
|
|
|
|
const currentIpNum = networkNum + i;
|
|
|
|
|
patterns.push(
|
|
|
|
|
`${(currentIpNum >>> 24) & 255}.${(currentIpNum >>> 16) & 255}.${(currentIpNum >>> 8) & 255}.${currentIpNum & 255}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return patterns;
|
|
|
|
|
}
|
|
|
|
|
}
|