diff --git a/changelog.md b/changelog.md index 54b8017..e86ef1c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # 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) Prepare patch release: documentation, tests and stability fixes (metrics, ACME, connection cleanup) diff --git a/test/test.ip-validation.ts b/test/test.ip-validation.ts new file mode 100644 index 0000000..60dea59 --- /dev/null +++ b/test/test.ip-validation.ts @@ -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(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index ca02ec4..5386679 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { 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.' } diff --git a/ts/core/utils/ip-utils.ts b/ts/core/utils/ip-utils.ts index 49a484b..e441545 100644 --- a/ts/core/utils/ip-utils.ts +++ b/ts/core/utils/ip-utils.ts @@ -21,13 +21,47 @@ export class IpUtils { const normalizedIPVariants = this.normalizeIP(ip); if (normalizedIPVariants.length === 0) return false; - // Normalize the pattern IPs for consistent comparison - const expandedPatterns = patterns.flatMap(pattern => this.normalizeIP(pattern)); + // Check each 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 - return normalizedIPVariants.some((ipVariant) => - expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern)) - ); + // 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; } /** @@ -124,6 +158,100 @@ export class IpUtils { 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 * diff --git a/ts/proxies/smart-proxy/security-manager.ts b/ts/proxies/smart-proxy/security-manager.ts index f908778..a405cce 100644 --- a/ts/proxies/smart-proxy/security-manager.ts +++ b/ts/proxies/smart-proxy/security-manager.ts @@ -127,8 +127,20 @@ export class SecurityManager { const normalizedIPVariants = normalizeIP(ip); if (normalizedIPVariants.length === 0) return false; - // Normalize the pattern IPs for consistent comparison - const expandedPatterns = patterns.flatMap(normalizeIP); + // Expand shorthand patterns and normalize IPs for consistent comparison + 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 return normalizedIPVariants.some((ipVariant) => diff --git a/ts/proxies/smart-proxy/utils/route-validator.ts b/ts/proxies/smart-proxy/utils/route-validator.ts index 1964868..55fd247 100644 --- a/ts/proxies/smart-proxy/utils/route-validator.ts +++ b/ts/proxies/smart-proxy/utils/route-validator.ts @@ -393,7 +393,8 @@ export class RouteValidator { // Check for wildcards in IPv4 if (ip.includes('*') && !ip.includes(':')) { 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) { if (part !== '*' && !/^\d{1,3}$/.test(part)) return false;