@ni-c/mcp-hub 0.11.2 → 0.11.3

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/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  <!-- #region changelog -->
9
9
 
10
+ ## [0.11.3] - 2026-09-12
11
+
12
+ ### Security
13
+
14
+ - **A remote upstream could redirect the hub anywhere.** The MCP requests to
15
+ a remote server — `tools/call`, the event stream, `subscriptions/listen` —
16
+ went out with the platform's default of following every redirect, and
17
+ neither the SDK's transports nor the hub's own fetch wrappers said
18
+ otherwise. An upstream answering with a `Location` on an internal address
19
+ had the hub connect there, send the JSON-RPC body and every configured
20
+ header except `Authorization` and `Cookie`, and read the answer as MCP. The
21
+ guard the authorization server always had now covers the data plane too: a
22
+ redirect is followed only within the origin of the configured `url` (same
23
+ scheme, host and port — `/mcp` to `/mcp/` keeps working), at most three
24
+ times, with the platform's method and body rules; anything else fails the
25
+ request with a reason that names the refused origin and nothing more.
26
+ Reported by the 2026-09-12 internal review.
27
+
28
+ ### Fixed
29
+
30
+ - **A peer could make the hub copy its input quadratically.** The byte-stream
31
+ transports — `type: "unix"`, `"tcp"` and the docker attach stream — appended
32
+ every chunk to one growing buffer, so a line or a frame delivered in small
33
+ pieces cost the event loop a copy of everything so far, per piece: ten
34
+ megabytes in 4 KiB pieces took close to a second, a 16 MiB docker frame two
35
+ and a half, and a peer chooses its piece size. Both decoders now collect the
36
+ pieces and join them once, which is linear; the existing caps (10 MiB per
37
+ line, 16 MiB per frame) are unchanged, and a property test holds the framing
38
+ identical however the bytes are cut.
39
+ - **A remote server that failed to connect was logged as "connection closed".**
40
+ The SDK closes the transport before `connect()` rejects, so the generic
41
+ close reason won the race and the rejection — which names the cause and may
42
+ carry a verdict no restart can fix — was thrown away. The exit is now
43
+ reported once, from the rejection, so a refused redirect, a TLS failure or
44
+ an unauthorized upstream reads as what it is.
45
+
10
46
  ## [0.11.2] - 2026-09-07
11
47
 
12
48
  ### Security
@@ -9,6 +9,7 @@ import { DockerTransport } from './transports/docker.js';
9
9
  import { setTransportHandlers } from './transports/stream.js';
10
10
  import { DockerClient, parseSandboxDockerHost } from './sandbox/docker-client.js';
11
11
  import { UpstreamAuth, UpstreamLoginRequiredError } from './upstream/auth.js';
12
+ import { boundedRedirectFetch } from './upstream/redirects.js';
12
13
  import { ToolCache } from './tool-cache.js';
13
14
  import { filterTools, hasToolFilter, unmatchedPatterns } from './tool-filter.js';
14
15
  import { subscriptionsAllowed } from './subscriptions.js';
