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
  [![npm version](https://badge.fury.io/js/mcp-sanitizer.svg)](https://badge.fury.io/js/mcp-sanitizer)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
- [![Security Tests](https://img.shields.io/badge/Security%20Tests-1100%2B-brightgreen)](./test)
7
+ [![Security Tests](https://img.shields.io/badge/Security%20Tests-1140%2B-brightgreen)](./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**: 1114 tests with 93% code coverage, sub-millisecond performance
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.2",
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.3",
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.14.2",
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
  }
@@ -149,7 +149,6 @@ const DEFAULT_CONFIG = {
149
149
  // URL validation
150
150
  url: {
151
151
  allowPrivateIPs: false,
152
- allowLocalhostWithoutPort: false,
153
152
  maxURLLength: 2048,
154
153
  blockedDomains: [],
155
154
  allowedDomains: []
@@ -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
- // Allow some common safe absolute paths for legitimate use
190
- const allowedAbsolutePaths = ['/tmp/', '/var/tmp/', '/home/', '/Users/'];
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
- * Validate URL against restricted locations (localhost, private IPs, etc.)
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
- const hostname = parsedUrl.hostname.toLowerCase();
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
- // Check for localhost - allow localhost with explicit port for development
275
- if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) {
276
- throw new Error('URL points to localhost without explicit port');
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
- // Check for private IP ranges
280
- const privateIPPatterns = [
281
- /^127\./, // 127.0.0.0/8 (loopback)
282
- /^10\./, // 10.0.0.0/8 (private)
283
- /^192\.168\./, // 192.168.0.0/16 (private)
284
- /^172\.(1[6-9]|2[0-9]|3[01])\./ // 172.16.0.0/12 (private)
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
- for (const pattern of privateIPPatterns) {
288
- if (pattern.test(hostname)) {
289
- throw new Error(`URL points to private IP range: ${hostname}`);
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
- // Check for link-local addresses
294
- if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) {
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
  }