@mctx-ai/mcp-server 0.3.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.
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Security Module - MCP Server Security Protections
3
+ *
4
+ * Provides comprehensive security controls:
5
+ * - Error sanitization (secret redaction, stack trace removal)
6
+ * - Request/response size limits (DoS prevention)
7
+ * - URI scheme validation (injection prevention)
8
+ * - Prototype pollution protection (object sanitization)
9
+ *
10
+ * Defense in depth: Multiple layers protect against OWASP Top 10 threats.
11
+ */
12
+
13
+ // Secret patterns to detect and redact
14
+ // IMPORTANT: Order matters - more specific patterns must come before generic patterns
15
+ const SECRET_PATTERNS = [
16
+ // AWS Access Keys (AKIA + 16 alphanumeric chars)
17
+ { regex: /AKIA[0-9A-Z]{16}/g, label: "REDACTED_AWS_KEY" },
18
+
19
+ // JWT tokens (three base64url segments separated by dots)
20
+ {
21
+ regex: /eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g,
22
+ label: "REDACTED_JWT",
23
+ },
24
+
25
+ // Database connection strings
26
+ {
27
+ regex: /(?:mongodb|postgres|mysql|mariadb|mssql|oracle):\/\/[^\s]+/gi,
28
+ label: "REDACTED_CONNECTION_STRING",
29
+ },
30
+
31
+ // Bearer tokens
32
+ { regex: /Bearer\s+[a-zA-Z0-9_\-.]+/gi, label: "Bearer [REDACTED]" },
33
+
34
+ // GitHub tokens (personal access tokens, OAuth, app tokens, refresh tokens)
35
+ // MUST come before generic API key pattern (which would match "token:")
36
+ // GitHub tokens: gh[pors]_ prefix + 33-40 alphanumeric chars
37
+ // Note: gh[pors] matches ghp, gho, ghr, ghs
38
+ { regex: /gh[pors]_[a-zA-Z0-9]{33,40}/g, label: "REDACTED_GITHUB_TOKEN" },
39
+
40
+ // Slack tokens (format: xoxb-numbers-alphanumeric or similar hyphen-separated patterns)
41
+ // MUST come before generic API key pattern (which would match "token:")
42
+ {
43
+ regex: /xox[bpas]-[a-zA-Z0-9]+-[a-zA-Z0-9-]+/g,
44
+ label: "REDACTED_SLACK_TOKEN",
45
+ },
46
+
47
+ // Private keys (RSA, EC, DSA)
48
+ {
49
+ regex:
50
+ /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA )?PRIVATE KEY-----/g,
51
+ label: "REDACTED_PRIVATE_KEY",
52
+ },
53
+
54
+ // GCP API keys (AIzaSy prefix + variable length alphanumeric/special chars)
55
+ { regex: /AIzaSy[0-9A-Za-z\-_]{21,}/g, label: "REDACTED_GCP_KEY" },
56
+
57
+ // Azure connection strings
58
+ { regex: /AccountKey=[a-zA-Z0-9+/=]{40,}/g, label: "REDACTED_AZURE_KEY" },
59
+
60
+ // Generic API key patterns (key=, token=, secret=, password= followed by value)
61
+ // ReDoS mitigation: bounded whitespace quantifiers (max 10 instead of unbounded \s*)
62
+ // MUST come LAST to avoid matching more specific token patterns above
63
+ {
64
+ regex:
65
+ /(?:api[_-]?key|token|secret|password)\s{0,10}[=:]\s{0,10}['"]?([a-zA-Z0-9_.-]{16,})['"]?/gi,
66
+ label: (match) =>
67
+ match.replace(/(['"]?)[a-zA-Z0-9_.-]{16,}(['"]?)/, "$1[REDACTED]$2"),
68
+ },
69
+ ];
70
+
71
+ // Dangerous URI schemes that enable injection attacks
72
+ const DANGEROUS_SCHEMES = ["file", "javascript", "data", "vbscript", "about"];
73
+
74
+ /**
75
+ * Sanitize error messages for safe output
76
+ *
77
+ * Security: Prevents information disclosure through error messages
78
+ * - Removes stack traces in production (leak implementation details)
79
+ * - Redacts secrets (AWS keys, JWTs, connection strings, API keys)
80
+ * - Preserves error context for debugging
81
+ *
82
+ * @param {Error} error - Error object to sanitize
83
+ * @param {boolean} isProduction - True to strip stack traces (default: true)
84
+ * @returns {string} Sanitized error message
85
+ */
86
+ export function sanitizeError(error, isProduction = true) {
87
+ if (!error) return "Unknown error";
88
+
89
+ let message = error.message || String(error);
90
+
91
+ // Redact secrets from message
92
+ for (const pattern of SECRET_PATTERNS) {
93
+ if (typeof pattern.label === "function") {
94
+ message = message.replace(pattern.regex, pattern.label);
95
+ } else {
96
+ message = message.replace(pattern.regex, `[${pattern.label}]`);
97
+ }
98
+ }
99
+
100
+ // In production: strip stack traces completely
101
+ // In development: include stack trace but redact secrets
102
+ if (!isProduction && error.stack) {
103
+ let stack = error.stack;
104
+
105
+ // Redact secrets from stack trace
106
+ for (const pattern of SECRET_PATTERNS) {
107
+ if (typeof pattern.label === "function") {
108
+ stack = stack.replace(pattern.regex, pattern.label);
109
+ } else {
110
+ stack = stack.replace(pattern.regex, `[${pattern.label}]`);
111
+ }
112
+ }
113
+
114
+ return stack;
115
+ }
116
+
117
+ return message;
118
+ }
119
+
120
+ /**
121
+ * Validate request body size
122
+ *
123
+ * Security: Prevents DoS attacks via large payloads
124
+ * - Default 1MB limit (configurable)
125
+ * - Validates before parsing to avoid memory exhaustion
126
+ *
127
+ * @param {string|Object} body - Request body (JSON string or parsed object)
128
+ * @param {number} maxSize - Maximum size in bytes (default: 1MB)
129
+ * @throws {Error} If body exceeds maxSize
130
+ */
131
+ export function validateRequestSize(body, maxSize = 1048576) {
132
+ if (!body) return;
133
+
134
+ const size =
135
+ typeof body === "string" ? body.length : JSON.stringify(body).length;
136
+
137
+ if (size > maxSize) {
138
+ throw new Error(
139
+ `Request body too large: ${size} bytes (max: ${maxSize} bytes)`,
140
+ );
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Validate response body size
146
+ *
147
+ * Security: Prevents DoS attacks via large responses
148
+ * - Default 1MB limit (configurable)
149
+ * - Protects clients from memory exhaustion
150
+ *
151
+ * @param {string|Object} body - Response body (JSON string or object)
152
+ * @param {number} maxSize - Maximum size in bytes (default: 1MB)
153
+ * @throws {Error} If body exceeds maxSize
154
+ */
155
+ export function validateResponseSize(body, maxSize = 1048576) {
156
+ if (!body) return;
157
+
158
+ const size =
159
+ typeof body === "string" ? body.length : JSON.stringify(body).length;
160
+
161
+ if (size > maxSize) {
162
+ throw new Error(
163
+ `Response body too large: ${size} bytes (max: ${maxSize} bytes)`,
164
+ );
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Validate string input length
170
+ *
171
+ * Security: Prevents DoS via excessively long strings
172
+ * - Default 10MB limit (configurable)
173
+ * - Useful for individual string fields
174
+ *
175
+ * @param {string} value - String to validate
176
+ * @param {number} maxLength - Maximum length in characters (default: 10MB)
177
+ * @throws {Error} If string exceeds maxLength
178
+ */
179
+ export function validateStringInput(value, maxLength = 10485760) {
180
+ if (typeof value !== "string") return;
181
+
182
+ if (value.length > maxLength) {
183
+ throw new Error(
184
+ `String input too long: ${value.length} chars (max: ${maxLength} chars)`,
185
+ );
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Validate URI scheme against allowlist
191
+ *
192
+ * Security: Prevents injection attacks via dangerous URI schemes
193
+ * - Blocks file://, javascript:, data:, etc. by default
194
+ * - Allows http:// and https:// by default
195
+ * - Supports custom allowlists for special cases
196
+ *
197
+ * @param {string} uri - URI to validate
198
+ * @param {string[]} allowedSchemes - Allowed schemes (default: ['http', 'https'])
199
+ * @returns {boolean} True if URI scheme is allowed
200
+ */
201
+ export function validateUriScheme(uri, allowedSchemes = ["http", "https"]) {
202
+ if (!uri || typeof uri !== "string") return false;
203
+
204
+ // Extract scheme (characters before first colon)
205
+ const schemeMatch = uri.match(/^([a-z][a-z0-9+.-]*?):/i);
206
+ if (!schemeMatch) return false;
207
+
208
+ const scheme = schemeMatch[1].toLowerCase();
209
+
210
+ // Check if scheme is explicitly dangerous
211
+ if (DANGEROUS_SCHEMES.includes(scheme)) {
212
+ return false;
213
+ }
214
+
215
+ // Check if scheme is in allowlist
216
+ return allowedSchemes.some((allowed) => allowed.toLowerCase() === scheme);
217
+ }
218
+
219
+ /**
220
+ * Canonicalize path and detect traversal attempts
221
+ *
222
+ * Security: Prevents path traversal attacks
223
+ * - Detects ../ sequences that escape intended directory
224
+ * - Handles double/triple encoding (%252e%252e%252f)
225
+ * - Detects null byte injection (%00 and literal \0)
226
+ * - Handles mixed encoding (%2e./, .%2e/)
227
+ * - Checks Unicode variants (\u002e\u002e/)
228
+ * - Normalizes path for consistent validation
229
+ *
230
+ * @param {string} uri - URI/path to canonicalize
231
+ * @returns {string} Canonicalized path
232
+ * @throws {Error} If path traversal detected
233
+ */
234
+ export function canonicalizePath(uri) {
235
+ if (!uri || typeof uri !== "string") {
236
+ throw new Error("Invalid URI: must be a non-empty string");
237
+ }
238
+
239
+ // Decode URI repeatedly (max 3 iterations) to catch double/triple encoding
240
+ let decoded = uri;
241
+ for (let i = 0; i < 3; i++) {
242
+ try {
243
+ const nextDecoded = decodeURIComponent(decoded);
244
+ if (nextDecoded === decoded) break; // No more decoding needed
245
+ decoded = nextDecoded;
246
+ } catch {
247
+ // Invalid URI encoding, continue with current value
248
+ break;
249
+ }
250
+ }
251
+
252
+ // Detect null byte injection (both encoded and literal)
253
+ if (decoded.includes("%00") || decoded.includes("\0")) {
254
+ throw new Error(
255
+ "Path traversal detected: null byte injection is not allowed",
256
+ );
257
+ }
258
+
259
+ // Detect Unicode variants of dots and slashes
260
+ // \u002e = '.', \u002f = '/'
261
+ if (
262
+ decoded.includes("\u002e\u002e\u002f") ||
263
+ decoded.includes("\u002e\u002e/") ||
264
+ decoded.includes("..\u002f")
265
+ ) {
266
+ throw new Error(
267
+ "Path traversal detected: Unicode-encoded ../ sequences are not allowed",
268
+ );
269
+ }
270
+
271
+ // Check fully decoded string for literal path traversal attempts
272
+ if (decoded.includes("../") || decoded.includes("..\\")) {
273
+ throw new Error("Path traversal detected: ../ sequences are not allowed");
274
+ }
275
+
276
+ // Check for mixed encoding in decoded string (case-insensitive)
277
+ const lowerDecoded = decoded.toLowerCase();
278
+ if (
279
+ lowerDecoded.includes("%2e%2e%2f") ||
280
+ lowerDecoded.includes("%2e%2e/") ||
281
+ lowerDecoded.includes("..%2f") ||
282
+ lowerDecoded.includes("%2e.") ||
283
+ lowerDecoded.includes(".%2e")
284
+ ) {
285
+ throw new Error(
286
+ "Path traversal detected: encoded ../ sequences are not allowed",
287
+ );
288
+ }
289
+
290
+ // Normalize path separators
291
+ let normalized = decoded.replace(/\\/g, "/");
292
+
293
+ // Remove duplicate slashes
294
+ normalized = normalized.replace(/\/+/g, "/");
295
+
296
+ return normalized;
297
+ }
298
+
299
+ /**
300
+ * Sanitize input object to prevent prototype pollution
301
+ *
302
+ * Security: Prevents prototype pollution attacks
303
+ * - Strips __proto__, constructor, prototype keys
304
+ * - Deep clones to avoid mutations
305
+ * - Handles nested objects and arrays
306
+ *
307
+ * Reference: OWASP Prototype Pollution Prevention
308
+ *
309
+ * @param {*} obj - Object to sanitize
310
+ * @returns {*} Clean copy with dangerous keys removed
311
+ */
312
+ export function sanitizeInput(obj) {
313
+ // Handle non-objects (primitives, null, undefined)
314
+ if (obj === null || typeof obj !== "object") {
315
+ return obj;
316
+ }
317
+
318
+ // Handle arrays
319
+ if (Array.isArray(obj)) {
320
+ return obj.map((item) => sanitizeInput(item));
321
+ }
322
+
323
+ // Handle objects - create clean copy
324
+ const sanitized = {};
325
+
326
+ for (const key in obj) {
327
+ // Skip prototype pollution vectors
328
+ if (key === "__proto__" || key === "constructor" || key === "prototype") {
329
+ continue;
330
+ }
331
+
332
+ // Recursively sanitize nested values
333
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
334
+ sanitized[key] = sanitizeInput(obj[key]);
335
+ }
336
+ }
337
+
338
+ return sanitized;
339
+ }