@mhingston5/conduit 1.1.7 → 1.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhingston5/conduit",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "type": "module",
5
5
  "description": "A secure Code Mode execution substrate for MCP agents",
6
6
  "main": "index.js",
@@ -60,6 +60,7 @@
60
60
  "p-limit": "^7.2.0",
61
61
  "pino": "^10.1.0",
62
62
  "pyodide": "^0.29.0",
63
+ "undici": "^7.18.2",
63
64
  "uuid": "^13.0.0",
64
65
  "zod": "^4.3.5"
65
66
  },
@@ -12,10 +12,9 @@ export class AuthMiddleware implements Middleware {
12
12
  next: NextFunction
13
13
  ): Promise<JSONRPCResponse | null> {
14
14
  const providedToken = request.auth?.bearerToken || '';
15
- const masterToken = this.securityService.getIpcToken();
16
15
 
17
16
  // If no master token is set (stdio mode), treat all requests as master (auth disabled)
18
- const isMaster = !masterToken || providedToken === masterToken;
17
+ const isMaster = this.securityService.isMasterToken(providedToken);
19
18
  const isSession = !isMaster && this.securityService.validateIpcToken(providedToken);
20
19
 
21
20
  if (!isMaster && !isSession) {
@@ -38,16 +38,16 @@ export class SecurityService implements IUrlValidator {
38
38
  return this.networkPolicy.checkRateLimit(key);
39
39
  }
40
40
 
41
- validateIpcToken(token: string): boolean {
42
- // Fix Sev1: Use timing-safe comparison for sensitive tokens
43
- if (!this.ipcToken) {
44
- return true;
45
- }
46
-
41
+ isMasterToken(token: string): boolean {
42
+ if (!this.ipcToken) return true;
47
43
  const expected = Buffer.from(this.ipcToken);
48
- const actual = Buffer.from(token);
44
+ const actual = Buffer.from(token || '');
45
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
46
+ }
49
47
 
50
- if (expected.length === actual.length && crypto.timingSafeEqual(expected, actual)) {
48
+ validateIpcToken(token: string): boolean {
49
+ // Fix Sev1: Use timing-safe comparison for sensitive tokens
50
+ if (this.isMasterToken(token)) {
51
51
  return true;
52
52
  }
53
53
 
@@ -44,33 +44,36 @@ export class IsolateExecutor implements Executor {
44
44
 
45
45
  let currentLogBytes = 0;
46
46
  let currentErrorBytes = 0;
47
+ let totalLogEntries = 0;
47
48
 
48
- // Inject console.log/error for output capture
49
49
  // Inject console.log/error for output capture
50
50
  await jail.set('__log', new ivm.Callback((msg: string) => {
51
+ if (totalLogEntries + 1 > limits.maxLogEntries) {
52
+ throw new Error('[LIMIT_LOG_ENTRIES]');
53
+ }
51
54
  if (currentLogBytes + msg.length + 1 > limits.maxOutputBytes) {
52
- // Check log entry count limit? We don't track count here yet effectively, but bytes is safer.
53
- // The interface says maxOutputBytes applies to total output.
54
55
  throw new Error('[LIMIT_LOG]');
55
56
  }
56
- if (currentLogBytes < limits.maxOutputBytes) {
57
- logs.push(msg);
58
- currentLogBytes += msg.length + 1; // +1 for newline approximation
59
- }
57
+
58
+ totalLogEntries++;
59
+ logs.push(msg);
60
+ currentLogBytes += msg.length + 1; // +1 for newline approximation
60
61
  }));
61
62
  await jail.set('__error', new ivm.Callback((msg: string) => {
63
+ if (totalLogEntries + 1 > limits.maxLogEntries) {
64
+ throw new Error('[LIMIT_LOG_ENTRIES]');
65
+ }
62
66
  if (currentErrorBytes + msg.length + 1 > limits.maxOutputBytes) {
63
67
  throw new Error('[LIMIT_OUTPUT]');
64
68
  }
65
- if (currentErrorBytes < limits.maxOutputBytes) {
66
- errors.push(msg);
67
- currentErrorBytes += msg.length + 1;
68
- }
69
+
70
+ totalLogEntries++;
71
+ errors.push(msg);
72
+ currentErrorBytes += msg.length + 1;
69
73
  }));
70
74
 
71
75
  // Async tool bridge (ID-based to avoid Promise transfer issues)
72
76
  let requestIdCounter = 0;
73
- const pendingToolCalls = new Map<number, Promise<any>>(); // Not used by Host, but Host initiates work
74
77
 
75
78
  await jail.set('__dispatchToolCall', new ivm.Callback((nameStr: string, argsStr: string) => {
76
79
  const requestId = ++requestIdCounter;
@@ -244,6 +247,30 @@ export class IsolateExecutor implements Executor {
244
247
  };
245
248
  }
246
249
 
250
+ if (message.includes('[LIMIT_LOG_ENTRIES]')) {
251
+ return {
252
+ stdout: logs.join('\n'),
253
+ stderr: errors.join('\n'),
254
+ exitCode: null,
255
+ error: {
256
+ code: ConduitError.LogLimitExceeded,
257
+ message: 'Log entry limit exceeded',
258
+ },
259
+ };
260
+ }
261
+
262
+ if (message.includes('[LIMIT_LOG]') || message.includes('[LIMIT_OUTPUT]')) {
263
+ return {
264
+ stdout: logs.join('\n'),
265
+ stderr: errors.join('\n'),
266
+ exitCode: null,
267
+ error: {
268
+ code: ConduitError.OutputLimitExceeded,
269
+ message: 'Output limit exceeded',
270
+ },
271
+ };
272
+ }
273
+
247
274
  this.logger.error({ err }, 'Isolate execution failed');
248
275
  return {
249
276
  stdout: logs.join('\n'),
@@ -10,6 +10,9 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
10
10
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
11
11
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
12
12
  import { z } from 'zod';
13
+ import dns from 'node:dns';
14
+ import net from 'node:net';
15
+ import { Agent } from 'undici';
13
16
 
14
17
  export type UpstreamInfo = {
15
18
  id: string;
@@ -30,6 +33,10 @@ export class UpstreamClient {
30
33
  private transport?: StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport;
31
34
  private connected: boolean = false;
32
35
 
36
+ // Pinned-IP dispatchers per upstream origin (defends against DNS rebinding)
37
+ private dispatcherCache = new Map<string, { resolvedIp: string; agent: Agent }>();
38
+ private pinned?: { origin: string; hostname: string; resolvedIp?: string };
39
+
33
40
  constructor(logger: Logger, info: UpstreamInfo, authService: AuthService, urlValidator: IUrlValidator) {
34
41
  this.logger = logger.child({ upstreamId: info.id });
35
42
  this.info = info;
@@ -59,7 +66,10 @@ export class UpstreamClient {
59
66
  }
60
67
 
61
68
  if (this.info.type === 'streamableHttp') {
62
- this.transport = new StreamableHTTPClientTransport(new URL(this.info.url), {
69
+ const upstreamUrl = new URL(this.info.url);
70
+ this.pinned = { origin: upstreamUrl.origin, hostname: upstreamUrl.hostname };
71
+
72
+ this.transport = new StreamableHTTPClientTransport(upstreamUrl, {
63
73
  fetch: this.createAuthedFetch(),
64
74
  });
65
75
  this.mcpClient = new Client({
@@ -72,6 +82,9 @@ export class UpstreamClient {
72
82
  }
73
83
 
74
84
  if (this.info.type === 'sse') {
85
+ const upstreamUrl = new URL(this.info.url);
86
+ this.pinned = { origin: upstreamUrl.origin, hostname: upstreamUrl.hostname };
87
+
75
88
  this.mcpClient = new Client({
76
89
  name: 'conduit-gateway',
77
90
  version: '1.0.0',
@@ -81,17 +94,89 @@ export class UpstreamClient {
81
94
  }
82
95
  }
83
96
 
97
+ private getDispatcher(origin: string, hostname: string, resolvedIp: string): Agent {
98
+ const existing = this.dispatcherCache.get(origin);
99
+ if (existing && existing.resolvedIp === resolvedIp) {
100
+ return existing.agent;
101
+ }
102
+
103
+ if (existing) {
104
+ try {
105
+ existing.agent.close();
106
+ } catch {
107
+ // ignore
108
+ }
109
+ }
110
+
111
+ const agent = new Agent({
112
+ connect: {
113
+ lookup: (lookupHostname: string, options: any, callback: any) => {
114
+ if (lookupHostname === hostname) {
115
+ callback(null, resolvedIp, net.isIP(resolvedIp));
116
+ return;
117
+ }
118
+ dns.lookup(lookupHostname, options, callback);
119
+ },
120
+ },
121
+ });
122
+
123
+ this.dispatcherCache.set(origin, { resolvedIp, agent });
124
+ return agent;
125
+ }
126
+
84
127
  private createAuthedFetch() {
85
128
  const creds = this.info.credentials;
86
- if (!creds) return fetch;
129
+ const pinned = this.pinned;
130
+
131
+ // Fall back to global fetch
132
+ const baseFetch = fetch;
87
133
 
88
134
  return async (input: any, init: any = {}) => {
89
- const headers = new Headers(init.headers || {});
90
- const authHeaders = await this.authService.getAuthHeaders(creds);
91
- for (const [k, v] of Object.entries(authHeaders)) {
92
- headers.set(k, v);
135
+ const requestUrlStr = (() => {
136
+ if (typeof input === 'string') return input;
137
+ if (input instanceof URL) return input.toString();
138
+ if (input instanceof Request) return input.url;
139
+ return String(input);
140
+ })();
141
+
142
+ const requestUrl = pinned
143
+ ? new URL(requestUrlStr, pinned.origin)
144
+ : new URL(requestUrlStr);
145
+
146
+ // Hard safety boundary: never allow fetch to escape upstream origin
147
+ if (pinned && requestUrl.origin !== pinned.origin) {
148
+ throw new Error(`Forbidden upstream redirect/origin: ${requestUrl.origin}`);
149
+ }
150
+
151
+ // Validate and (optionally) pin resolved IP for DNS-rebinding defense
152
+ if (pinned && !pinned.resolvedIp) {
153
+ const securityResult = await this.urlValidator.validateUrl(pinned.origin);
154
+ if (!securityResult.valid) {
155
+ throw new Error(securityResult.message || 'Forbidden URL');
156
+ }
157
+ pinned.resolvedIp = securityResult.resolvedIp;
158
+ }
159
+
160
+ const headers = new Headers((input instanceof Request ? input.headers : undefined) || undefined);
161
+ const initHeaders = new Headers(init.headers || {});
162
+ for (const [k, v] of initHeaders.entries()) headers.set(k, v);
163
+
164
+ if (creds) {
165
+ const authHeaders = await this.authService.getAuthHeaders(creds);
166
+ for (const [k, v] of Object.entries(authHeaders)) {
167
+ headers.set(k, v);
168
+ }
93
169
  }
94
- return fetch(input, { ...init, headers });
170
+
171
+ const request = input instanceof Request
172
+ ? new Request(input, { ...init, headers, redirect: init.redirect ?? 'manual' })
173
+ : new Request(requestUrl.toString(), { ...init, headers, redirect: init.redirect ?? 'manual' });
174
+
175
+ const dispatcher = (pinned && pinned.resolvedIp)
176
+ ? this.getDispatcher(pinned.origin, pinned.hostname, pinned.resolvedIp)
177
+ : undefined;
178
+
179
+ return baseFetch(request, dispatcher ? { dispatcher } : undefined);
95
180
  };
96
181
  }
97
182
 
@@ -119,6 +204,9 @@ export class UpstreamClient {
119
204
  this.logger.error({ url: this.info.url }, 'Blocked upstream URL (SSRF)');
120
205
  throw new Error(securityResult.message || 'Forbidden URL');
121
206
  }
207
+ if (this.pinned) {
208
+ this.pinned.resolvedIp = securityResult.resolvedIp;
209
+ }
122
210
  }
123
211
 
124
212
  try {
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { createRequire } from 'node:module';
3
4
  import { ConfigService } from './core/config.service.js';
4
5
  import { createLogger, loggerStorage } from './core/logger.js';
5
6
  import { SocketTransport } from './transport/socket.transport.js';
@@ -20,10 +21,13 @@ import { handleAuth } from './auth.cmd.js';
20
21
 
21
22
  const program = new Command();
22
23
 
24
+ const require = createRequire(import.meta.url);
25
+ const pkg = require('../package.json') as { version?: string };
26
+
23
27
  program
24
28
  .name('conduit')
25
29
  .description('A secure Code Mode execution substrate for MCP agents')
26
- .version('1.0.0');
30
+ .version(pkg.version || '0.0.0');
27
31
 
28
32
  program
29
33
  .command('serve', { isDefault: true })
@@ -15,6 +15,7 @@ describe('Middleware Tests', () => {
15
15
  validateToken: vi.fn(),
16
16
  checkRateLimit: vi.fn(),
17
17
  getIpcToken: vi.fn(),
18
+ isMasterToken: vi.fn(),
18
19
  validateIpcToken: vi.fn(),
19
20
  getSession: vi.fn(),
20
21
  };
@@ -39,8 +40,7 @@ describe('Middleware Tests', () => {
39
40
  authMiddleware = new AuthMiddleware(mockSecurityService as SecurityService);
40
41
  });
41
42
 
42
- it('should validate bearer token', () => {
43
- mockSecurityService.validateToken.mockReturnValue(true);
43
+ it('should validate bearer token', async () => {
44
44
  const request = {
45
45
  jsonrpc: '2.0',
46
46
  id: 1,
@@ -48,24 +48,27 @@ describe('Middleware Tests', () => {
48
48
  auth: { bearerToken: 'valid-token' }
49
49
  };
50
50
 
51
- mockSecurityService.getIpcToken.mockReturnValue('master-token');
51
+ // Not master and not a valid session -> Forbidden
52
+ mockSecurityService.isMasterToken.mockReturnValue(false);
52
53
  mockSecurityService.validateIpcToken.mockReturnValue(false);
53
- // Mock validateToken behavior via logic or specific mock if used, but AuthMiddleware uses getIpcToken/validateIpcToken
54
54
 
55
- authMiddleware.handle(request as any, context, mockNext);
55
+ const result1 = await authMiddleware.handle(request as any, context, mockNext);
56
+ expect(mockSecurityService.isMasterToken).toHaveBeenCalledWith('valid-token');
56
57
  expect(mockSecurityService.validateIpcToken).toHaveBeenCalledWith('valid-token');
57
- expect(mockNext).not.toHaveBeenCalled(); // Should fail because neither master nor session valid
58
- // Wait, logic says: isMaster = token === getIpcToken(). isSession = validateIpcToken() && !isMaster.
59
- // If valid-token is NOT master and NOT session, it returns 403.
58
+ expect(result1?.error?.code).toBe(ConduitError.Forbidden);
59
+ expect(mockNext).not.toHaveBeenCalled();
60
+
61
+ // Master token -> allowed
62
+ mockNext.mockClear();
63
+ mockSecurityService.isMasterToken.mockReturnValue(true);
60
64
 
61
- // Let's make it a master token to pass 'valid-token' test
62
- mockSecurityService.getIpcToken.mockReturnValue('valid-token');
63
- authMiddleware.handle(request as any, context, mockNext);
65
+ const result2 = await authMiddleware.handle(request as any, context, mockNext);
66
+ expect(result2?.error).toBeUndefined();
64
67
  expect(mockNext).toHaveBeenCalled();
65
68
  });
66
69
 
67
70
  it('should throw Forbidden if token is invalid', async () => {
68
- mockSecurityService.getIpcToken.mockReturnValue('master-token');
71
+ mockSecurityService.isMasterToken.mockReturnValue(false);
69
72
  mockSecurityService.validateIpcToken.mockReturnValue(false);
70
73
 
71
74
  const request = {
@@ -76,7 +79,7 @@ describe('Middleware Tests', () => {
76
79
  };
77
80
 
78
81
  const result = await authMiddleware.handle(request as any, context, mockNext);
79
- expect(result.error?.code).toBe(ConduitError.Forbidden);
82
+ expect(result?.error?.code).toBe(ConduitError.Forbidden);
80
83
  expect(mockNext).not.toHaveBeenCalled();
81
84
  });
82
85
  });
@@ -43,6 +43,7 @@ describe('RequestController Routing', () => {
43
43
  createSession: vi.fn().mockReturnValue('token'),
44
44
  invalidateSession: vi.fn(),
45
45
  getIpcToken: vi.fn().mockReturnValue('master-token'),
46
+ isMasterToken: vi.fn().mockReturnValue(true),
46
47
  validateIpcToken: vi.fn().mockReturnValue(true),
47
48
  getSession: vi.fn(),
48
49
  checkRateLimit: vi.fn().mockReturnValue(true),
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
3
  const mcpClientMocks = {
4
4
  connect: vi.fn(async () => undefined),
@@ -60,8 +60,15 @@ vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => {
60
60
  });
61
61
 
62
62
  describe('UpstreamClient (remote transports)', () => {
63
+ const originalFetch = globalThis.fetch;
64
+
63
65
  beforeEach(() => {
64
66
  vi.clearAllMocks();
67
+ globalThis.fetch = vi.fn(async () => new Response(null, { status: 200 })) as any;
68
+ });
69
+
70
+ afterEach(() => {
71
+ globalThis.fetch = originalFetch;
65
72
  });
66
73
 
67
74
  it('uses Streamable HTTP client transport for type=streamableHttp', async () => {
@@ -87,6 +94,38 @@ describe('UpstreamClient (remote transports)', () => {
87
94
  expect(res.result).toBeDefined();
88
95
  });
89
96
 
97
+ it('pins DNS resolution and blocks cross-origin fetches', async () => {
98
+ const { UpstreamClient } = await import('../src/gateway/upstream.client.js');
99
+
100
+ const logger: any = { child: () => logger, debug: vi.fn(), info: vi.fn(), error: vi.fn() };
101
+ const authService: any = { getAuthHeaders: vi.fn(async () => ({ Authorization: 'Bearer t' })) };
102
+ const urlValidator: any = { validateUrl: vi.fn(async () => ({ valid: true, resolvedIp: '93.184.216.34' })) };
103
+
104
+ const client = new UpstreamClient(
105
+ logger,
106
+ { id: 'atl', type: 'streamableHttp', url: 'https://mcp.atlassian.com/v1/sse' } as any,
107
+ authService,
108
+ urlValidator
109
+ );
110
+
111
+ // Trigger initial URL validation + pinning
112
+ await client.call({ jsonrpc: '2.0', id: '1', method: 'tools/list' } as any, { correlationId: 'c1' } as any);
113
+
114
+ const [, opts] = transportMocks.streamableHttpCtor.mock.calls[0];
115
+ const wrappedFetch = opts.fetch;
116
+
117
+ // Same-origin request should pass a dispatcher and block redirects
118
+ await wrappedFetch('https://mcp.atlassian.com/v1/sse', {});
119
+ expect((globalThis.fetch as any).mock.calls.length).toBeGreaterThan(0);
120
+ const [request, init] = (globalThis.fetch as any).mock.calls.at(-1);
121
+ expect(request).toBeInstanceOf(Request);
122
+ expect(request.redirect).toBe('manual');
123
+ expect(init?.dispatcher).toBeDefined();
124
+
125
+ // Cross-origin request should be blocked
126
+ await expect(wrappedFetch('https://evil.example.com/', {})).rejects.toThrow(/Forbidden upstream redirect\/origin/);
127
+ });
128
+
90
129
  it('lazily creates SSE transport for type=sse and attaches auth headers', async () => {
91
130
  const { UpstreamClient } = await import('../src/gateway/upstream.client.js');
92
131