mcp-sanitizer 1.3.2 → 1.4.0
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
|
@@ -4,7 +4,7 @@ A comprehensive security sanitization library for Model Context Protocol (MCP) s
|
|
|
4
4
|
|
|
5
5
|
[](https://badge.fury.io/js/mcp-sanitizer)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
|
-
[](./test)
|
|
8
8
|
|
|
9
9
|
## 🔒 Security Features
|
|
10
10
|
|
|
@@ -17,7 +17,7 @@ MCP Sanitizer provides comprehensive, defense-in-depth protection:
|
|
|
17
17
|
- ✅ **Framework Integration**: Express, Fastify, and Koa middleware with `skipPaths` support
|
|
18
18
|
- ✅ **Security Policies**: Pre-configured policies (STRICT, MODERATE, PERMISSIVE, DEVELOPMENT, PRODUCTION)
|
|
19
19
|
- ✅ **Comprehensive Validation**: Checking 42+ attack vectors across 12 validation layers in <1ms
|
|
20
|
-
- ✅ **Comprehensive Testing**:
|
|
20
|
+
- ✅ **Comprehensive Testing**: 1140 tests with 93% code coverage, sub-millisecond performance
|
|
21
21
|
|
|
22
22
|
### Security Philosophy
|
|
23
23
|
While we maintain rigorous security standards and comprehensive test coverage, we acknowledge that:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-sanitizer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
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.
|
|
42
|
+
"shell-quote": "^1.8.4",
|
|
43
43
|
"sqlstring": "^2.3.3",
|
|
44
44
|
"unorm": "^1.6.0",
|
|
45
45
|
"validator": "^13.15.22"
|
|
@@ -64,7 +64,8 @@
|
|
|
64
64
|
},
|
|
65
65
|
"homepage": "https://github.com/starman69/mcp-sanitizer#readme",
|
|
66
66
|
"overrides": {
|
|
67
|
-
"qs": ">=6.
|
|
68
|
-
"flatted": ">=3.4.2"
|
|
67
|
+
"qs": ">=6.15.2",
|
|
68
|
+
"flatted": ">=3.4.2",
|
|
69
|
+
"form-data": ">=4.0.6"
|
|
69
70
|
}
|
|
70
71
|
}
|
|
@@ -111,7 +111,6 @@ const STRICT_POLICY = {
|
|
|
111
111
|
},
|
|
112
112
|
url: {
|
|
113
113
|
allowPrivateIPs: false,
|
|
114
|
-
allowLocalhostWithoutPort: false,
|
|
115
114
|
maxURLLength: 512,
|
|
116
115
|
blockedDomains: ['localhost', '127.0.0.1', '0.0.0.0'],
|
|
117
116
|
allowedDomains: [] // Must explicitly allow domains
|
|
@@ -204,7 +203,6 @@ const MODERATE_POLICY = {
|
|
|
204
203
|
},
|
|
205
204
|
url: {
|
|
206
205
|
allowPrivateIPs: false,
|
|
207
|
-
allowLocalhostWithoutPort: false,
|
|
208
206
|
maxURLLength: 2048,
|
|
209
207
|
blockedDomains: [],
|
|
210
208
|
allowedDomains: []
|
|
@@ -288,7 +286,6 @@ const PERMISSIVE_POLICY = {
|
|
|
288
286
|
},
|
|
289
287
|
url: {
|
|
290
288
|
allowPrivateIPs: true,
|
|
291
|
-
allowLocalhostWithoutPort: true,
|
|
292
289
|
maxURLLength: 8192,
|
|
293
290
|
blockedDomains: [],
|
|
294
291
|
allowedDomains: []
|
|
@@ -350,7 +347,6 @@ const DEVELOPMENT_POLICY = {
|
|
|
350
347
|
...MODERATE_POLICY.contextSettings,
|
|
351
348
|
url: {
|
|
352
349
|
allowPrivateIPs: true,
|
|
353
|
-
allowLocalhostWithoutPort: true,
|
|
354
350
|
maxURLLength: 4096,
|
|
355
351
|
blockedDomains: [],
|
|
356
352
|
allowedDomains: []
|
|
@@ -424,7 +420,6 @@ const PRODUCTION_POLICY = {
|
|
|
424
420
|
},
|
|
425
421
|
url: {
|
|
426
422
|
allowPrivateIPs: false,
|
|
427
|
-
allowLocalhostWithoutPort: false,
|
|
428
423
|
maxURLLength: 2048,
|
|
429
424
|
blockedDomains: ['localhost', '127.0.0.1', '0.0.0.0'],
|
|
430
425
|
allowedDomains: [] // Should be configured per application
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const path = require('path');
|
|
12
|
+
const net = require('net');
|
|
12
13
|
const { URL } = require('url');
|
|
13
14
|
// CVE-TBD-001 FIX: Import unified parser for consistent string normalization
|
|
14
15
|
const { parseUnified } = require('./unified-parser');
|
|
@@ -149,6 +150,20 @@ function validateFilePath (filePath) {
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
|
|
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
|
+
}
|
|
166
|
+
|
|
152
167
|
// Use path-is-inside to check if the path tries to escape a safe directory
|
|
153
168
|
// Define safe root directories
|
|
154
169
|
const safeRoots = ['/tmp', '/var/tmp', './uploads', './data', process.cwd()];
|
|
@@ -186,8 +201,11 @@ function validateFilePath (filePath) {
|
|
|
186
201
|
|
|
187
202
|
// If path is not in a safe location and is absolute, be more restrictive
|
|
188
203
|
if (!isInSafeLocation && path.isAbsolute(normalizedPath)) {
|
|
189
|
-
//
|
|
190
|
-
|
|
204
|
+
// GHSA-7w82-6f9j-v9jp FIX: `/home/` and `/Users/` were removed from this
|
|
205
|
+
// allowlist. Whitelisting entire home roots let any absolute path under a
|
|
206
|
+
// user's home directory (e.g. ~/.ssh/id_rsa) pass with blocked=false.
|
|
207
|
+
// Only genuinely shared scratch directories remain allowed by default.
|
|
208
|
+
const allowedAbsolutePaths = ['/tmp/', '/var/tmp/'];
|
|
191
209
|
const isAllowedAbsolute = allowedAbsolutePaths.some(allowed =>
|
|
192
210
|
normalizedPath.toLowerCase().startsWith(allowed.toLowerCase())
|
|
193
211
|
);
|
|
@@ -256,7 +274,58 @@ function validateURL (url, allowedProtocols = ['http', 'https']) {
|
|
|
256
274
|
}
|
|
257
275
|
|
|
258
276
|
/**
|
|
259
|
-
*
|
|
277
|
+
* Expand an IPv6 address (including `::` compression and trailing embedded
|
|
278
|
+
* IPv4) into its canonical 8-group, zero-padded, lowercase form so ranges can
|
|
279
|
+
* be compared reliably. Returns null when the input is not a parseable IPv6.
|
|
280
|
+
* @param {string} addr - IPv6 hostname without surrounding brackets
|
|
281
|
+
* @returns {string|null} - e.g. '0000:0000:0000:0000:0000:0000:0000:0001'
|
|
282
|
+
*/
|
|
283
|
+
function expandIPv6 (addr) {
|
|
284
|
+
let s = addr;
|
|
285
|
+
|
|
286
|
+
// Convert a trailing embedded IPv4 (e.g. ::ffff:127.0.0.1) into two hex groups
|
|
287
|
+
const embedded = s.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
288
|
+
if (embedded) {
|
|
289
|
+
const octets = embedded[2].split('.').map(Number);
|
|
290
|
+
if (octets.every(n => n >= 0 && n <= 255)) {
|
|
291
|
+
const g1 = ((octets[0] << 8) | octets[1]).toString(16);
|
|
292
|
+
const g2 = ((octets[2] << 8) | octets[3]).toString(16);
|
|
293
|
+
s = `${embedded[1]}${g1}:${g2}`;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let groups;
|
|
298
|
+
if (s.includes('::')) {
|
|
299
|
+
const parts = s.split('::');
|
|
300
|
+
if (parts.length > 2) return null;
|
|
301
|
+
const head = parts[0] ? parts[0].split(':') : [];
|
|
302
|
+
const tail = parts[1] ? parts[1].split(':') : [];
|
|
303
|
+
const missing = 8 - head.length - tail.length;
|
|
304
|
+
if (missing < 0) return null;
|
|
305
|
+
groups = head.concat(Array(missing).fill('0'), tail);
|
|
306
|
+
} else {
|
|
307
|
+
groups = s.split(':');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (groups.length !== 8) return null;
|
|
311
|
+
return groups
|
|
312
|
+
.map(g => (/^[0-9a-f]{1,4}$/.test(g) ? g.padStart(4, '0') : null))
|
|
313
|
+
.map(g => g || 'zzzz') // invalid group -> sentinel that matches no real range
|
|
314
|
+
.join(':');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
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
|
+
*
|
|
260
329
|
* @param {string|URL} url - The URL to validate (string or URL object)
|
|
261
330
|
* @throws {Error} - If URL points to restricted location
|
|
262
331
|
*/
|
|
@@ -269,29 +338,77 @@ function validateURLLocation (url) {
|
|
|
269
338
|
throw new Error('URL must be a string or URL object');
|
|
270
339
|
}
|
|
271
340
|
|
|
272
|
-
|
|
341
|
+
// Normalize: strip IPv6 brackets and a trailing dot (FQDN root) before checks.
|
|
342
|
+
const hostname = parsedUrl.hostname
|
|
343
|
+
.toLowerCase()
|
|
344
|
+
.replace(/^\[|\]$/g, '')
|
|
345
|
+
.replace(/\.$/, '');
|
|
273
346
|
|
|
274
|
-
//
|
|
275
|
-
if (
|
|
276
|
-
throw new Error('URL points to localhost
|
|
347
|
+
// localhost and any *.localhost name — blocked regardless of port.
|
|
348
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
|
349
|
+
throw new Error('URL points to localhost');
|
|
277
350
|
}
|
|
278
351
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
];
|
|
352
|
+
const ipVersion = net.isIP(hostname);
|
|
353
|
+
|
|
354
|
+
// IPv4 literal — classify directly.
|
|
355
|
+
if (ipVersion === 4) {
|
|
356
|
+
assertPublicIPv4(hostname.split('.').map(Number), hostname);
|
|
357
|
+
}
|
|
286
358
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
359
|
+
// IPv6 — classify the address family, then any IPv4-mapped/compatible payload.
|
|
360
|
+
if (ipVersion === 6) {
|
|
361
|
+
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
|
+
}
|
|
290
388
|
}
|
|
291
389
|
}
|
|
390
|
+
}
|
|
292
391
|
|
|
293
|
-
|
|
294
|
-
|
|
392
|
+
/**
|
|
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.
|
|
395
|
+
* @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
|
|
398
|
+
*/
|
|
399
|
+
function assertPublicIPv4 (o, hostname) {
|
|
400
|
+
// 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.
|
|
405
|
+
if (o[0] === 10 ||
|
|
406
|
+
(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}`);
|
|
409
|
+
}
|
|
410
|
+
// Link-local 169.254.0.0/16 (includes cloud metadata 169.254.169.254).
|
|
411
|
+
if (o[0] === 169 && o[1] === 254) {
|
|
295
412
|
throw new Error(`URL points to link-local address: ${hostname}`);
|
|
296
413
|
}
|
|
297
414
|
}
|