@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/README.md +1 -1
- package/dist/index.js +124 -33
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/core/middleware/auth.middleware.ts +1 -2
- package/src/core/security.service.ts +8 -8
- package/src/executors/isolate.executor.ts +39 -12
- package/src/gateway/upstream.client.ts +95 -7
- package/src/index.ts +5 -1
- package/tests/middleware.test.ts +16 -13
- package/tests/routing.test.ts +1 -0
- package/tests/upstream.transports.test.ts +40 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mhingston5/conduit",
|
|
3
|
-
"version": "1.1.
|
|
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 =
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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('
|
|
30
|
+
.version(pkg.version || '0.0.0');
|
|
27
31
|
|
|
28
32
|
program
|
|
29
33
|
.command('serve', { isDefault: true })
|
package/tests/middleware.test.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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.
|
|
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
|
|
82
|
+
expect(result?.error?.code).toBe(ConduitError.Forbidden);
|
|
80
83
|
expect(mockNext).not.toHaveBeenCalled();
|
|
81
84
|
});
|
|
82
85
|
});
|
package/tests/routing.test.ts
CHANGED
|
@@ -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
|
|