fix(ip-utils): Fix IP wildcard/shorthand handling and add validation test
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-08-19 - 21.1.6 - fix(ip-utils)
|
||||||
|
Fix IP wildcard/shorthand handling and add validation test
|
||||||
|
|
||||||
|
- Support shorthand IPv4 wildcard patterns (e.g. '10.*', '192.168.*') by expanding them to full 4-octet patterns before matching
|
||||||
|
- Normalize and expand patterns in IpUtils.isGlobIPMatch and SharedSecurityManager IP checks to ensure consistent minimatch comparisons
|
||||||
|
- Relax route validator wildcard checks to accept 1-4 octet wildcard specifications for IPv4 patterns
|
||||||
|
- Add test harness test-ip-validation.ts to exercise common wildcard/shorthand IP patterns
|
||||||
|
|
||||||
## 2025-08-19 - 21.1.5 - fix(core)
|
## 2025-08-19 - 21.1.5 - fix(core)
|
||||||
Prepare patch release: documentation, tests and stability fixes (metrics, ACME, connection cleanup)
|
Prepare patch release: documentation, tests and stability fixes (metrics, ACME, connection cleanup)
|
||||||
|
|
||||||
|
|||||||
128
test/test.ip-validation.ts
Normal file
128
test/test.ip-validation.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartproxy from '../ts/index.js';
|
||||||
|
import { RouteValidator } from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||||
|
import { IpUtils } from '../ts/core/utils/ip-utils.js';
|
||||||
|
|
||||||
|
tap.test('IP Validation - Shorthand patterns', async () => {
|
||||||
|
|
||||||
|
// Test shorthand patterns are now accepted
|
||||||
|
const testPatterns = [
|
||||||
|
{ pattern: '192.168.*', shouldPass: true },
|
||||||
|
{ pattern: '192.168.*.*', shouldPass: true },
|
||||||
|
{ pattern: '10.*', shouldPass: true },
|
||||||
|
{ pattern: '10.*.*.*', shouldPass: true },
|
||||||
|
{ pattern: '172.16.*', shouldPass: true },
|
||||||
|
{ pattern: '10.0.0.0/8', shouldPass: true },
|
||||||
|
{ pattern: '192.168.0.0/16', shouldPass: true },
|
||||||
|
{ pattern: '192.168.1.100', shouldPass: true },
|
||||||
|
{ pattern: '*', shouldPass: true },
|
||||||
|
{ pattern: '192.168.1.1-192.168.1.100', shouldPass: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { pattern, shouldPass } of testPatterns) {
|
||||||
|
const route = {
|
||||||
|
name: 'test',
|
||||||
|
match: { ports: 80 },
|
||||||
|
action: { type: 'forward' as const, targets: [{ host: 'localhost', port: 8080 }] },
|
||||||
|
security: { ipAllowList: [pattern] }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = RouteValidator.validateRoute(route);
|
||||||
|
|
||||||
|
if (shouldPass) {
|
||||||
|
expect(result.valid).toEqual(true);
|
||||||
|
console.log(`✅ Pattern '${pattern}' correctly accepted`);
|
||||||
|
} else {
|
||||||
|
expect(result.valid).toEqual(false);
|
||||||
|
console.log(`✅ Pattern '${pattern}' correctly rejected`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('IP Matching - Runtime shorthand pattern matching', async () => {
|
||||||
|
|
||||||
|
// Test runtime matching with shorthand patterns
|
||||||
|
const testCases = [
|
||||||
|
{ ip: '192.168.1.100', patterns: ['192.168.*'], expected: true },
|
||||||
|
{ ip: '192.168.1.100', patterns: ['192.168.1.*'], expected: true },
|
||||||
|
{ ip: '192.168.1.100', patterns: ['192.168.2.*'], expected: false },
|
||||||
|
{ ip: '10.0.0.1', patterns: ['10.*'], expected: true },
|
||||||
|
{ ip: '10.1.2.3', patterns: ['10.*'], expected: true },
|
||||||
|
{ ip: '172.16.0.1', patterns: ['10.*'], expected: false },
|
||||||
|
{ ip: '192.168.1.1', patterns: ['192.168.*.*'], expected: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { ip, patterns, expected } of testCases) {
|
||||||
|
const result = IpUtils.isGlobIPMatch(ip, patterns);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
console.log(`✅ IP ${ip} with pattern ${patterns[0]} = ${result} (expected ${expected})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('IP Matching - CIDR notation', async () => {
|
||||||
|
|
||||||
|
// Test CIDR notation matching
|
||||||
|
const cidrTests = [
|
||||||
|
{ ip: '10.0.0.1', cidr: '10.0.0.0/8', expected: true },
|
||||||
|
{ ip: '10.255.255.255', cidr: '10.0.0.0/8', expected: true },
|
||||||
|
{ ip: '11.0.0.1', cidr: '10.0.0.0/8', expected: false },
|
||||||
|
{ ip: '192.168.1.1', cidr: '192.168.0.0/16', expected: true },
|
||||||
|
{ ip: '192.168.255.255', cidr: '192.168.0.0/16', expected: true },
|
||||||
|
{ ip: '192.169.0.1', cidr: '192.168.0.0/16', expected: false },
|
||||||
|
{ ip: '192.168.1.100', cidr: '192.168.1.0/24', expected: true },
|
||||||
|
{ ip: '192.168.2.100', cidr: '192.168.1.0/24', expected: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { ip, cidr, expected } of cidrTests) {
|
||||||
|
const result = IpUtils.isGlobIPMatch(ip, [cidr]);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
console.log(`✅ IP ${ip} in CIDR ${cidr} = ${result} (expected ${expected})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('IP Matching - Range notation', async () => {
|
||||||
|
|
||||||
|
// Test range notation matching
|
||||||
|
const rangeTests = [
|
||||||
|
{ ip: '192.168.1.1', range: '192.168.1.1-192.168.1.100', expected: true },
|
||||||
|
{ ip: '192.168.1.50', range: '192.168.1.1-192.168.1.100', expected: true },
|
||||||
|
{ ip: '192.168.1.100', range: '192.168.1.1-192.168.1.100', expected: true },
|
||||||
|
{ ip: '192.168.1.101', range: '192.168.1.1-192.168.1.100', expected: false },
|
||||||
|
{ ip: '192.168.2.50', range: '192.168.1.1-192.168.1.100', expected: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { ip, range, expected } of rangeTests) {
|
||||||
|
const result = IpUtils.isGlobIPMatch(ip, [range]);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
console.log(`✅ IP ${ip} in range ${range} = ${result} (expected ${expected})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('IP Matching - Mixed patterns', async () => {
|
||||||
|
|
||||||
|
// Test with mixed pattern types
|
||||||
|
const allowList = [
|
||||||
|
'10.0.0.0/8', // CIDR
|
||||||
|
'192.168.*', // Shorthand glob
|
||||||
|
'172.16.1.*', // Specific subnet glob
|
||||||
|
'8.8.8.8', // Single IP
|
||||||
|
'1.1.1.1-1.1.1.10' // Range
|
||||||
|
];
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
{ ip: '10.1.2.3', expected: true }, // Matches CIDR
|
||||||
|
{ ip: '192.168.100.1', expected: true }, // Matches shorthand glob
|
||||||
|
{ ip: '172.16.1.5', expected: true }, // Matches specific glob
|
||||||
|
{ ip: '8.8.8.8', expected: true }, // Matches single IP
|
||||||
|
{ ip: '1.1.1.5', expected: true }, // Matches range
|
||||||
|
{ ip: '9.9.9.9', expected: false }, // Doesn't match any
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { ip, expected } of tests) {
|
||||||
|
const result = IpUtils.isGlobIPMatch(ip, allowList);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
console.log(`✅ IP ${ip} in mixed patterns = ${result} (expected ${expected})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '21.1.5',
|
version: '21.1.6',
|
||||||
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
|
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,47 @@ export class IpUtils {
|
|||||||
const normalizedIPVariants = this.normalizeIP(ip);
|
const normalizedIPVariants = this.normalizeIP(ip);
|
||||||
if (normalizedIPVariants.length === 0) return false;
|
if (normalizedIPVariants.length === 0) return false;
|
||||||
|
|
||||||
// Normalize the pattern IPs for consistent comparison
|
// Check each pattern
|
||||||
const expandedPatterns = patterns.flatMap(pattern => this.normalizeIP(pattern));
|
for (const pattern of patterns) {
|
||||||
|
// Handle CIDR notation
|
||||||
|
if (pattern.includes('/')) {
|
||||||
|
if (this.matchCIDR(ip, pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for any match between normalized IP variants and patterns
|
// Handle range notation
|
||||||
return normalizedIPVariants.some((ipVariant) =>
|
if (pattern.includes('-') && !pattern.includes('*')) {
|
||||||
expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern))
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,6 +158,100 @@ export class IpUtils {
|
|||||||
return !this.isPrivateIP(ip);
|
return !this.isPrivateIP(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a subnet CIDR to an IP range for filtering
|
* Convert a subnet CIDR to an IP range for filtering
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -127,8 +127,20 @@ export class SecurityManager {
|
|||||||
const normalizedIPVariants = normalizeIP(ip);
|
const normalizedIPVariants = normalizeIP(ip);
|
||||||
if (normalizedIPVariants.length === 0) return false;
|
if (normalizedIPVariants.length === 0) return false;
|
||||||
|
|
||||||
// Normalize the pattern IPs for consistent comparison
|
// Expand shorthand patterns and normalize IPs for consistent comparison
|
||||||
const expandedPatterns = patterns.flatMap(normalizeIP);
|
const expandShorthand = (pattern: string): string => {
|
||||||
|
// Expand shorthand IP patterns like '192.168.*' to '192.168.*.*'
|
||||||
|
if (pattern.includes('*') && !pattern.includes(':')) {
|
||||||
|
const parts = pattern.split('.');
|
||||||
|
while (parts.length < 4) {
|
||||||
|
parts.push('*');
|
||||||
|
}
|
||||||
|
return parts.join('.');
|
||||||
|
}
|
||||||
|
return pattern;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandedPatterns = patterns.map(expandShorthand).flatMap(normalizeIP);
|
||||||
|
|
||||||
// Check for any match between normalized IP variants and patterns
|
// Check for any match between normalized IP variants and patterns
|
||||||
return normalizedIPVariants.some((ipVariant) =>
|
return normalizedIPVariants.some((ipVariant) =>
|
||||||
|
|||||||
@@ -393,7 +393,8 @@ export class RouteValidator {
|
|||||||
// Check for wildcards in IPv4
|
// Check for wildcards in IPv4
|
||||||
if (ip.includes('*') && !ip.includes(':')) {
|
if (ip.includes('*') && !ip.includes(':')) {
|
||||||
const parts = ip.split('.');
|
const parts = ip.split('.');
|
||||||
if (parts.length !== 4) return false;
|
// Allow 1-4 parts for wildcard patterns (e.g., '10.*', '192.168.*', '192.168.1.*')
|
||||||
|
if (parts.length < 1 || parts.length > 4) return false;
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part !== '*' && !/^\d{1,3}$/.test(part)) return false;
|
if (part !== '*' && !/^\d{1,3}$/.test(part)) return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user