@agentmailbox/mcp-auth 1.0.10 → 1.1.1
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 +78 -4
- package/index.js +492 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,13 +57,87 @@ Replace:
|
|
|
57
57
|
| Variable | Required | Description |
|
|
58
58
|
|----------|----------|-------------|
|
|
59
59
|
| `MCP_OAUTH_CLIENT_ID` | Yes | Your OAuth2 client ID |
|
|
60
|
-
| `MCP_OAUTH_CLIENT_SECRET` |
|
|
60
|
+
| `MCP_OAUTH_CLIENT_SECRET` | Conditional | Your OAuth2 client secret (or use file-based secret) |
|
|
61
|
+
| `MCP_OAUTH_CLIENT_SECRET_FILE` | Conditional | Path to file containing client secret (more secure) |
|
|
62
|
+
| `MCP_DISABLE_CERT_PINNING` | No | Set to `"true"` to disable certificate pinning (for corporate proxies) |
|
|
63
|
+
| `MCP_DEBUG` | No | Set to `"true"` to enable debug logging (never logs secrets) |
|
|
64
|
+
|
|
65
|
+
**Note:** Either `MCP_OAUTH_CLIENT_SECRET` or `MCP_OAUTH_CLIENT_SECRET_FILE` must be provided.
|
|
66
|
+
|
|
67
|
+
### Using File-Based Secrets (Recommended for Production)
|
|
68
|
+
|
|
69
|
+
For enhanced security, store your client secret in a file instead of an environment variable:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Create a secret file with restricted permissions (avoids shell history)
|
|
73
|
+
read -rs SECRET && printf '%s' "$SECRET" > ~/.agentmail-secret && unset SECRET
|
|
74
|
+
chmod 600 ~/.agentmail-secret
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then configure:
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"env": {
|
|
81
|
+
"MCP_OAUTH_CLIENT_ID": "your-client-id",
|
|
82
|
+
"MCP_OAUTH_CLIENT_SECRET_FILE": "/Users/you/.agentmail-secret"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Security Features
|
|
88
|
+
|
|
89
|
+
This package implements several security measures:
|
|
90
|
+
|
|
91
|
+
| Feature | Description |
|
|
92
|
+
|---------|-------------|
|
|
93
|
+
| **HTTPS Enforcement** | All endpoints must use HTTPS (localhost allowed for development) |
|
|
94
|
+
| **Certificate Pinning** | SPKI pinning for AgentMailbox domains (Amazon Root CAs) |
|
|
95
|
+
| **Environment Isolation** | Child processes receive minimal environment variables |
|
|
96
|
+
| **File-Based Secrets** | Option to read secrets from files instead of environment variables |
|
|
97
|
+
| **Response Size Limits** | Prevents memory exhaustion from malicious responses (100KB max) |
|
|
98
|
+
| **Token Validation** | Validates JWT token format before use |
|
|
99
|
+
| **Error Sanitization** | Removes sensitive data from error messages |
|
|
100
|
+
| **Pinned Dependencies** | Uses pinned version of mcp-remote |
|
|
101
|
+
| **Exponential Backoff** | Automatic retries with jitter to prevent thundering herd |
|
|
102
|
+
| **Security Headers** | Advisory checks for HSTS and X-Content-Type-Options (logs warnings in debug mode) |
|
|
103
|
+
|
|
104
|
+
### Certificate Pinning
|
|
105
|
+
|
|
106
|
+
Certificate pinning is enabled by default for AgentMailbox domains (`auth.agentmailbox.to`, `mcp.agentmailbox.to`). This helps mitigate many MITM scenarios by validating that certificates are signed by expected root CAs.
|
|
107
|
+
|
|
108
|
+
If you're behind a corporate proxy that performs TLS inspection, you may need to disable pinning:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"env": {
|
|
113
|
+
"MCP_DISABLE_CERT_PINNING": "true"
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Warning:** Only disable certificate pinning if you understand the security implications.
|
|
119
|
+
|
|
120
|
+
## Troubleshooting
|
|
121
|
+
|
|
122
|
+
Enable debug logging to diagnose connection issues:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"env": {
|
|
127
|
+
"MCP_DEBUG": "true"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Debug logs are written to stderr and include timestamps. **Sensitive data (tokens, secrets) is never logged.**
|
|
61
133
|
|
|
62
134
|
## How It Works
|
|
63
135
|
|
|
64
|
-
1.
|
|
65
|
-
2.
|
|
66
|
-
3.
|
|
136
|
+
1. Reads client credentials from environment variables or secret file
|
|
137
|
+
2. Fetches an OAuth2 access token using the Client Credentials flow (with automatic retries)
|
|
138
|
+
3. Validates the token response (size limits, JWT format, security headers)
|
|
139
|
+
4. Passes the token to `mcp-remote` via environment variable
|
|
140
|
+
5. Forwards all MCP communication to the AgentMailbox server
|
|
67
141
|
|
|
68
142
|
## Requirements
|
|
69
143
|
|
package/index.js
CHANGED
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
* OAuth2 Client Credentials wrapper for MCP servers.
|
|
6
6
|
* Handles M2M (machine-to-machine) authentication for AgentMailbox MCP endpoints.
|
|
7
7
|
*
|
|
8
|
+
* Security features:
|
|
9
|
+
* - HTTPS enforcement for all endpoints (localhost allowed for development)
|
|
10
|
+
* - Certificate pinning for AgentMailbox domains (optional, enabled by default)
|
|
11
|
+
* - Environment isolation for child processes
|
|
12
|
+
* - Support for secret file to avoid environment variable exposure
|
|
13
|
+
* - Response size limits and token format validation
|
|
14
|
+
* - Sanitized error messages to prevent information leakage
|
|
15
|
+
* - Exponential backoff for retries (prevents accidental DoS)
|
|
16
|
+
* - Security headers validation
|
|
17
|
+
* - Optional debug logging for troubleshooting
|
|
18
|
+
*
|
|
8
19
|
* Usage with npx in claude_desktop_config.json:
|
|
9
20
|
* {
|
|
10
21
|
* "mcpServers": {
|
|
@@ -27,15 +38,123 @@
|
|
|
27
38
|
*/
|
|
28
39
|
|
|
29
40
|
import { spawn } from 'child_process';
|
|
41
|
+
import { readFileSync } from 'fs';
|
|
42
|
+
import { request } from 'https';
|
|
43
|
+
import { request as httpRequest } from 'http';
|
|
44
|
+
import { createHash } from 'crypto';
|
|
45
|
+
import { checkServerIdentity as tlsCheckServerIdentity } from 'tls';
|
|
46
|
+
|
|
47
|
+
// Security constants
|
|
48
|
+
const TOKEN_TIMEOUT_MS = 30000; // 30 seconds
|
|
49
|
+
const MAX_TOKEN_RESPONSE_SIZE = 100000; // 100KB max response
|
|
50
|
+
const MCP_REMOTE_VERSION = '0.1.12'; // Pinned version for security
|
|
51
|
+
|
|
52
|
+
// Retry configuration with exponential backoff
|
|
53
|
+
const MAX_RETRIES = 3;
|
|
54
|
+
const INITIAL_RETRY_DELAY_MS = 1000; // 1 second
|
|
55
|
+
const MAX_RETRY_DELAY_MS = 10000; // 10 seconds
|
|
56
|
+
|
|
57
|
+
// Valid localhost hostnames (including IPv6)
|
|
58
|
+
const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
59
|
+
|
|
60
|
+
// Known AgentMailbox domains that should have certificate pinning
|
|
61
|
+
const PINNED_DOMAINS = new Set([
|
|
62
|
+
'auth.agentmailbox.to',
|
|
63
|
+
'mcp.agentmailbox.to',
|
|
64
|
+
'api.agentmailbox.to',
|
|
65
|
+
]);
|
|
30
66
|
|
|
31
|
-
//
|
|
32
|
-
const
|
|
67
|
+
// Expected security headers for token endpoint
|
|
68
|
+
const EXPECTED_SECURITY_HEADERS = {
|
|
69
|
+
'strict-transport-security': true, // HSTS should be present
|
|
70
|
+
'x-content-type-options': 'nosniff',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// SPKI (Subject Public Key Info) hashes for certificate pinning
|
|
74
|
+
// These are SHA-256 hashes of the public keys in the certificate chain
|
|
75
|
+
// Using Amazon Root CA and intermediate CAs that sign AgentMailbox certs
|
|
76
|
+
// To update: openssl s_client -connect auth.agentmailbox.to:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
|
|
77
|
+
// Public key hashes for certificate pinning (these are PUBLIC, not secrets)
|
|
78
|
+
// pragma: allowlist nextline secret
|
|
79
|
+
const PINNED_PUBLIC_KEYS = new Set([
|
|
80
|
+
// Amazon Root CA 1 - pragma: allowlist secret
|
|
81
|
+
'++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=', // pragma: allowlist secret
|
|
82
|
+
// Amazon Root CA 2
|
|
83
|
+
'f0KW/FtqTjs108NpYj42SrGvOB2PpxIVM8nWxjPqJGE=', // pragma: allowlist secret
|
|
84
|
+
// Amazon Root CA 3
|
|
85
|
+
'NqvDJlas/GRcYbcWE8S/IceG7Qz2ddXE5XX9x8JMA+k=', // pragma: allowlist secret
|
|
86
|
+
// Amazon Root CA 4
|
|
87
|
+
'9+ze1cZgR9KO1kZrVDxA4HQ6voHRCSVNz4RdTCx4U8U=', // pragma: allowlist secret
|
|
88
|
+
// Starfield Services Root CA (Amazon's root)
|
|
89
|
+
'KwccWaCgrnaw6tsrrSO61FgLacNgG2MMLq8GE6+oP5I=', // pragma: allowlist secret
|
|
90
|
+
]);
|
|
33
91
|
|
|
34
92
|
const [mcpUrl, tokenEndpoint, scope] = process.argv.slice(2);
|
|
35
93
|
const clientId = process.env.MCP_OAUTH_CLIENT_ID;
|
|
36
|
-
const clientSecret = process.env.MCP_OAUTH_CLIENT_SECRET;
|
|
37
94
|
|
|
38
|
-
|
|
95
|
+
// Configuration flags
|
|
96
|
+
const DISABLE_CERT_PINNING = process.env.MCP_DISABLE_CERT_PINNING === 'true';
|
|
97
|
+
const DEBUG_MODE = process.env.MCP_DEBUG === 'true';
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Debug logger - only logs when MCP_DEBUG=true
|
|
101
|
+
* Never logs sensitive data like secrets or tokens
|
|
102
|
+
*/
|
|
103
|
+
function debugLog(message, data = null) {
|
|
104
|
+
if (!DEBUG_MODE) return;
|
|
105
|
+
|
|
106
|
+
const timestamp = new Date().toISOString();
|
|
107
|
+
const prefix = `[mcp-auth ${timestamp}]`;
|
|
108
|
+
|
|
109
|
+
if (data) {
|
|
110
|
+
// Sanitize any potentially sensitive data
|
|
111
|
+
const safeData = JSON.stringify(data, (key, value) => {
|
|
112
|
+
const sensitiveKeys = ['secret', 'token', 'password', 'authorization', 'bearer'];
|
|
113
|
+
if (sensitiveKeys.some(k => key.toLowerCase().includes(k))) {
|
|
114
|
+
return '[REDACTED]';
|
|
115
|
+
}
|
|
116
|
+
return value;
|
|
117
|
+
});
|
|
118
|
+
console.error(`${prefix} ${message}:`, safeData);
|
|
119
|
+
} else {
|
|
120
|
+
console.error(`${prefix} ${message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get client secret from environment variable or file.
|
|
126
|
+
* File-based secrets are more secure as they're not visible in process listings.
|
|
127
|
+
*/
|
|
128
|
+
function getClientSecret() {
|
|
129
|
+
// Prefer file-based secret if specified
|
|
130
|
+
const secretFile = process.env.MCP_OAUTH_CLIENT_SECRET_FILE;
|
|
131
|
+
if (secretFile) {
|
|
132
|
+
debugLog('Reading secret from file', { path: secretFile });
|
|
133
|
+
try {
|
|
134
|
+
const secret = readFileSync(secretFile, 'utf-8').trim();
|
|
135
|
+
if (!secret) {
|
|
136
|
+
console.error('Error: Secret file is empty');
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
debugLog('Secret loaded from file successfully');
|
|
140
|
+
return secret;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error(`Error: Failed to read secret file: ${err.code === 'ENOENT' ? 'File not found' : 'Read error'}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Fall back to environment variable
|
|
148
|
+
const secret = process.env.MCP_OAUTH_CLIENT_SECRET;
|
|
149
|
+
if (!secret) {
|
|
150
|
+
console.error('Error: MCP_OAUTH_CLIENT_SECRET or MCP_OAUTH_CLIENT_SECRET_FILE must be set');
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
debugLog('Using secret from environment variable');
|
|
154
|
+
return secret;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!mcpUrl || !tokenEndpoint || !clientId) {
|
|
39
158
|
console.error('Usage: npx @agentmailbox/mcp-auth <mcp-url> <token-endpoint> [scope]');
|
|
40
159
|
console.error('');
|
|
41
160
|
console.error('Arguments:');
|
|
@@ -43,9 +162,160 @@ if (!mcpUrl || !tokenEndpoint || !clientId || !clientSecret) {
|
|
|
43
162
|
console.error(' token-endpoint The OAuth2 token endpoint (e.g., https://auth.agentmailbox.to/oauth2/token)');
|
|
44
163
|
console.error(' scope Optional OAuth2 scope (default: mcp-api/email.full)');
|
|
45
164
|
console.error('');
|
|
46
|
-
console.error('Environment variables
|
|
47
|
-
console.error(' MCP_OAUTH_CLIENT_ID
|
|
48
|
-
console.error(' MCP_OAUTH_CLIENT_SECRET
|
|
165
|
+
console.error('Environment variables:');
|
|
166
|
+
console.error(' MCP_OAUTH_CLIENT_ID Your OAuth2 client ID (required)');
|
|
167
|
+
console.error(' MCP_OAUTH_CLIENT_SECRET Your OAuth2 client secret');
|
|
168
|
+
console.error(' MCP_OAUTH_CLIENT_SECRET_FILE Path to file containing client secret (more secure)');
|
|
169
|
+
console.error(' MCP_DISABLE_CERT_PINNING Set to "true" to disable certificate pinning (for corporate proxies)');
|
|
170
|
+
console.error(' MCP_DEBUG Set to "true" to enable debug logging (never logs secrets)');
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
debugLog('Starting mcp-auth', {
|
|
175
|
+
mcpUrl,
|
|
176
|
+
tokenEndpoint,
|
|
177
|
+
scope: scope || 'mcp-api/email.full',
|
|
178
|
+
certPinning: !DISABLE_CERT_PINNING,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Get secret after argument validation
|
|
182
|
+
const clientSecret = getClientSecret();
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Validate that a URL uses HTTPS (or localhost for development).
|
|
186
|
+
* Prevents sending credentials over insecure connections.
|
|
187
|
+
*/
|
|
188
|
+
function isSecureEndpoint(url) {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = new URL(url);
|
|
191
|
+
|
|
192
|
+
// HTTPS is always allowed
|
|
193
|
+
if (parsed.protocol === 'https:') {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// HTTP only allowed for true localhost (not localhost.attacker.com)
|
|
198
|
+
if (parsed.protocol === 'http:') {
|
|
199
|
+
return LOCALHOST_HOSTS.has(parsed.hostname.toLowerCase());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return false;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Validate URL format to prevent injection attacks.
|
|
210
|
+
*/
|
|
211
|
+
function isValidUrl(url) {
|
|
212
|
+
try {
|
|
213
|
+
const parsed = new URL(url);
|
|
214
|
+
// Only allow http/https protocols
|
|
215
|
+
return ['http:', 'https:'].includes(parsed.protocol);
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Check if a domain should have certificate pinning.
|
|
223
|
+
*/
|
|
224
|
+
function shouldPinCertificate(hostname) {
|
|
225
|
+
if (DISABLE_CERT_PINNING) return false;
|
|
226
|
+
return PINNED_DOMAINS.has(hostname.toLowerCase());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Verify certificate against pinned public keys.
|
|
231
|
+
* Uses SPKI (Subject Public Key Info) pinning which survives cert renewals.
|
|
232
|
+
*/
|
|
233
|
+
function verifyCertificatePin(cert, hostname) {
|
|
234
|
+
if (!shouldPinCertificate(hostname)) {
|
|
235
|
+
debugLog('Certificate pinning skipped', { hostname, reason: DISABLE_CERT_PINNING ? 'disabled' : 'not in pinned domains' });
|
|
236
|
+
return; // No pinning required for this domain
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
debugLog('Verifying certificate pin', { hostname });
|
|
240
|
+
|
|
241
|
+
// Get the certificate chain, tracking seen certs to prevent any cycle
|
|
242
|
+
const certChain = [];
|
|
243
|
+
const seenCerts = new Set();
|
|
244
|
+
let currentCert = cert;
|
|
245
|
+
while (currentCert) {
|
|
246
|
+
// Break if we've seen this certificate before (any cycle, not just back to root)
|
|
247
|
+
if (seenCerts.has(currentCert)) break;
|
|
248
|
+
seenCerts.add(currentCert);
|
|
249
|
+
certChain.push(currentCert);
|
|
250
|
+
const issuer = currentCert.issuerCertificate;
|
|
251
|
+
// Break if no issuer or self-signed (issuer points to itself)
|
|
252
|
+
if (!issuer || issuer === currentCert) break;
|
|
253
|
+
currentCert = issuer;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Check if any certificate in the chain matches our pinned keys
|
|
257
|
+
for (const c of certChain) {
|
|
258
|
+
if (c.pubkey) {
|
|
259
|
+
const spkiHash = createHash('sha256').update(c.pubkey).digest('base64');
|
|
260
|
+
if (PINNED_PUBLIC_KEYS.has(spkiHash)) {
|
|
261
|
+
debugLog('Certificate pin verified successfully');
|
|
262
|
+
return; // Valid pin found
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
throw new Error(`Certificate pinning failed for ${hostname}. None of the certificates in the chain match pinned public keys.`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Verify expected security headers are present in the response.
|
|
272
|
+
*/
|
|
273
|
+
function verifySecurityHeaders(headers, hostname) {
|
|
274
|
+
// Only verify for AgentMailbox domains
|
|
275
|
+
if (!PINNED_DOMAINS.has(hostname.toLowerCase())) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const warnings = [];
|
|
280
|
+
|
|
281
|
+
for (const [header, expectedValue] of Object.entries(EXPECTED_SECURITY_HEADERS)) {
|
|
282
|
+
const actualValue = headers[header];
|
|
283
|
+
|
|
284
|
+
if (expectedValue === true) {
|
|
285
|
+
// Just check presence
|
|
286
|
+
if (!actualValue) {
|
|
287
|
+
warnings.push(`Missing security header: ${header}`);
|
|
288
|
+
}
|
|
289
|
+
} else if (actualValue !== expectedValue) {
|
|
290
|
+
warnings.push(`Unexpected ${header} value: expected "${expectedValue}", got "${actualValue || 'missing'}"`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (warnings.length > 0) {
|
|
295
|
+
debugLog('Security header warnings', { warnings });
|
|
296
|
+
// Don't fail, just log warnings in debug mode
|
|
297
|
+
} else {
|
|
298
|
+
debugLog('Security headers verified');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!isValidUrl(tokenEndpoint)) {
|
|
303
|
+
console.error('Error: Invalid token-endpoint URL format');
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!isValidUrl(mcpUrl)) {
|
|
308
|
+
console.error('Error: Invalid mcp-url URL format');
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!isSecureEndpoint(tokenEndpoint)) {
|
|
313
|
+
console.error('Error: token-endpoint must use HTTPS (localhost allowed for development)');
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!isSecureEndpoint(mcpUrl)) {
|
|
318
|
+
console.error('Error: mcp-url must use HTTPS (localhost allowed for development)');
|
|
49
319
|
process.exit(1);
|
|
50
320
|
}
|
|
51
321
|
|
|
@@ -60,12 +330,17 @@ function buildChildEnv(env) {
|
|
|
60
330
|
// Temp directories
|
|
61
331
|
'TMP', 'TEMP', 'TMPDIR',
|
|
62
332
|
// Windows system variables
|
|
63
|
-
'SystemRoot', 'ComSpec', 'WINDIR', 'PATHEXT',
|
|
333
|
+
'SystemRoot', 'ComSpec', 'WINDIR', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA',
|
|
64
334
|
// Proxy configuration
|
|
65
335
|
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
|
|
66
336
|
'http_proxy', 'https_proxy', 'no_proxy',
|
|
67
337
|
// Node.js configuration
|
|
68
338
|
'NODE_EXTRA_CA_CERTS', 'NODE_OPTIONS',
|
|
339
|
+
// npm/npx configuration (for custom registries and cache in enterprise environments)
|
|
340
|
+
'NPM_CONFIG_REGISTRY', 'npm_config_registry',
|
|
341
|
+
'NPM_CONFIG_USERCONFIG', 'npm_config_userconfig',
|
|
342
|
+
'NPM_CONFIG_CACHE', 'npm_config_cache',
|
|
343
|
+
'NPM_CONFIG_PREFIX', 'npm_config_prefix',
|
|
69
344
|
// mcp-remote configuration directory
|
|
70
345
|
'MCP_REMOTE_CONFIG_DIR',
|
|
71
346
|
];
|
|
@@ -74,6 +349,137 @@ function buildChildEnv(env) {
|
|
|
74
349
|
);
|
|
75
350
|
}
|
|
76
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Validate that a string looks like a JWT token.
|
|
354
|
+
* JWTs have three base64url-encoded parts separated by dots.
|
|
355
|
+
*/
|
|
356
|
+
function isValidJwtFormat(token) {
|
|
357
|
+
if (typeof token !== 'string') return false;
|
|
358
|
+
const parts = token.split('.');
|
|
359
|
+
if (parts.length !== 3) return false;
|
|
360
|
+
|
|
361
|
+
// Check each part is valid base64url (alphanumeric, -, _)
|
|
362
|
+
const base64urlRegex = /^[A-Za-z0-9_-]+$/;
|
|
363
|
+
return parts.every(part => part.length > 0 && base64urlRegex.test(part));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Sanitize error messages to prevent information leakage.
|
|
368
|
+
* Defensively handles non-string inputs to avoid secondary failures.
|
|
369
|
+
*/
|
|
370
|
+
function sanitizeErrorMessage(message) {
|
|
371
|
+
// Safely coerce input to string
|
|
372
|
+
const safeMessage = typeof message === 'string' ? message : String(message ?? '');
|
|
373
|
+
// Remove potential sensitive data patterns
|
|
374
|
+
return safeMessage
|
|
375
|
+
.substring(0, 200) // Limit length
|
|
376
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [REDACTED]')
|
|
377
|
+
.replace(/client_secret[^&]*/gi, 'client_secret=[REDACTED]')
|
|
378
|
+
.replace(/access_token[^&]*/gi, 'access_token=[REDACTED]');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Sleep for a given number of milliseconds.
|
|
383
|
+
*/
|
|
384
|
+
function sleep(ms) {
|
|
385
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Calculate exponential backoff delay with jitter.
|
|
390
|
+
*/
|
|
391
|
+
function calculateBackoffDelay(attempt) {
|
|
392
|
+
const exponentialDelay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
393
|
+
const cappedDelay = Math.min(exponentialDelay, MAX_RETRY_DELAY_MS);
|
|
394
|
+
// Add jitter (0-25% of delay) to prevent thundering herd
|
|
395
|
+
const jitter = cappedDelay * Math.random() * 0.25;
|
|
396
|
+
return Math.floor(cappedDelay + jitter);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Make HTTPS request with certificate pinning support.
|
|
401
|
+
*/
|
|
402
|
+
function makeRequest(urlString, options, body) {
|
|
403
|
+
return new Promise((resolve, reject) => {
|
|
404
|
+
const url = new URL(urlString);
|
|
405
|
+
const isHttps = url.protocol === 'https:';
|
|
406
|
+
const requestFn = isHttps ? request : httpRequest;
|
|
407
|
+
|
|
408
|
+
const reqOptions = {
|
|
409
|
+
hostname: url.hostname,
|
|
410
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
411
|
+
path: url.pathname + url.search,
|
|
412
|
+
method: options.method || 'GET',
|
|
413
|
+
headers: options.headers || {},
|
|
414
|
+
timeout: options.timeout || TOKEN_TIMEOUT_MS,
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// Add certificate pinning during TLS handshake (before credentials are sent)
|
|
418
|
+
if (isHttps) {
|
|
419
|
+
reqOptions.checkServerIdentity = (host, cert) => {
|
|
420
|
+
// First, perform standard hostname verification
|
|
421
|
+
const hostnameError = tlsCheckServerIdentity(host, cert);
|
|
422
|
+
if (hostnameError) return hostnameError;
|
|
423
|
+
// Then verify certificate pinning
|
|
424
|
+
try {
|
|
425
|
+
verifyCertificatePin(cert, host);
|
|
426
|
+
return undefined; // Success
|
|
427
|
+
} catch (e) {
|
|
428
|
+
return e instanceof Error ? e : new Error('Certificate pinning failed');
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
debugLog('Making request', { hostname: url.hostname, method: reqOptions.method, path: reqOptions.path });
|
|
434
|
+
|
|
435
|
+
const req = requestFn(reqOptions, (res) => {
|
|
436
|
+
// Verify security headers
|
|
437
|
+
verifySecurityHeaders(res.headers, url.hostname);
|
|
438
|
+
|
|
439
|
+
let data = '';
|
|
440
|
+
let receivedLength = 0;
|
|
441
|
+
|
|
442
|
+
res.on('data', (chunk) => {
|
|
443
|
+
receivedLength += chunk.length;
|
|
444
|
+
if (receivedLength > MAX_TOKEN_RESPONSE_SIZE) {
|
|
445
|
+
req.destroy();
|
|
446
|
+
reject(new Error('Token response exceeded maximum allowed size'));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
data += chunk;
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
res.on('end', () => {
|
|
453
|
+
debugLog('Request completed', { status: res.statusCode, dataLength: data.length });
|
|
454
|
+
resolve({
|
|
455
|
+
status: res.statusCode,
|
|
456
|
+
headers: res.headers,
|
|
457
|
+
data,
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
req.on('error', (err) => {
|
|
463
|
+
debugLog('Request error', { error: err.message });
|
|
464
|
+
reject(new Error('Failed to connect to token endpoint'));
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
req.on('timeout', () => {
|
|
468
|
+
req.destroy();
|
|
469
|
+
debugLog('Request timeout');
|
|
470
|
+
reject(new Error(`Token request timed out after ${TOKEN_TIMEOUT_MS}ms`));
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
if (body) {
|
|
474
|
+
req.write(body);
|
|
475
|
+
}
|
|
476
|
+
req.end();
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Get access token with exponential backoff retry logic.
|
|
482
|
+
*/
|
|
77
483
|
async function getAccessToken() {
|
|
78
484
|
const params = new URLSearchParams({
|
|
79
485
|
grant_type: 'client_credentials',
|
|
@@ -82,38 +488,73 @@ async function getAccessToken() {
|
|
|
82
488
|
scope: scope || 'mcp-api/email.full',
|
|
83
489
|
});
|
|
84
490
|
|
|
85
|
-
|
|
86
|
-
const controller = new AbortController();
|
|
87
|
-
const timeoutId = setTimeout(() => controller.abort(), TOKEN_TIMEOUT_MS);
|
|
491
|
+
let lastError;
|
|
88
492
|
|
|
89
|
-
let
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
body: params.toString(),
|
|
95
|
-
signal: controller.signal,
|
|
96
|
-
});
|
|
97
|
-
} catch (err) {
|
|
98
|
-
clearTimeout(timeoutId);
|
|
99
|
-
if (err.name === 'AbortError') {
|
|
100
|
-
throw new Error(`Token request timed out after ${TOKEN_TIMEOUT_MS}ms`);
|
|
493
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
494
|
+
if (attempt > 0) {
|
|
495
|
+
const delay = calculateBackoffDelay(attempt - 1);
|
|
496
|
+
debugLog(`Retry attempt ${attempt}/${MAX_RETRIES}`, { delayMs: delay });
|
|
497
|
+
await sleep(delay);
|
|
101
498
|
}
|
|
102
|
-
throw err;
|
|
103
|
-
} finally {
|
|
104
|
-
clearTimeout(timeoutId);
|
|
105
|
-
}
|
|
106
499
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
500
|
+
try {
|
|
501
|
+
const response = await makeRequest(tokenEndpoint, {
|
|
502
|
+
method: 'POST',
|
|
503
|
+
headers: {
|
|
504
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
505
|
+
'Content-Length': Buffer.byteLength(params.toString()),
|
|
506
|
+
},
|
|
507
|
+
timeout: TOKEN_TIMEOUT_MS,
|
|
508
|
+
}, params.toString());
|
|
111
509
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
510
|
+
// Don't retry on client errors (4xx) - these won't succeed on retry
|
|
511
|
+
if (response.status >= 400 && response.status < 500) {
|
|
512
|
+
debugLog('Client error, not retrying', { status: response.status });
|
|
513
|
+
throw new Error(`Token request failed with status ${response.status}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Retry on server errors (5xx) or network issues
|
|
517
|
+
if (response.status !== 200) {
|
|
518
|
+
throw new Error(`Token request failed with status ${response.status}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let data;
|
|
522
|
+
try {
|
|
523
|
+
data = JSON.parse(response.data);
|
|
524
|
+
} catch {
|
|
525
|
+
throw new Error('Invalid JSON response from token endpoint');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!data || typeof data.access_token !== 'string' || data.access_token.trim() === '') {
|
|
529
|
+
throw new Error('Missing access_token in response');
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const token = data.access_token.trim();
|
|
533
|
+
|
|
534
|
+
// Validate token format (should be JWT)
|
|
535
|
+
if (!isValidJwtFormat(token)) {
|
|
536
|
+
throw new Error('Invalid token format received');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
debugLog('Access token obtained successfully');
|
|
540
|
+
return token;
|
|
541
|
+
|
|
542
|
+
} catch (err) {
|
|
543
|
+
lastError = err;
|
|
544
|
+
debugLog('Token request failed', { attempt, error: err.message });
|
|
545
|
+
|
|
546
|
+
// Don't retry on certain errors
|
|
547
|
+
if (err.message.includes('Certificate pinning failed') ||
|
|
548
|
+
err.message.includes('Invalid token format') ||
|
|
549
|
+
err.message.includes('Missing access_token') ||
|
|
550
|
+
err.message.includes('status 4')) {
|
|
551
|
+
throw err;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
115
554
|
}
|
|
116
|
-
|
|
555
|
+
|
|
556
|
+
// All retries exhausted
|
|
557
|
+
throw lastError || new Error('Failed to obtain access token after retries');
|
|
117
558
|
}
|
|
118
559
|
|
|
119
560
|
async function main() {
|
|
@@ -123,25 +564,33 @@ async function main() {
|
|
|
123
564
|
// Build minimal environment with only necessary variables
|
|
124
565
|
const childEnv = buildChildEnv(process.env);
|
|
125
566
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
567
|
+
// Add token to child environment for mcp-remote to use
|
|
568
|
+
// This avoids exposing the token in process arguments
|
|
569
|
+
childEnv.MCP_HEADER_Authorization = `Bearer ${token}`;
|
|
570
|
+
|
|
571
|
+
debugLog('Launching mcp-remote', { version: MCP_REMOTE_VERSION, mcpUrl });
|
|
572
|
+
|
|
573
|
+
// Launch mcp-remote with pinned version
|
|
574
|
+
// Token is passed via environment variable, not command line args
|
|
128
575
|
const child = spawn('npx', [
|
|
129
576
|
'-y',
|
|
130
|
-
|
|
577
|
+
`mcp-remote@${MCP_REMOTE_VERSION}`,
|
|
131
578
|
mcpUrl,
|
|
132
579
|
'--header',
|
|
133
|
-
|
|
580
|
+
'Authorization:${MCP_HEADER_Authorization}',
|
|
134
581
|
], {
|
|
135
582
|
stdio: 'inherit',
|
|
136
583
|
env: childEnv,
|
|
137
584
|
});
|
|
138
585
|
|
|
139
586
|
child.on('error', (err) => {
|
|
140
|
-
|
|
587
|
+
const rawMessage = err instanceof Error ? err.message : err;
|
|
588
|
+
console.error('Failed to start mcp-remote:', sanitizeErrorMessage(rawMessage));
|
|
141
589
|
process.exit(1);
|
|
142
590
|
});
|
|
143
591
|
|
|
144
592
|
child.on('exit', (code, signal) => {
|
|
593
|
+
debugLog('mcp-remote exited', { code, signal });
|
|
145
594
|
if (code !== null) {
|
|
146
595
|
process.exit(code);
|
|
147
596
|
} else if (signal !== null) {
|
|
@@ -154,6 +603,7 @@ async function main() {
|
|
|
154
603
|
|
|
155
604
|
// Forward signals to child process for graceful shutdown
|
|
156
605
|
const forwardSignal = (sig) => {
|
|
606
|
+
debugLog('Forwarding signal to child', { signal: sig });
|
|
157
607
|
if (child.pid) {
|
|
158
608
|
child.kill(sig);
|
|
159
609
|
}
|
|
@@ -161,7 +611,8 @@ async function main() {
|
|
|
161
611
|
process.on('SIGINT', () => forwardSignal('SIGINT'));
|
|
162
612
|
process.on('SIGTERM', () => forwardSignal('SIGTERM'));
|
|
163
613
|
} catch (err) {
|
|
164
|
-
|
|
614
|
+
const rawMessage = err instanceof Error ? err.message : err;
|
|
615
|
+
console.error('Error:', sanitizeErrorMessage(rawMessage));
|
|
165
616
|
process.exit(1);
|
|
166
617
|
}
|
|
167
618
|
}
|