mcp-sanitizer 1.4.1 → 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 +20 -1
- package/package.json +5 -5
- package/src/index.js +1 -0
- package/src/sanitizer/mcp-sanitizer.js +2 -2
- package/src/sanitizer/validators/file-path.js +10 -1
- package/src/sanitizer/validators/url.js +69 -104
- package/src/utils/validation-utils.js +233 -78
package/README.md
CHANGED
|
@@ -188,7 +188,8 @@ const sanitizer = new MCPSanitizer({
|
|
|
188
188
|
contextSettings: {
|
|
189
189
|
url: {
|
|
190
190
|
allowPrivateIPs: false,
|
|
191
|
-
|
|
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.
|
|
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.
|
|
42
|
+
"shell-quote": "^1.12.0",
|
|
43
43
|
"sqlstring": "^2.3.3",
|
|
44
44
|
"unorm": "^1.6.0",
|
|
45
45
|
"validator": "^13.15.22"
|
|
@@ -67,11 +67,11 @@
|
|
|
67
67
|
"qs": ">=6.15.2",
|
|
68
68
|
"flatted": ">=3.4.2",
|
|
69
69
|
"form-data": ">=4.0.6",
|
|
70
|
-
"js-yaml": "4.3.
|
|
70
|
+
"js-yaml": "4.3.2",
|
|
71
71
|
"@istanbuljs/load-nyc-config": {
|
|
72
|
-
"js-yaml": "3.15.
|
|
72
|
+
"js-yaml": "3.15.2"
|
|
73
73
|
},
|
|
74
|
-
"brace-expansion": "1.1.
|
|
74
|
+
"brace-expansion": "1.1.21",
|
|
75
75
|
"@babel/core": "^7.29.7"
|
|
76
76
|
}
|
|
77
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
//
|
|
505
|
-
|
|
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
|
-
|
|
508
|
-
await dns.lookup(hostname);
|
|
506
|
+
resolved = await resolveHost(host);
|
|
509
507
|
} catch (error) {
|
|
510
|
-
result.
|
|
511
|
-
result.
|
|
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
|
-
*
|
|
520
|
-
* @param {string} hostname -
|
|
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
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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
|
|
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 =
|
|
349
|
+
const hostname = String(rawHostname)
|
|
343
350
|
.toLowerCase()
|
|
344
351
|
.replace(/^\[|\]$/g, '')
|
|
345
352
|
.replace(/\.$/, '');
|
|
346
353
|
|
|
347
|
-
// localhost and any *.localhost name —
|
|
354
|
+
// localhost and any *.localhost name — regardless of port.
|
|
348
355
|
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
|
349
|
-
|
|
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
|
-
|
|
363
|
+
return { hostname, category: classifyIPv4(hostname.split('.').map(Number)) };
|
|
357
364
|
}
|
|
358
365
|
|
|
359
|
-
// IPv6 — classify the address family, then any IPv4
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
431
|
+
function classifyIPv4 (o) {
|
|
400
432
|
// Loopback 127.0.0.0/8 and unspecified 0.0.0.0/8.
|
|
401
|
-
if (o[0] === 127
|
|
402
|
-
|
|
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
|
-
|
|
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,
|