mcp-sanitizer 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -188,7 +188,8 @@ const sanitizer = new MCPSanitizer({
188
188
  contextSettings: {
189
189
  url: {
190
190
  allowPrivateIPs: false,
191
- allowLocalhostWithoutPort: false
191
+ blockedDomains: ['tracker.example'], // Domain and its subdomains
192
+ allowedDomains: [] // Non-empty = only these domains
192
193
  },
193
194
  filePath: {
194
195
  allowAbsolutePaths: false,
@@ -368,6 +369,24 @@ if (urlResult.blocked) {
368
369
  }
369
370
  ```
370
371
 
372
+ `sanitize()` checks the URL string only: it blocks private/loopback/link-local IP
373
+ literals and internal-only names (`*.internal`, `*.local`, ...), but cannot know
374
+ that a public name such as `10.0.0.5.nip.io` resolves to a private address. Before
375
+ fetching, resolve and pin with `resolveAndValidateURL`:
376
+
377
+ ```javascript
378
+ const http = require('http');
379
+ const { resolveAndValidateURL } = require('mcp-sanitizer');
380
+
381
+ // Rejects if the hostname resolves to any restricted address
382
+ const { url, address, family } = await resolveAndValidateURL(params.url);
383
+
384
+ // Connect to the checked address so a DNS change cannot redirect the request
385
+ const lookup = (host, opts, cb) =>
386
+ opts.all ? cb(null, [{ address, family }]) : cb(null, address, family);
387
+ http.get(url, { lookup });
388
+ ```
389
+
371
390
  ## Contributing
372
391
 
373
392
  1. Fork the repository
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-sanitizer",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Comprehensive security sanitization library for Model Context Protocol (MCP) servers with trusted security libraries",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -39,7 +39,7 @@
39
39
  "escape-html": "^1.0.3",
40
40
  "path-is-inside": "^1.0.2",
41
41
  "sanitize-filename": "^1.6.3",
42
- "shell-quote": "^1.8.4",
42
+ "shell-quote": "^1.12.0",
43
43
  "sqlstring": "^2.3.3",
44
44
  "unorm": "^1.6.0",
45
45
  "validator": "^13.15.22"
@@ -66,6 +66,12 @@
66
66
  "overrides": {
67
67
  "qs": ">=6.15.2",
68
68
  "flatted": ">=3.4.2",
69
- "form-data": ">=4.0.6"
69
+ "form-data": ">=4.0.6",
70
+ "js-yaml": "4.3.2",
71
+ "@istanbuljs/load-nyc-config": {
72
+ "js-yaml": "3.15.2"
73
+ },
74
+ "brace-expansion": "1.1.21",
75
+ "@babel/core": "^7.29.7"
70
76
  }
71
77
  }
package/src/index.js CHANGED
@@ -116,6 +116,7 @@ module.exports.validateAgainstBlockedPatterns = utils.validateAgainstBlockedPatt
116
116
  module.exports.isDangerousKey = utils.isDangerousKey;
117
117
  module.exports.validateObjectKey = utils.validateObjectKey;
118
118
  module.exports.validateNonEmptyString = utils.validateNonEmptyString;
119
+ module.exports.resolveAndValidateURL = utils.validationUtils.resolveAndValidateURL;
119
120
 
120
121
  // Export new security enhancement functions
121
122
  module.exports.detectDirectionalOverrides = utils.detectDirectionalOverrides;
@@ -696,7 +696,7 @@ class MCPSanitizer {
696
696
  _secureSanitizeURL (url) {
697
697
  // url is already decoded by _sanitizeString
698
698
  const parsedUrl = validationUtils.validateURL(url, this.options.allowedProtocols);
699
- validationUtils.validateURLLocation(parsedUrl);
699
+ validationUtils.validateURLLocation(parsedUrl, this.options.contextSettings?.url);
700
700
  return parsedUrl.toString();
701
701
  }
702
702
 
@@ -806,7 +806,7 @@ class MCPSanitizer {
806
806
  _legacySanitizeURL (url) {
807
807
  // Legacy URL sanitization - security decoder is now integrated
808
808
  const parsedUrl = validationUtils.validateURL(url, this.options.allowedProtocols);
809
- validationUtils.validateURLLocation(parsedUrl);
809
+ validationUtils.validateURLLocation(parsedUrl, this.options.contextSettings?.url);
810
810
  return parsedUrl.toString();
811
811
  }
812
812
 
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  const path = require('path');
29
- // const { validationUtils } = require('../../utils') // Unused - commented to fix ESLint
29
+ const { assertNotSensitivePath } = require('../../utils/validation-utils');
30
30
  const { detectAllPatterns, SEVERITY_LEVELS } = require('../../patterns');
31
31
  const sanitizeFilename = require('sanitize-filename');
32
32
  const pathIsInside = require('path-is-inside');
@@ -220,6 +220,15 @@ class FilePathValidator {
220
220
  return result;
221
221
  }
222
222
 
223
+ // GHSA-v25x-53wq-6r99 FIX: shared sensitive-path denylist.
224
+ try {
225
+ assertNotSensitivePath(normalizedPath);
226
+ } catch (error) {
227
+ result.warnings.push(error.message);
228
+ result.severity = this._getHigherSeverity(result.severity, SEVERITY.CRITICAL);
229
+ return result;
230
+ }
231
+
223
232
  // Check file extension
224
233
  const extension = path.extname(normalizedPath).toLowerCase();
225
234
  result.metadata.extension = extension;
@@ -27,7 +27,8 @@
27
27
  */
28
28
 
29
29
  const { URL } = require('url');
30
- // const { validationUtils } = require('../../utils') // Unused - commented to fix ESLint
30
+ const net = require('net');
31
+ const { classifyHost, assertDomainAllowed, resolveHost } = require('../../utils/validation-utils');
31
32
  const { detectAllPatterns, SEVERITY_LEVELS } = require('../../patterns');
32
33
  const validator = require('validator');
33
34
  const {
@@ -249,13 +250,13 @@ class URLValidator {
249
250
  if (!hostnameResult.isValid) {
250
251
  result.warnings.push(...hostnameResult.warnings);
251
252
  result.severity = this._getHigherSeverity(result.severity, hostnameResult.severity);
252
-
253
- if (hostnameResult.severity === SEVERITY.CRITICAL) {
254
- return result;
255
- }
253
+ return result;
256
254
  }
257
255
  result.metadata.hostname = parsedUrl.hostname;
258
256
  result.metadata.isPrivateIP = hostnameResult.isPrivateIP;
257
+ if (hostnameResult.resolvedAddresses) {
258
+ result.metadata.resolvedAddresses = hostnameResult.resolvedAddresses;
259
+ }
259
260
 
260
261
  // Validate port
261
262
  const portResult = this._validatePort(parsedUrl.port, parsedUrl.protocol);
@@ -473,90 +474,65 @@ class URLValidator {
473
474
  return result;
474
475
  }
475
476
 
476
- // Check dangerous hostname patterns
477
- for (const pattern of DANGEROUS_HOSTNAMES) {
478
- if (pattern.test(hostname)) {
479
- const allowanceCheck = this._checkHostnameAllowance(hostname);
480
- if (!allowanceCheck.allowed) {
481
- result.isValid = false;
482
- result.warnings.push(`Hostname '${hostname}' is ${allowanceCheck.reason}`);
483
- result.severity = SEVERITY.CRITICAL;
484
- return result;
485
- } else {
486
- result.warnings.push(`Warning: Accessing ${allowanceCheck.reason} hostname '${hostname}'`);
487
- result.severity = SEVERITY.MEDIUM;
488
- }
489
- }
477
+ // GHSA-v25x-53wq-6r99 FIX: use the shared classifier, not local regexes.
478
+ const locationResult = this._validateLocation(hostname);
479
+ result.isPrivateIP = locationResult.isPrivate;
480
+ if (!locationResult.isValid) {
481
+ result.isValid = false;
482
+ result.warnings.push(...locationResult.warnings);
483
+ result.severity = locationResult.severity;
484
+ return result;
485
+ }
486
+ if (locationResult.warnings.length > 0) {
487
+ result.warnings.push(...locationResult.warnings);
488
+ result.severity = this._getHigherSeverity(result.severity, locationResult.severity);
490
489
  }
491
490
 
492
- // Check if hostname is an IP address
493
- if (this._isIPAddress(hostname)) {
494
- const ipResult = this._validateIPAddress(hostname);
495
- result.isPrivateIP = ipResult.isPrivate;
496
-
497
- if (!ipResult.isValid) {
498
- result.isValid = false;
499
- result.warnings.push(...ipResult.warnings);
500
- result.severity = ipResult.severity;
501
- }
491
+ const host = classifyHost(hostname).hostname;
492
+ try {
493
+ assertDomainAllowed(host, this.config);
494
+ } catch (error) {
495
+ result.isValid = false;
496
+ result.warnings.push(error.message);
497
+ result.severity = SEVERITY.CRITICAL;
498
+ return result;
502
499
  }
503
500
 
504
- // DNS validation if enabled
505
- if (this.config.validateDNS && result.isValid) {
501
+ // GHSA-qvp2-8q5w-gcfp FIX: when enabled, classify every resolved address,
502
+ // not just whether the name resolves. Fails closed on lookup errors.
503
+ if (this.config.validateDNS && !net.isIP(host)) {
504
+ let resolved;
506
505
  try {
507
- const dns = require('dns').promises;
508
- await dns.lookup(hostname);
506
+ resolved = await resolveHost(host);
509
507
  } catch (error) {
510
- result.warnings.push(`DNS resolution failed for hostname '${hostname}'`);
511
- result.severity = this._getHigherSeverity(result.severity, SEVERITY.MEDIUM);
508
+ result.isValid = false;
509
+ result.warnings.push(`DNS resolution failed for hostname '${host}'`);
510
+ result.severity = SEVERITY.HIGH;
511
+ return result;
512
512
  }
513
+ for (const { address } of resolved) {
514
+ const addressResult = this._validateLocation(address);
515
+ if (!addressResult.isValid) {
516
+ result.isValid = false;
517
+ result.isPrivateIP = true;
518
+ result.warnings.push(`Hostname '${host}' resolves to a restricted address`, ...addressResult.warnings);
519
+ result.severity = SEVERITY.CRITICAL;
520
+ return result;
521
+ }
522
+ }
523
+ result.resolvedAddresses = resolved.map(({ address, family }) => ({ address, family }));
513
524
  }
514
525
 
515
526
  return result;
516
527
  }
517
528
 
518
529
  /**
519
- * Check if hostname access is allowed based on configuration
520
- * @param {string} hostname - Hostname to check
521
- * @returns {Object} Allowance check result
522
- * @private
523
- */
524
- _checkHostnameAllowance (hostname) {
525
- const lower = hostname.toLowerCase();
526
-
527
- if (lower === 'localhost' || lower === '127.0.0.1' || lower === '::1') {
528
- return {
529
- allowed: this.config.allowLocalhost || this.config.allowLoopback,
530
- reason: 'localhost/loopback'
531
- };
532
- }
533
-
534
- return { allowed: true, reason: null };
535
- }
536
-
537
- /**
538
- * Check if a string is an IP address
539
- * @param {string} hostname - Hostname to check
540
- * @returns {boolean} True if it's an IP address
541
- * @private
542
- */
543
- _isIPAddress (hostname) {
544
- // IPv4 pattern
545
- const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
546
-
547
- // IPv6 pattern (simplified)
548
- const ipv6Pattern = /^([0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}$/i;
549
-
550
- return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
551
- }
552
-
553
- /**
554
- * Validate IP address for private ranges and restrictions
555
- * @param {string} ip - IP address to validate
530
+ * Apply this validator's allow* flags to the shared host classification.
531
+ * @param {string} hostname - `URL.hostname` (brackets allowed)
556
532
  * @returns {Object} Validation result
557
533
  * @private
558
534
  */
559
- _validateIPAddress (ip) {
535
+ _validateLocation (hostname) {
560
536
  const result = {
561
537
  isValid: true,
562
538
  warnings: [],
@@ -564,40 +540,29 @@ class URLValidator {
564
540
  isPrivate: false
565
541
  };
566
542
 
567
- // Check IPv4 private ranges
568
- for (const pattern of PRIVATE_IP_RANGES.ipv4) {
569
- if (pattern.test(ip)) {
570
- result.isPrivate = true;
571
-
572
- if (!this.config.allowPrivateIPs) {
573
- result.isValid = false;
574
- result.warnings.push(`Access to private IP address '${ip}' is not allowed`);
575
- result.severity = SEVERITY.CRITICAL;
576
- return result;
577
- } else {
578
- result.warnings.push(`Warning: Accessing private IP address '${ip}'`);
579
- result.severity = SEVERITY.MEDIUM;
580
- }
581
- break;
582
- }
543
+ const { hostname: host, category } = classifyHost(hostname);
544
+ if (!category) {
545
+ return result;
583
546
  }
584
547
 
585
- // Check IPv6 private ranges
586
- for (const pattern of PRIVATE_IP_RANGES.ipv6) {
587
- if (pattern.test(ip)) {
588
- result.isPrivate = true;
548
+ const allowedBy = {
549
+ localhost: this.config.allowLocalhost || this.config.allowLoopback,
550
+ loopback: this.config.allowLocalhost || this.config.allowLoopback,
551
+ unspecified: this.config.allowLocalhost || this.config.allowLoopback,
552
+ private: this.config.allowPrivateIPs,
553
+ 'internal-name': this.config.allowPrivateIPs,
554
+ reserved: this.config.allowPrivateIPs,
555
+ 'link-local': this.config.allowLinkLocal
556
+ };
589
557
 
590
- if (!this.config.allowPrivateIPs) {
591
- result.isValid = false;
592
- result.warnings.push(`Access to private IPv6 address '${ip}' is not allowed`);
593
- result.severity = SEVERITY.CRITICAL;
594
- return result;
595
- } else {
596
- result.warnings.push(`Warning: Accessing private IPv6 address '${ip}'`);
597
- result.severity = SEVERITY.MEDIUM;
598
- }
599
- break;
600
- }
558
+ result.isPrivate = true;
559
+ if (!allowedBy[category]) {
560
+ result.isValid = false;
561
+ result.warnings.push(`Access to ${category} address '${host}' is not allowed`);
562
+ result.severity = SEVERITY.CRITICAL;
563
+ } else {
564
+ result.warnings.push(`Warning: Accessing ${category} address '${host}'`);
565
+ result.severity = SEVERITY.MEDIUM;
601
566
  }
602
567
 
603
568
  return result;
@@ -10,6 +10,7 @@
10
10
 
11
11
  const path = require('path');
12
12
  const net = require('net');
13
+ const dns = require('dns');
13
14
  const { URL } = require('url');
14
15
  // CVE-TBD-001 FIX: Import unified parser for consistent string normalization
15
16
  const { parseUnified } = require('./unified-parser');
@@ -150,19 +151,7 @@ function validateFilePath (filePath) {
150
151
  }
151
152
  }
152
153
 
153
- // GHSA-7w82-6f9j-v9jp FIX: Block sensitive user-configuration files/directories
154
- // regardless of where they sit (home dir, /tmp, cwd, relative paths). These hold
155
- // SSH keys, cloud credentials, and tokens that must never be exposed via a tool.
156
- const sensitiveFileFragments = [
157
- '/.ssh/', '/.aws/', '/.gnupg/', '/.kube/', '/.config/',
158
- '/.docker/', '/.npmrc', '/.netrc', '/.env', '/.git-credentials'
159
- ];
160
- const sensitiveCheckPath = lowerPath.replace(/\\/g, '/');
161
- for (const fragment of sensitiveFileFragments) {
162
- if (sensitiveCheckPath.includes(fragment)) {
163
- throw new Error('Access to sensitive configuration path not allowed');
164
- }
165
- }
154
+ assertNotSensitivePath(normalizedPath);
166
155
 
167
156
  // Use path-is-inside to check if the path tries to escape a safe directory
168
157
  // Define safe root directories
@@ -219,6 +208,31 @@ function validateFilePath (filePath) {
219
208
  return safePath;
220
209
  }
221
210
 
211
+ // Directory or file names that hold credentials; `.env.*` is matched separately.
212
+ const SENSITIVE_PATH_SEGMENTS = new Set([
213
+ '.ssh', '.aws', '.gnupg', '.kube', '.config', '.docker',
214
+ '.npmrc', '.netrc', '.env', '.git-credentials'
215
+ ]);
216
+
217
+ /**
218
+ * GHSA-7w82-6f9j-v9jp FIX: Block sensitive config files/dirs (SSH keys, cloud
219
+ * credentials, tokens) wherever they sit. Shared by every file-path entry point
220
+ * (GHSA-v25x-53wq-6r99 FIX: validators/file-path.js never ran this check).
221
+ *
222
+ * GHSA-6xj4-4chr-656w FIX: match whole path segments, not `/`-prefixed
223
+ * substrings, so a sensitive first segment (`.env`, `.ssh/id_rsa`) is caught.
224
+ * @param {string} normalizedPath - Path already normalized by the unified parser
225
+ * @throws {Error} - If the path targets a sensitive configuration location
226
+ */
227
+ function assertNotSensitivePath (normalizedPath) {
228
+ const segments = normalizedPath.toLowerCase().split(/[\\/]+/)
229
+ // Windows ignores trailing dots/spaces and `:stream` suffixes (`.env.`, `.env::$DATA`).
230
+ .map(s => s.replace(/:.*$/, '').replace(/[. ]+$/, ''));
231
+ if (segments.some(s => SENSITIVE_PATH_SEGMENTS.has(s) || /^\.env\./.test(s))) {
232
+ throw new Error('Access to sensitive configuration path not allowed');
233
+ }
234
+ }
235
+
222
236
  /**
223
237
  * Validate file extension against allowed list
224
238
  * @param {string} filePath - The file path to validate
@@ -314,103 +328,239 @@ function expandIPv6 (addr) {
314
328
  .join(':');
315
329
  }
316
330
 
331
+ // Name suffixes that only resolve on private networks (RFC 6762, 8375, 6761 §6.3
332
+ // and common unreserved internal TLDs). Includes GCP `metadata.google.internal`.
333
+ const INTERNAL_NAME_SUFFIXES = [
334
+ '.internal', '.local', '.localdomain', '.home.arpa', '.lan', '.corp'
335
+ ];
336
+ // Single-label cloud metadata aliases resolved via the DNS search path.
337
+ const INTERNAL_NAMES = new Set(['metadata', 'instance-data']);
338
+
317
339
  /**
318
- * Validate URL against restricted locations (localhost, loopback, private and
319
- * link-local addresses). Blocks unconditionally — there is no escape hatch — so
320
- * the sanitizer fails closed for SSRF targets.
321
- *
322
- * GHSA-4mfg-r38w-w8fg FIX: previously (1) localhost was only blocked when no
323
- * explicit port was present, and (2) bracketed IPv6 hostnames (`[::1]`,
324
- * `[fe80::1]`) never matched the string checks. Both gaps allowed SSRF to
325
- * internal services. The hostname is now bracket-stripped and classified by
326
- * actual address family/range, covering IPv4-mapped IPv6 and additional
327
- * loopback/private encodings.
328
- *
329
- * @param {string|URL} url - The URL to validate (string or URL object)
330
- * @throws {Error} - If URL points to restricted location
340
+ * Classify a URL hostname as a restricted location. Shared by every URL entry
341
+ * point (GHSA-v25x-53wq-6r99 FIX: validators/url.js had its own weaker copy).
342
+ * @param {string} rawHostname - `URL.hostname` (brackets allowed)
343
+ * @returns {{hostname: string, category: (string|null)}} - 'localhost',
344
+ * 'loopback', 'unspecified', 'private', 'link-local', 'reserved',
345
+ * 'internal-name', or null
331
346
  */
332
- function validateURLLocation (url) {
333
- let parsedUrl = url;
334
-
335
- if (typeof url === 'string') {
336
- parsedUrl = new URL(url);
337
- } else if (!(url instanceof URL)) {
338
- throw new Error('URL must be a string or URL object');
339
- }
340
-
347
+ function classifyHost (rawHostname) {
341
348
  // Normalize: strip IPv6 brackets and a trailing dot (FQDN root) before checks.
342
- const hostname = parsedUrl.hostname
349
+ const hostname = String(rawHostname)
343
350
  .toLowerCase()
344
351
  .replace(/^\[|\]$/g, '')
345
352
  .replace(/\.$/, '');
346
353
 
347
- // localhost and any *.localhost name — blocked regardless of port.
354
+ // localhost and any *.localhost name — regardless of port.
348
355
  if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
349
- throw new Error('URL points to localhost');
356
+ return { hostname, category: 'localhost' };
350
357
  }
351
358
 
352
359
  const ipVersion = net.isIP(hostname);
353
360
 
354
361
  // IPv4 literal — classify directly.
355
362
  if (ipVersion === 4) {
356
- assertPublicIPv4(hostname.split('.').map(Number), hostname);
363
+ return { hostname, category: classifyIPv4(hostname.split('.').map(Number)) };
357
364
  }
358
365
 
359
- // IPv6 — classify the address family, then any IPv4-mapped/compatible payload.
366
+ // IPv6 — classify the address family, then any embedded IPv4 payload.
360
367
  if (ipVersion === 6) {
361
368
  const canonical = expandIPv6(hostname);
362
- if (canonical) {
363
- // Loopback (::1) and unspecified (::).
364
- if (canonical === '0000:0000:0000:0000:0000:0000:0000:0001' ||
365
- canonical === '0000:0000:0000:0000:0000:0000:0000:0000') {
366
- throw new Error(`URL points to private IP range: ${hostname}`);
367
- }
368
- const firstGroup = canonical.slice(0, 4);
369
- // Link-local fe80::/10 (fe80–febf).
370
- if (firstGroup >= 'fe80' && firstGroup <= 'febf') {
371
- throw new Error(`URL points to link-local address: ${hostname}`);
372
- }
373
- // Unique-local fc00::/7 (fc00–fdff).
374
- if (firstGroup >= 'fc00' && firstGroup <= 'fdff') {
375
- throw new Error(`URL points to private IP range: ${hostname}`);
376
- }
377
- // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) addresses:
378
- // the first five groups are zero and the sixth is ffff or zero. Node
379
- // re-encodes the trailing IPv4 as hex, so recover it from the canonical
380
- // form and apply the same IPv4 range checks.
381
- const groups = canonical.split(':');
382
- if (groups.slice(0, 5).every(g => g === '0000') &&
383
- (groups[5] === 'ffff' || groups[5] === '0000')) {
384
- const hi = parseInt(groups[6], 16);
385
- const lo = parseInt(groups[7], 16);
386
- assertPublicIPv4([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff], hostname);
387
- }
369
+ if (!canonical) return { hostname, category: null };
370
+ if (canonical === '0000:0000:0000:0000:0000:0000:0000:0001') {
371
+ return { hostname, category: 'loopback' };
372
+ }
373
+ if (canonical === '0000:0000:0000:0000:0000:0000:0000:0000') {
374
+ return { hostname, category: 'unspecified' };
375
+ }
376
+ const groups = canonical.split(':');
377
+ // Link-local fe80::/10 (fe80–febf).
378
+ if (groups[0] >= 'fe80' && groups[0] <= 'febf') {
379
+ return { hostname, category: 'link-local' };
380
+ }
381
+ // Unique-local fc00::/7 and deprecated site-local fec0::/10.
382
+ if ((groups[0] >= 'fc00' && groups[0] <= 'fdff') ||
383
+ (groups[0] >= 'fec0' && groups[0] <= 'feff')) {
384
+ return { hostname, category: 'private' };
385
+ }
386
+ // Multicast ff00::/8.
387
+ if (groups[0].startsWith('ff')) {
388
+ return { hostname, category: 'reserved' };
388
389
  }
390
+ // IPv4-mapped (::ffff:a.b.c.d), IPv4-compatible (::a.b.c.d) and NAT64
391
+ // (64:ff9b::a.b.c.d) carry the IPv4 in the last 32 bits; 6to4
392
+ // (2002:aabb:ccdd::) carries it in groups 1–2. Node re-encodes the IPv4 as
393
+ // hex, so recover it from the canonical form and apply the IPv4 checks.
394
+ const zeros = (from, to) => groups.slice(from, to).every(g => g === '0000');
395
+ if ((zeros(0, 5) && (groups[5] === 'ffff' || groups[5] === '0000')) ||
396
+ (groups[0] === '0064' && groups[1] === 'ff9b' && zeros(2, 6))) {
397
+ return { hostname, category: classifyEmbeddedIPv4(groups[6], groups[7]) };
398
+ }
399
+ if (groups[0] === '2002') {
400
+ return { hostname, category: classifyEmbeddedIPv4(groups[1], groups[2]) };
401
+ }
402
+ return { hostname, category: null };
403
+ }
404
+
405
+ // GHSA-qvp2-8q5w-gcfp FIX: names that can only resolve internally.
406
+ if (INTERNAL_NAMES.has(hostname) ||
407
+ INTERNAL_NAME_SUFFIXES.some(suffix => hostname.endsWith(suffix))) {
408
+ return { hostname, category: 'internal-name' };
389
409
  }
410
+
411
+ return { hostname, category: null };
412
+ }
413
+
414
+ /**
415
+ * Classify an IPv4 address embedded in two IPv6 hex groups.
416
+ * @param {string} hiHex - Group holding octets a.b
417
+ * @param {string} loHex - Group holding octets c.d
418
+ * @returns {string|null} - Restricted category, or null if publicly routable
419
+ */
420
+ function classifyEmbeddedIPv4 (hiHex, loHex) {
421
+ const hi = parseInt(hiHex, 16);
422
+ const lo = parseInt(loHex, 16);
423
+ return classifyIPv4([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff]);
390
424
  }
391
425
 
392
426
  /**
393
- * Throw if the given IPv4 octets fall in a loopback, unspecified, RFC 1918
394
- * private, or link-local range. Shared by literal IPv4 and IPv4-mapped IPv6.
427
+ * Classify IPv4 octets; shared by literal IPv4 and embedded IPv4 in IPv6.
395
428
  * @param {number[]} o - Four octets [a, b, c, d]
396
- * @param {string} hostname - Original hostname, for the error message
397
- * @throws {Error} - If the address is not publicly routable
429
+ * @returns {string|null} - Restricted category, or null if publicly routable
398
430
  */
399
- function assertPublicIPv4 (o, hostname) {
431
+ function classifyIPv4 (o) {
400
432
  // Loopback 127.0.0.0/8 and unspecified 0.0.0.0/8.
401
- if (o[0] === 127 || o[0] === 0) {
402
- throw new Error(`URL points to private IP range: ${hostname}`);
403
- }
404
- // RFC 1918 private ranges.
433
+ if (o[0] === 127) return 'loopback';
434
+ if (o[0] === 0) return 'unspecified';
435
+ // RFC 1918 private ranges and CGNAT shared space 100.64.0.0/10.
405
436
  if (o[0] === 10 ||
406
437
  (o[0] === 192 && o[1] === 168) ||
407
- (o[0] === 172 && o[1] >= 16 && o[1] <= 31)) {
408
- throw new Error(`URL points to private IP range: ${hostname}`);
438
+ (o[0] === 172 && o[1] >= 16 && o[1] <= 31) ||
439
+ (o[0] === 100 && o[1] >= 64 && o[1] <= 127)) {
440
+ return 'private';
409
441
  }
410
442
  // Link-local 169.254.0.0/16 (includes cloud metadata 169.254.169.254).
411
- if (o[0] === 169 && o[1] === 254) {
443
+ if (o[0] === 169 && o[1] === 254) return 'link-local';
444
+ // IETF protocol assignments 192.0.0.0/24, benchmarking 198.18.0.0/15,
445
+ // multicast 224.0.0.0/4 and reserved/broadcast 240.0.0.0/4.
446
+ if ((o[0] === 192 && o[1] === 0 && o[2] === 0) ||
447
+ (o[0] === 198 && (o[1] === 18 || o[1] === 19)) ||
448
+ o[0] >= 224) {
449
+ return 'reserved';
450
+ }
451
+ return null;
452
+ }
453
+
454
+ /**
455
+ * Throw if a hostname is excluded by `blockedDomains` or, when `allowedDomains`
456
+ * is non-empty, not covered by it. An entry matches itself and its subdomains.
457
+ * The allowlist only narrows: it never admits a restricted `classifyHost` category.
458
+ * @param {string} hostname - Normalized hostname (from `classifyHost`)
459
+ * @param {Object} [options] - `{ blockedDomains, allowedDomains }`
460
+ * @throws {Error} - If the hostname is not permitted
461
+ */
462
+ function assertDomainAllowed (hostname, { blockedDomains = [], allowedDomains = [] } = {}) {
463
+ const matches = (entry) => {
464
+ const domain = String(entry).toLowerCase().replace(/^\*?\./, '').replace(/\.$/, '');
465
+ return domain !== '' && (hostname === domain || hostname.endsWith('.' + domain));
466
+ };
467
+ if (blockedDomains.some(matches)) {
468
+ throw new Error(`URL host is in blockedDomains: ${hostname}`);
469
+ }
470
+ if (allowedDomains.length > 0 && !allowedDomains.some(matches)) {
471
+ throw new Error(`URL host is not in allowedDomains: ${hostname}`);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Validate URL against restricted locations (localhost, loopback, private,
477
+ * link-local, reserved addresses and internal-only names). Blocks
478
+ * unconditionally — there is no escape hatch — so the sanitizer fails closed
479
+ * for SSRF targets. String-level only: a public name that resolves to a
480
+ * private address needs `resolveAndValidateURL`.
481
+ *
482
+ * GHSA-4mfg-r38w-w8fg FIX: previously (1) localhost was only blocked when no
483
+ * explicit port was present, and (2) bracketed IPv6 hostnames (`[::1]`,
484
+ * `[fe80::1]`) never matched the string checks. Both gaps allowed SSRF to
485
+ * internal services. The hostname is now bracket-stripped and classified by
486
+ * actual address family/range (see `classifyHost`), covering IPv4-mapped IPv6
487
+ * and additional loopback/private encodings.
488
+ *
489
+ * @param {string|URL} url - The URL to validate (string or URL object)
490
+ * @param {Object} [options] - `{ blockedDomains, allowedDomains }`
491
+ * @throws {Error} - If URL points to restricted location
492
+ */
493
+ function validateURLLocation (url, options = {}) {
494
+ let parsedUrl = url;
495
+
496
+ if (typeof url === 'string') {
497
+ parsedUrl = new URL(url);
498
+ } else if (!(url instanceof URL)) {
499
+ throw new Error('URL must be a string or URL object');
500
+ }
501
+
502
+ const { hostname, category } = classifyHost(parsedUrl.hostname);
503
+ if (category === 'localhost') {
504
+ throw new Error('URL points to localhost');
505
+ }
506
+ if (category === 'link-local') {
412
507
  throw new Error(`URL points to link-local address: ${hostname}`);
413
508
  }
509
+ if (category === 'reserved') {
510
+ throw new Error(`URL points to reserved address: ${hostname}`);
511
+ }
512
+ if (category === 'internal-name') {
513
+ throw new Error(`URL points to internal hostname: ${hostname}`);
514
+ }
515
+ if (category) {
516
+ throw new Error(`URL points to private IP range: ${hostname}`);
517
+ }
518
+ assertDomainAllowed(hostname, options);
519
+ }
520
+
521
+ /**
522
+ * Resolve a hostname to every address it maps to and classify each one.
523
+ * @param {string} hostname - Normalized, non-IP hostname
524
+ * @param {Function} [lookup] - `dns.promises.lookup`-compatible (for tests)
525
+ * @returns {Promise<Array<{address: string, family: number, category: (string|null)}>>}
526
+ * @throws {Error} - If resolution fails or returns no addresses
527
+ */
528
+ async function resolveHost (hostname, lookup = dns.promises.lookup) {
529
+ const addresses = await lookup(hostname, { all: true, verbatim: true });
530
+ if (!addresses || addresses.length === 0) {
531
+ throw new Error(`DNS lookup returned no addresses for ${hostname}`);
532
+ }
533
+ return addresses.map(({ address, family }) =>
534
+ ({ address, family, category: classifyHost(address).category }));
535
+ }
536
+
537
+ /**
538
+ * GHSA-qvp2-8q5w-gcfp FIX: validate a URL and every address its hostname
539
+ * resolves to, so a public DNS alias for a private address (`10.0.0.5.nip.io`)
540
+ * is rejected. Connect to the returned `address` (e.g. via a custom `lookup`
541
+ * on the HTTP agent) so a later DNS change cannot redirect the request.
542
+ *
543
+ * @param {string} url - The URL to validate
544
+ * @param {Object} [options] - `{ allowedProtocols, blockedDomains, allowedDomains, lookup }`
545
+ * @returns {Promise<{url: URL, address: string, family: number}>} - Checked URL and pinned address
546
+ * @throws {Error} - If the URL or any resolved address is restricted
547
+ */
548
+ async function resolveAndValidateURL (url, options = {}) {
549
+ const parsedUrl = validateURL(url, options.allowedProtocols);
550
+ validateURLLocation(parsedUrl, options);
551
+
552
+ const { hostname } = classifyHost(parsedUrl.hostname);
553
+ const family = net.isIP(hostname);
554
+ if (family) {
555
+ return { url: parsedUrl, address: hostname, family };
556
+ }
557
+
558
+ const resolved = await resolveHost(hostname, options.lookup);
559
+ const restricted = resolved.find(r => r.category);
560
+ if (restricted) {
561
+ throw new Error(`URL host ${hostname} resolves to ${restricted.category} address: ${restricted.address}`);
562
+ }
563
+ return { url: parsedUrl, address: resolved[0].address, family: resolved[0].family };
414
564
  }
415
565
 
416
566
  /**
@@ -569,9 +719,14 @@ module.exports = {
569
719
  validateFunction,
570
720
  validateRegExp,
571
721
  validateFilePath,
722
+ assertNotSensitivePath,
572
723
  validateFileExtension,
573
724
  validateURL,
574
725
  validateURLLocation,
726
+ classifyHost,
727
+ assertDomainAllowed,
728
+ resolveHost,
729
+ resolveAndValidateURL,
575
730
  validateCommand,
576
731
  validateOptions,
577
732
  validateRange,