From 99706044e835097d2ad6500efcbde4d9d4c6195a Mon Sep 17 00:00:00 2001 From: Jai Kandepu <73307963+C0oki3s@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:11:20 +0530 Subject: [PATCH 1/2] Refactor ssrf module for enhanced security and configuration Refactor ssrf module to include configuration options for blacklist, allowlist, and path behavior. Enhance URL validation and DNS checks to improve security against SSRF attacks. --- lib/index.js | 452 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 303 insertions(+), 149 deletions(-) diff --git a/lib/index.js b/lib/index.js index aa8f9d6..3a55073 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,195 +1,349 @@ const dns = require('dns') -const Parser = require('url') const fs = require('fs') const ipAddress = require('ipaddr.js') -const ssrf = {} -let Forntend_Input -let Destruct_URL -let Sanitize -const user_blacklist = [] -let user_path = true +// Module configuration (not per-call state) +const config = { + blacklist: [], // array of lowercase hostnames or IPs as strings (normalized, brackets stripped) + allowlist: [], // optional allowlist; when non-empty, only these hosts/IPs are allowed + path: true, // when false, only return origin (protocol + host[:port]) + blockRanges: null, // optional Set of ipaddr.js range() strings to block; when null, blocks all non-unicast + cidrAllow: [], // parsed CIDR networks to allow (each item: [addr, prefix]) + cidrDeny: [], // parsed CIDR networks to deny + portAllowlist: null // optional Set of allowed ports; null => allow all +} +const ssrf = {} + +// Public API: Configure module ssrf.options = ({ - blacklist, /** Take's input Absolute path like(/usr/blacklist/urls.txt) */ - path -}) => { - if (blacklist != undefined) { - try { - const data = fs.readFileSync(blacklist).toString().replace(/\r\n/g, '\n').split('\n') - /** - * @data consist of user supplied host name's - * Ex: google.com\n mail.google.com\n - */ - - for (i in data) { - user_blacklist.push(data[i]) // pushing to a global array object {user_blacklist} + blacklist, /** Accepts absolute path to file OR an array of hosts/IPs */ + whitelist, allowlist, /** Accepts absolute path or array; allowlist takes precedence over whitelist */ + path, + blockRanges, /** Array of ipaddr.js range names to block (e.g., ['private','loopback']) */ + cidrAllow, /** Array of CIDR strings to allow; if provided, all resolved IPs must fall within at least one */ + cidrDeny, /** Array of CIDR strings to deny; if any resolved IP falls within one, it's blocked */ + portAllowlist /** Array of allowed port numbers; if provided, URL port (or default) must be in this set */ +} = {}) => { + // Load/assign blacklist + if (blacklist !== undefined) { + if (Array.isArray(blacklist)) { + config.blacklist = normalizeList(blacklist) + } else if (typeof blacklist === 'string') { + try { + const content = fs.readFileSync(blacklist, 'utf8') + config.blacklist = normalizeList(content.replace(/\r\n/g, '\n').split('\n')) + } catch (error) { + throw new Error('File does not Exists') } - } catch (error) { - throw new Error('File does not Exists') + } else { + throw new Error('Invalid blacklist: expected file path or array') + } + } + // Load/assign allowlist (aka whitelist) + const wl = allowlist !== undefined ? allowlist : whitelist + if (wl !== undefined) { + if (Array.isArray(wl)) { + config.allowlist = normalizeList(wl) + } else if (typeof wl === 'string') { + try { + const content = fs.readFileSync(wl, 'utf8') + config.allowlist = normalizeList(content.replace(/\r\n/g, '\n').split('\n')) + } catch (error) { + throw new Error('File does not Exists') + } + } else { + throw new Error('Invalid allowlist: expected file path or array') } } - if (path != undefined) { - user_path = path + // Configure path behavior + if (typeof path === 'boolean') { + config.path = path + } + // Configure block ranges + if (blockRanges !== undefined) { + if (Array.isArray(blockRanges)) { + config.blockRanges = new Set(blockRanges.map(String)) + } else { + throw new Error('Invalid blockRanges: expected array of range names') + } + } + // Configure CIDR allow/deny + if (cidrAllow !== undefined) { + if (Array.isArray(cidrAllow)) config.cidrAllow = parseCidrs(cidrAllow) + else throw new Error('Invalid cidrAllow: expected array of CIDR strings') + } + if (cidrDeny !== undefined) { + if (Array.isArray(cidrDeny)) config.cidrDeny = parseCidrs(cidrDeny) + else throw new Error('Invalid cidrDeny: expected array of CIDR strings') + } + // Configure port allowlist + if (portAllowlist !== undefined) { + if (Array.isArray(portAllowlist)) { + const nums = portAllowlist.map((p) => Number(p)).filter((n) => Number.isInteger(n) && n >= 0 && n <= 65535) + config.portAllowlist = new Set(nums) + } else { + throw new Error('Invalid portAllowlist: expected array of numbers') + } } } -ssrf.url = async (url) => { - Forntend_Input = url +// Public API: Validate and return a safe URL string +ssrf.url = async (input) => { + const issues = [] - /** - * Forntend_input will be configured by user which is input's name parameter - * need to supply -> ssrf.url(req.body.url) - */ - await SanitizeURl(Forntend_Input) - - /** - * Calling SanitizeURL - * where SanitizeURL will Set a global variable of Destructed url - */ + // Parse with WHATWG URL + let urlObj + try { + urlObj = new URL(input) + } catch (e) { + issues.push({ ssrf: 'Invalid URL' }) + throw new Error(JSON.stringify(issues)) + } - const blacklist_ssrf = await CheckBlacklist() - /** - * blacklist_ssrf will be error handler in main function if blacklist_ssrf - * have length grater than 0 it will set a to req global object - */ + // Schema check: only http/https + if (!isAllowedProtocol(urlObj)) { + issues.push({ ssrf: 'Schema Error' }) + throw new Error(JSON.stringify(issues)) + } - if (blacklist_ssrf) { - throw new Error(JSON.stringify(blacklist_ssrf)) + // Normalize host (lowercase, strip IPv6 brackets) for DNS and comparisons + const rawHost = (urlObj.hostname || '').toLowerCase() + const hostname = stripIPv6Brackets(rawHost) + const normalizedIp = normalizeObfuscatedIPv4(hostname) + const hostForChecks = normalizedIp || hostname - } else { - if (user_path == false) { - return `${Destruct_URL.protocol}//${Destruct_URL.hostname}` - } else { - return Destruct_URL.href + // Port allowlist check (if configured) + if (config.portAllowlist instanceof Set) { + const port = getPort(urlObj) + if (!config.portAllowlist.has(port)) { + issues.push({ ssrf: 'Port Policy Error' }) + throw new Error(JSON.stringify(issues)) } - /** - * Setting Sanitize input to req.object - * By default req.Sanitize return's Absolute URl including path and parameters - * To only return hostname user need to set path:false in ssrf.options({}) - */ } - // } -} -const SanitizeURl = (url) => { - const re = /[^@]/ - if (re.test(url)) { - Sanitize = url?.split('@')[0].toString() - } - /** - * Sanitize variable Contains url after sanitization - * where this module only takes first part of the hostname - * if user send's https://google.com@attacker.com - * this module parse first hostname as google.com - */ - Destruct_URL = Parser.parse(Sanitize, true) - // console.log(Destruct_URL.hostname) - /* - * Destruct_URL Contains parts of url Example url:https://google.com?a=1 - * Destruct_URL.hostname = google - * (url) module https://nodejs.org/api/url.html - */ -} + // Blacklist check (exact match) + if (config.blacklist.length) { + for (const blocked of config.blacklist) { + if (hostForChecks === stripIPv6Brackets(blocked)) { + issues.push({ ssrf: 'Blacklist Error' }) + break + } + // If blacklist entry is an IP literal and hostname resolves to that IP, the DNS check will catch it + } + if (issues.length) throw new Error(JSON.stringify(issues)) + } -const CheckIp = (ip) => { - if (!ipAddress.isValid(ip)) { - return true + // Allowlist check (if provided): must match to proceed + if (config.allowlist.length) { + let allowed = false + for (const allowedHost of config.allowlist) { + if (hostForChecks === stripIPv6Brackets(allowedHost)) { + allowed = true + break + } + } + if (!allowed) { + issues.push({ ssrf: 'Allowlist Error' }) + throw new Error(JSON.stringify(issues)) + } } + + // DNS/IP checks to prevent private/rfc1918/loopback etc. (basic DNS rebinding mitigation) try { - const address = ipAddress.parse(ip) - const range = address.range() - if (range !== 'unicast') { - return false - /** - * Block's every private network IP For more ref to - * https://www.npmjs.com/package/ipaddr.js/v/1.1.0 - */ + const ips = await resolveAll(hostForChecks) + + // If no records found, treat as error + if (!ips.length) { + issues.push({ ssrf: 'DNS Resolution Failed' }) + } + + for (const ip of ips) { + // CIDR deny takes precedence if provided + if (config.cidrDeny.length && isInAnyCidr(ip, config.cidrDeny)) { + issues.push({ ssrf: 'CIDR Deny Error' }) + break + } + if (!isPublicUnicast(ip)) { + issues.push({ ssrf: 'Private IP Lookup' }) + break + } + } + + // CIDR allow: all IPs must be in allowed CIDR(s) + if (!issues.length && config.cidrAllow.length) { + for (const ip of ips) { + if (!isInAnyCidr(ip, config.cidrAllow)) { + issues.push({ ssrf: 'CIDR Allow Error' }) + break + } + } } } catch (err) { - return false + issues.push({ ssrf: 'Catch Block' }) + } + + if (issues.length) { + throw new Error(JSON.stringify(issues)) + } + + // Return sanitized URL + if (config.path === false) { + // Include port when present + return urlObj.origin } - return true + return urlObj.href } -const CheckSchema = () => { - /** - * This tool Only allow http and https Schema - */ +// Helpers +function normalizeList (list) { + return list + .map((s) => stripIPv6Brackets((s || '').toString().trim().toLowerCase())) + .filter((s) => s.length > 0) +} + +function isAllowedProtocol (urlObj) { + return urlObj.protocol === 'http:' || urlObj.protocol === 'https:' +} + +function getPort (urlObj) { + if (urlObj.port) return Number(urlObj.port) + return urlObj.protocol === 'https:' ? 443 : 80 +} - if (Destruct_URL.protocol == 'http:') { - return true - } else if (Destruct_URL.protocol == 'https:') { - return true - } else { +function isPublicUnicast (ip) { + // Validate + if (!ipAddress.isValid(ip)) return false + try { + const addr = ipAddress.parse(ip) + const range = addr.range() // 'unicast', 'private', 'loopback', 'linkLocal', 'multicast', 'reserved', 'uniqueLocal', etc. + if (config.blockRanges instanceof Set) { + return !config.blockRanges.has(range) + } + return range === 'unicast' + } catch (e) { return false } } -const CheckBlacklist = async () => { - const Catch = [] - /** - * value contains Boolean value which return from CheckSchema function - * if it is http ot https its return true else false then throw Append Error - */ - const value = CheckSchema() +async function resolveAll (hostname) { + // If the hostname is already an IP literal, just return it + if (ipAddress.isValid(hostname)) return [hostname] - if (!value) { - Catch.push({ ssrf: 'Schema Error' }) - } - /** - * After every condition we will Check if there is an Error dangling - * in Catch Array if any Object Alredy Present in Catch - * we will return and Dont go Forward - */ - if (Catch.length) { - return Catch + const results = [] + const p = dns.promises + // Resolve A and AAAA; ignore errors per family and continue + try { + const v4 = await p.resolve4(hostname) + results.push(...v4) + } catch {} + try { + const v6 = await p.resolve6(hostname) + results.push(...v6) + } catch {} + + // Fallback: dns.lookup if resolvers are blocked + if (!results.length) { + const address = await new Promise((resolve, reject) => { + dns.lookup(hostname, { all: false, family: 0, hints: dns.ADDRCONFIG | dns.V4MAPPED }, (err, address) => { + if (err) return reject(err) + resolve(address) + }) + }) + if (typeof address === 'string') results.push(address) + else if (address && address.address) results.push(address.address) } - user_blacklist?.forEach((host) => { - /** - * if any blacklist passed by user We will check here - * where host contains user supplied hostnames and if - * any of them Matchs with End user input will return - * Error - */ - if (host == Destruct_URL.hostname) { - Catch.push({ ssrf: 'Blacklist Error' }) - } - }) + return results +} - if (Catch.length) { - return Catch +function stripIPv6Brackets (host) { + if (host.startsWith('[') && host.endsWith(']')) { + return host.slice(1, -1) } + return host +} - try { - const lookup_return = await lookup() // contains ip address - /** - * CheckIp prevent DNS rebinding attack - */ - const result = CheckIp(lookup_return) - if (!result) { - Catch.push({ ssrf: 'Private IP Lookup' }) +// Attempt to normalize IPv4 provided in hex/octal/short/dword notations +function normalizeObfuscatedIPv4 (host) { + // Pure integer (dword) + if (/^\d+$/.test(host)) { + const n = Number(host) + if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) { + return fromDword(n) + } + } + // Pure hex dword e.g. 0x7f000001 + if (/^0x[0-9a-f]+$/i.test(host)) { + const n = Number(host) + if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) { + return fromDword(n) } - } catch (error) { - Catch.push({ ssrf: 'Catch Block' }) } - if (Catch.length) { - return Catch + // Dotted parts possibly hex/octal/dec, with legacy shortening + if (host.includes('.')) { + const parts = host.split('.') + if (parts.every(p => p.length > 0)) { + const nums = parts.map(parseFlexible) + if (nums.every(x => x !== null && x >= 0)) { + let dword = null + if (nums.length === 4) { + if (nums.every(x => x <= 255)) dword = (nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3] + } else if (nums.length === 3) { + if (nums[0] <= 255 && nums[1] <= 255 && nums[2] <= 0xFFFF) dword = (nums[0] << 24) | (nums[1] << 16) | nums[2] + } else if (nums.length === 2) { + if (nums[0] <= 255 && nums[1] <= 0xFFFFFF) dword = (nums[0] << 24) | nums[1] + } else if (nums.length === 1) { + if (nums[0] <= 0xFFFFFFFF) dword = nums[0] + } + if (dword !== null) return fromDword(dword >>> 0) + } + } } + return null } -async function lookup () { - const options = { - family: 4, - hints: dns.ADDRCONFIG | dns.V4MAPPED +function parseFlexible (s) { + if (/^0x[0-9a-f]+$/i.test(s)) return parseInt(s, 16) + if (/^0[0-7]+$/.test(s)) return parseInt(s, 8) + if (/^\d+$/.test(s)) return parseInt(s, 10) + return null +} + +function fromDword (n) { + const a = (n >>> 24) & 0xFF + const b = (n >>> 16) & 0xFF + const c = (n >>> 8) & 0xFF + const d = n & 0xFF + return `${a}.${b}.${c}.${d}` +} + +function parseCidrs (list) { + const out = [] + for (const item of list) { + const s = String(item).trim() + if (!s) continue + try { + const parsed = ipAddress.parseCIDR(s) + out.push(parsed) + } catch (e) { + throw new Error(`Invalid CIDR: ${s}`) + } } - return new Promise((resolve, reject) => { - dns.lookup(Destruct_URL.hostname, options, (err, address, family) => { - if (err) reject(err) - resolve(address) - }) - }) -}; + return out +} + +function isInAnyCidr (ip, cidrs) { + try { + const addr = ipAddress.parse(ip) + for (const [net, prefix] of cidrs) { + if (addr.kind() !== net.kind()) continue + if (addr.match([net, prefix])) return true + } + } catch { + return false + } + return false +} module.exports = ssrf From 754d1fb52cb243032daab67428769daba7f6e25d Mon Sep 17 00:00:00 2001 From: Jai Kandepu <73307963+C0oki3s@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:17:59 +0530 Subject: [PATCH 2/2] Refactor dns.lookup and address handling logic Updated dns.lookup to return all addresses and adjusted bitwise operations for consistency. --- lib/index.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/index.js b/lib/index.js index 3a55073..dcbb8c5 100644 --- a/lib/index.js +++ b/lib/index.js @@ -242,16 +242,19 @@ async function resolveAll (hostname) { results.push(...v6) } catch {} - // Fallback: dns.lookup if resolvers are blocked + // Fallback: dns.lookup (gives system resolver results) if (!results.length) { - const address = await new Promise((resolve, reject) => { - dns.lookup(hostname, { all: false, family: 0, hints: dns.ADDRCONFIG | dns.V4MAPPED }, (err, address) => { + const addresses = await new Promise((resolve, reject) => { + dns.lookup(hostname, { all: true, family: 0, hints: dns.ADDRCONFIG | dns.V4MAPPED }, (err, addresses) => { if (err) return reject(err) - resolve(address) + resolve(addresses) }) }) - if (typeof address === 'string') results.push(address) - else if (address && address.address) results.push(address.address) + if (Array.isArray(addresses)) { + for (const addr of addresses) { + if (addr && addr.address) results.push(addr.address) + } + } } return results @@ -288,11 +291,11 @@ function normalizeObfuscatedIPv4 (host) { if (nums.every(x => x !== null && x >= 0)) { let dword = null if (nums.length === 4) { - if (nums.every(x => x <= 255)) dword = (nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3] + if (nums.every(x => x <= 255)) dword = ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0 } else if (nums.length === 3) { - if (nums[0] <= 255 && nums[1] <= 255 && nums[2] <= 0xFFFF) dword = (nums[0] << 24) | (nums[1] << 16) | nums[2] + if (nums[0] <= 255 && nums[1] <= 255 && nums[2] <= 0xFFFF) dword = ((nums[0] << 24) | (nums[1] << 16) | nums[2]) >>> 0 } else if (nums.length === 2) { - if (nums[0] <= 255 && nums[1] <= 0xFFFFFF) dword = (nums[0] << 24) | nums[1] + if (nums[0] <= 255 && nums[1] <= 0xFFFFFF) dword = ((nums[0] << 24) | nums[1]) >>> 0 } else if (nums.length === 1) { if (nums[0] <= 0xFFFFFFFF) dword = nums[0] }