@@ -44,6 +45,10 @@ function eraHasPing(client) {
44
45
  /**
45
46
  * Remote upstreams get their configured headers on EVERY request via a fetch
46
47
  * wrapper — requestInit alone does not cover the SSE stream GET.
48
+ *
49
+ * Both wrappers follow a redirect only within the configured origin, and at
50
+ * most three: the platform would otherwise follow a `Location` anywhere,
51
+ * headers and body included. See `boundedRedirectFetch`.
47
52
  */
48
53
  function buildRemoteTransport(config, auth) {
49
54
  const url = new URL(config.url);
@@ -59,14 +64,15 @@ function buildRemoteTransport(config, auth) {
59
64
  ? new SSEClientTransport(url, { authProvider: auth.provider(), fetch: guarded })
60
65
  : new StreamableHTTPClientTransport(url, { authProvider: auth.provider(), fetch: guarded });
61
66
  }
62
- const fetchWithHeaders = (input, init) => {
67
+ // The headers go on inside the redirect wrapper, so every hop carries them.
68
+ const fetchWithHeaders = boundedRedirectFetch(url.origin, (input, init) => {
63
69
  const merged = new Headers(init?.headers);
64
70
  for (const [key, value] of Object.entries(headers)) {
65
71
  if (!merged.has(key))
66
72
  merged.set(key, value);
67
73
  }
68
74
  return fetch(input, { ...init, headers: merged });
69
- };
75
+ });
70
76
  if (config.transport === 'sse') {
71
77
  return new SSEClientTransport(url, { requestInit: { headers }, fetch: fetchWithHeaders });
72
78
  }
@@ -190,6 +196,8 @@ export class ManagedServer {
190
196
  * kill the new child.
191
197
  */
192
198
  generation = 0;
199
+ /** True while client.connect() is in flight; see start(). */
200
+ connecting = false;
193
201
  /** The one upstream listen stream carrying this route's whole demand (modern era). */
194
202
  upstream;
195
203
  upstreamFilter;
@@ -353,13 +361,24 @@ export class ManagedServer {
353
361
  inputRequired: { autoFulfill: false }
354
362
  });
355
363
  setTransportHandlers(transport, { onclose: () => this.onExit(this.exitReason(), generation) });
364
+ // While the opening exchange runs, a close of the transport is not
365
+ // reported by onclose: the SDK closes the transport *before* connect()
366
+ // rejects, so the generic "connection closed" used to win the race and the
367
+ // rejection — which names the cause, and may carry a verdict no restart
368
+ // can fix — was thrown away by onExit's state guard. A refused redirect,
369
+ // a TLS failure or an unauthorized upstream all read as "connection
370
+ // closed" in the log. connect() always rejects once its transport is
371
+ // gone, so the catch below is the one place that reports this exit.
372
+ this.connecting = true;
356
373
  try {
357
374
  await client.connect(transport);
358
375
  }
359
376
  catch (error) {
377
+ this.connecting = false;
360
378
  this.onExit(`failed to start: ${error.message}`, generation, classifyAuthFailure(error));
361
379
  return;
362
380
  }
381
+ this.connecting = false;
363
382
  if (generation !== this.generation) {
364
383
  // sleep()/stop() ran while we were connecting; it already set the final
365
384
  // state, so this child is surplus and only needs to go away again.
@@ -615,6 +634,10 @@ export class ManagedServer {
615
634
  // left behind must not touch the current child.
616
635
  if (generation !== this.generation)
617
636
  return;
637
+ // A close during the opening exchange is reported by start()'s catch, with
638
+ // the reason connect() gives — see the note there.
639
+ if (this.connecting && reason === this.exitReason())
640
+ return;
618
641
  // A failed start reports twice: transport.onclose fires and start()'s catch
619
642
  // calls us as well. Without this guard the second call would overwrite
620
643
  // restartTimer without clearing it, so two children would be spawned and
@@ -15,7 +15,16 @@ const STDERR = 2;
15
15
  export class DockerFrameDecoder {
16
16
  onFrame;
17
17
  onError;
18
- buffer = Buffer.alloc(0);
18
+ /**
19
+ * The bytes of the frame in progress, as the chunks they arrived in, joined
20
+ * once the whole frame is here. One growing buffer would copy itself on every
21
+ * chunk — quadratic in the frame size, on the hub's event loop, for a size
22
+ * the peer chooses.
23
+ */
24
+ pending = [];
25
+ pendingBytes = 0;
26
+ /** The header of the frame in progress, once eight bytes have arrived. */
27
+ header;
19
28
  failed = false;
20
29
  constructor(onFrame, onError) {
21
30
  this.onFrame = onFrame;
@@ -24,25 +33,44 @@ export class DockerFrameDecoder {
24
33
  push(chunk) {
25
34
  if (this.failed)
26
35
  return;
27
- this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
36
+ if (chunk.length === 0)
37
+ return;
38
+ this.pending.push(chunk);
39
+ this.pendingBytes += chunk.length;
28
40
  for (;;) {
29
- if (this.buffer.length < 8)
30
- return;
31
- const size = this.buffer.readUInt32BE(4);
32
- if (size > MAX_FRAME_BYTES) {
33
- this.failed = true;
34
- this.onError(new Error(`docker frame of ${size} bytes exceeds the ${MAX_FRAME_BYTES} byte limit`));
35
- this.buffer = Buffer.alloc(0);
36
- return;
41
+ if (this.header === undefined) {
42
+ if (this.pendingBytes < 8)
43
+ return;
44
+ // Joining here is cheap: the pending pieces hold at most a header's
45
+ // worth of bytes plus whatever one chunk brought with it.
46
+ const joined = this.join();
47
+ const size = joined.readUInt32BE(4);
48
+ if (size > MAX_FRAME_BYTES) {
49
+ this.failed = true;
50
+ this.onError(new Error(`docker frame of ${size} bytes exceeds the ${MAX_FRAME_BYTES} byte limit`));
51
+ this.pending = [];
52
+ this.pendingBytes = 0;
53
+ return;
54
+ }
55
+ this.header = { stream: joined[0], size };
56
+ this.replace(joined.subarray(8));
37
57
  }
38
- if (this.buffer.length < 8 + size)
58
+ if (this.pendingBytes < this.header.size)
39
59
  return;
40
- const stream = this.buffer[0];
41
- const payload = this.buffer.subarray(8, 8 + size);
42
- this.buffer = this.buffer.subarray(8 + size);
43
- this.onFrame(stream, payload);
60
+ const joined = this.join();
61
+ const { stream, size } = this.header;
62
+ this.header = undefined;
63
+ this.replace(joined.subarray(size));
64
+ this.onFrame(stream, joined.subarray(0, size));
44
65
  }
45
66
  }
67
+ join() {
68
+ return this.pending.length === 1 ? this.pending[0] : Buffer.concat(this.pending);
69
+ }
70
+ replace(rest) {
71
+ this.pending = rest.length > 0 ? [rest] : [];
72
+ this.pendingBytes = rest.length;
73
+ }
46
74
  }
47
75
  /**
48
76
  * An MCP server running in its own container, spoken to over the Docker API.
@@ -18,7 +18,17 @@ export class StreamTransport {
18
18
  onclose;
19
19
  onerror;
20
20
  onmessage;
21
- buffer = Buffer.alloc(0);
21
+ /**
22
+ * Bytes received since the last newline, as the chunks they arrived in.
23
+ *
24
+ * A list rather than one growing buffer: appending a chunk to a buffer copies
25
+ * the whole buffer, so a peer that sends a long line in small pieces makes
26
+ * the hub copy quadratically — ten megabytes in 4 KiB chunks cost a second
27
+ * of the event loop, on every restart the peer cared to provoke. The pieces
28
+ * are joined once, when a newline arrives, which is linear.
29
+ */
30
+ pending = [];
31
+ pendingBytes = 0;
22
32
  started = false;
23
33
  closed = false;
24
34
  reportedClose = false;
@@ -55,25 +65,40 @@ export class StreamTransport {
55
65
  * own codec still does the parsing; only the policy is local.
56
66
  */
57
67
  receive(chunk) {
58
- if (this.buffer.length + chunk.length > STDIO_DEFAULT_MAX_BUFFER_SIZE) {
68
+ if (this.pendingBytes + chunk.length > STDIO_DEFAULT_MAX_BUFFER_SIZE) {
59
69
  // A peer that keeps sending without ever writing a newline. This runs
60
70
  // inside a 'data' handler, so a throw here would reach
61
71
  // process.on('uncaughtException') and take the entire hub down — every
62
72
  // other server with it — because one sandboxed server misbehaved. The
63
73
  // stream is desynchronised anyway: report it, end this connection, let
64
74
  // the supervisor restart it.
65
- this.buffer = Buffer.alloc(0);
75
+ this.pending = [];
76
+ this.pendingBytes = 0;
66
77
  this.onerror?.(new Error(`ReadBuffer exceeded maximum size of ${STDIO_DEFAULT_MAX_BUFFER_SIZE} bytes`));
67
78
  void this.close();
68
79
  return;
69
80
  }
70
- this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
81
+ // Only the new chunk can hold the newline that completes a line; the
82
+ // pending pieces were already searched when they arrived.
83
+ if (chunk.indexOf('\n') === -1) {
84
+ this.pending.push(chunk);
85
+ this.pendingBytes += chunk.length;
86
+ return;
87
+ }
88
+ let buffer = this.pending.length === 0 ? chunk : Buffer.concat([...this.pending, chunk]);
89
+ this.pending = [];
90
+ this.pendingBytes = 0;
71
91
  for (;;) {
72
- const newline = this.buffer.indexOf('\n');
73
- if (newline === -1)
92
+ const newline = buffer.indexOf('\n');
93
+ if (newline === -1) {
94
+ if (buffer.length > 0) {
95
+ this.pending.push(buffer);
96
+ this.pendingBytes = buffer.length;
97
+ }
74
98
  return;
75
- const line = this.buffer.toString('utf8', 0, newline).replace(/\r$/, '');
76
- this.buffer = this.buffer.subarray(newline + 1);
99
+ }
100
+ const line = buffer.toString('utf8', 0, newline).replace(/\r$/, '');
101
+ buffer = buffer.subarray(newline + 1);
77
102
  let message;
78
103
  try {
79
104
  message = deserializeMessage(line);
@@ -108,7 +133,8 @@ export class StreamTransport {
108
133
  return;
109
134
  this.reportedClose = true;
110
135
  this.closed = true;
111
- this.buffer = Buffer.alloc(0);
136
+ this.pending = [];
137
+ this.pendingBytes = 0;
112
138
  this.onclose?.();
113
139
  }
114
140
  }
@@ -5,6 +5,7 @@ import { isPrivateAddress, resolvePublicAddress } from '../auth/address.js';
5
5
  import { boundedResponse, guardedRequest } from '../auth/pinned-fetch.js';
6
6
  import { logSafe } from '../auth/text.js';
7
7
  import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata } from './provider.js';
8
+ import { boundedRedirectFetch } from './redirects.js';
8
9
  /**
9
10
  * Everything the hub needs to authenticate itself to one upstream MCP server.
10
11
  *
@@ -380,6 +381,9 @@ export class UpstreamAuth {
380
381
  */
381
382
  createFetch() {
382
383
  const upstreamOrigin = new URL(this.identity.serverUrl).origin;
384
+ // Data-plane redirects stay within the upstream's origin, three at most;
385
+ // the control plane refuses them outright in asFetch.
386
+ const dataPlane = boundedRedirectFetch(upstreamOrigin);
383
387
  return async (input, init) => {
384
388
  const url = new URL(input instanceof Request ? input.url : String(input));
385
389
  const isControlPlane = url.origin !== upstreamOrigin || url.pathname.startsWith('/.well-known/');
@@ -394,7 +398,7 @@ export class UpstreamAuth {
394
398
  if (!headers.has(key))
395
399
  headers.set(key, value);
396
400
  }
397
- return { response: await fetch(url, { ...init, headers }), token };
401
+ return { response: await dataPlane(url, { ...init, headers }), token };
398
402
  };
399
403
  const first = await send();
400
404
  if (first.response.status !== 401)
@@ -0,0 +1,86 @@
1
+ import { logSafe } from '../auth/text.js';
2
+ /**
3
+ * Redirects on the data plane of a remote upstream, followed only within the
4
+ * origin the operator configured.
5
+ *
6
+ * `fetch` follows a 3xx by default, and neither the SDK's transports nor the
7
+ * hub's own wrappers said otherwise — so a remote MCP server could answer a
8
+ * `tools/call`, the SSE stream or a `subscriptions/listen` with a `Location`
9
+ * pointing at an internal address, and the hub would connect there, send the
10
+ * JSON-RPC body and every configured header except `Authorization` and
11
+ * `Cookie` (the two the platform strips across origins), and parse whatever
12
+ * came back as MCP. The control plane — discovery, token, registration — has
13
+ * refused redirects since the guard for the authorization server was written;
14
+ * this closes the same door on the other side.
15
+ *
16
+ * Same origin is the line, not same host: a different port on the same name
17
+ * is a different service, and a plain-http twin of an https upstream is not
18
+ * the upstream. Within that line a hop is followed because servers really do
19
+ * redirect `/mcp` to `/mcp/`, and refusing it would break upstreams that were
20
+ * never a problem.
21
+ */
22
+ export const MAX_REDIRECT_HOPS = 3;
23
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
24
+ /**
25
+ * Wraps a fetch so that every redirect it would follow is checked first.
26
+ *
27
+ * `origin` is the configured upstream's origin (`new URL(config.url).origin`).
28
+ * The returned function has the platform's shape and can be handed to the SDK
29
+ * transports as their `fetch`; every hop goes through `fetchImpl`, so a wrapper
30
+ * that adds headers still adds them on each hop.
31
+ */
32
+ export function boundedRedirectFetch(origin, fetchImpl = fetch) {
33
+ return async (input, init) => {
34
+ let url = new URL(input instanceof Request ? input.url : String(input));
35
+ let request = { ...init, redirect: 'manual' };
36
+ for (let hop = 0;; hop++) {
37
+ // A caller that built a Request keeps it on the first hop — its body
38
+ // lives there. Nothing in the hub does, but the shape is the platform's.
39
+ const response = await fetchImpl(hop === 0 && input instanceof Request ? new Request(input, request) : url, request);
40
+ if (!REDIRECT_STATUSES.has(response.status))
41
+ return response;
42
+ const location = response.headers.get('location');
43
+ if (location === null)
44
+ return response;
45
+ // Nothing of the redirect's body is wanted, and holding the stream open
46
+ // would keep the connection with it.
47
+ await response.body?.cancel().catch(() => { });
48
+ let next;
49
+ try {
50
+ next = new URL(location, url);
51
+ }
52
+ catch {
53
+ throw new Error(`upstream at ${logSafe(url.origin)} redirected to an unparseable location`);
54
+ }
55
+ if (next.origin !== origin) {
56
+ throw new Error(`upstream at ${logSafe(url.origin)} redirected to ${logSafe(next.origin)} — refused, redirects are followed only within the configured origin`);
57
+ }
58
+ if (hop + 1 >= MAX_REDIRECT_HOPS) {
59
+ throw new Error(`upstream at ${logSafe(url.origin)} redirected more than ${MAX_REDIRECT_HOPS} times`);
60
+ }
61
+ // A fragment on the request URL survives a redirect that has none.
62
+ if (!next.hash && url.hash)
63
+ next.hash = url.hash;
64
+ url = next;
65
+ request = nextRequest(request, response.status);
66
+ }
67
+ };
68
+ }
69
+ /**
70
+ * What the platform would do to the method and body on this hop (Fetch
71
+ * standard, "HTTP-redirect fetch"): a 303 always becomes a GET, a 301 or 302
72
+ * turns a POST into a GET, and a 307 or 308 keeps both. The body is dropped
73
+ * whenever the method changes, with the headers that only described it.
74
+ */
75
+ function nextRequest(request, status) {
76
+ const method = (request.method ?? 'GET').toUpperCase();
77
+ const becomesGet = status === 303 ? method !== 'GET' && method !== 'HEAD' : (status === 301 || status === 302) && method === 'POST';
78
+ if (!becomesGet)
79
+ return request;
80
+ const headers = new Headers(request.headers);
81
+ for (const name of ['content-encoding', 'content-language', 'content-location', 'content-type', 'content-length']) {
82
+ headers.delete(name);
83
+ }
84
+ return { ...request, method: 'GET', body: undefined, headers };
85
+ }
86
+ //# sourceMappingURL=redirects.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ni-c/mcp-hub",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "description": "Serve multiple stdio MCP servers from one container: Claude-Code-style mcpServers config, path-based routing, hub meta-tools, and CIMD-first OAuth 2.1 + API tokens for ChatGPT, Claude and any Streamable-HTTP MCP client.",
5
5
  "keywords": [
6
6
  "mcp",