@ni-c/mcp-hub 0.11.3 → 0.11.4

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,80 @@ 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.4] - 2026-09-24
11
+
12
+ ### Security
13
+
14
+ - **An approval for one server was an approval for all of them.** The login
15
+ and consent pages name the resource a client asks for under *Requested
16
+ access*, but the approval was stored for the client and its redirect URI
17
+ only. While the operator's session lasted, a client approved for
18
+ `/paperless/mcp` could ask for `/hub` and receive a code without any page —
19
+ a token for every server. The approval now records the resources the page
20
+ showed, and a request for any other one brings the page back: *Approve /
21
+ Deny* within a live session, the sign-in page otherwise. Approvals written
22
+ by an older version name no resource, so each connector sees the page once
23
+ more the next time it authorizes; refresh tokens are unaffected.
24
+ `mcp-hub-admin clients list` shows `approvedResources`.
25
+ - **A remote upstream's reply had no size limit.** The control plane to an
26
+ upstream's authorization server has capped every response since August; the
27
+ MCP requests themselves read whatever came back, so one `tools/call` answered
28
+ with 300 MiB cost the hub 630 MB of heap, and an event without a line end
29
+ grew without bound in the SDK's SSE parser. Replies are now held to 10 MiB,
30
+ the limit the byte-stream transports apply to one message — a JSON reply as a
31
+ whole, an event stream per event, and a declared `content-length` above it is
32
+ refused before reading. `resources/updated` URIs longer than
33
+ `MCP_SUBSCRIPTION_MAX_URI_BYTES` (8 KiB) are dropped.
34
+ - **The consent page could be made to show a different address.** A redirect
35
+ URI such as `https://claude.ai@attacker.example/cb` connects to
36
+ `attacker.example`, and a bidi override or zero-width character in a
37
+ redirect URI or metadata document URL could reorder or hide the host in the
38
+ line the operator reads. Both are refused now, for every scheme and both
39
+ registration mechanisms; percent-encoded bytes are still accepted, and
40
+ values stored earlier are shown with such characters as visible escapes.
41
+ - **`/revoke` was not rate-limited.** It runs the same client authentication as
42
+ `/token`, `private_key_jwt` signature checks included, and a client can
43
+ register itself; it now has the same budget as `/token`, counted
44
+ separately so a flood of one cannot starve the other.
45
+ - **Requests could drive a crashing server's restarts.** Every request to an
46
+ on-demand server cancelled its crash backoff and reset the give-up count, so
47
+ a client repeating a call restarted a crashing sandbox container at its own
48
+ pace — 30 attempts in 12 seconds instead of 4. A request now waits for the
49
+ scheduled restart; after a give-up the first request retries and further
50
+ ones within the backoff get the last error.
51
+ - **DPoP was offered but never checked.** oidc-provider enables it by default,
52
+ so a client that sent a proof received a `DPoP` token that the hub then
53
+ accepted as a plain bearer. It is switched off, which is what the standards
54
+ page already said.
55
+ - **Text from a sandbox or an upstream reached the terminal unescaped.** A
56
+ sandbox container's stderr and an upstream authorization server's error text
57
+ in `mcp-hub-admin upstream …` now go through the same escaping as every other
58
+ stranger's text, so an escape sequence or a bare CR can no longer rewrite
59
+ what the operator sees. `LOG_FILE` was not affected.
60
+ - **An upstream token with a line break broke its server for good.** A token
61
+ containing CR or LF was stored and then made every request to that server
62
+ fail in `Headers.set()`, while `/health` kept showing it up. Tokens, and the
63
+ RFC 7592 registration token, must now be printable ASCII of at most 16 KiB;
64
+ a malformed one fails the server's authorization instead of being stored,
65
+ and its value is never logged.
66
+
67
+ Reported by the 2026-09-24 internal review.
68
+
69
+ ### Fixed
70
+
71
+ - **A wrongly signed JWT cost a signature check.** A bearer shaped like an
72
+ API token is now decoded first and only verified when its algorithm,
73
+ subject and `jti` match a live token, which makes rejecting garbage about
74
+ twenty times cheaper. A valid token is still verified in full, and
75
+ revocation is still checked after verification.
76
+ - **`clients revoke` reported 0 refresh tokens.** It counted a map nothing
77
+ writes any more; it now counts the live ones it invalidates. The revocation
78
+ itself was always effective.
79
+ - **A character split across two docker frames was garbled** in the sandbox's
80
+ stderr; the stream is now decoded across frame boundaries, and a last line
81
+ without a newline is written out when the container stops instead of being
82
+ dropped.
83
+
10
84
  ## [0.11.3] - 2026-09-12
11
85
 
12
86
  ### Security
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![CI](https://img.shields.io/github/actions/workflow/status/ni-c/mcp-hub/ci.yml?branch=main&label=CI)](https://github.com/ni-c/mcp-hub/actions/workflows/ci.yml)
6
6
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ni-c/mcp-hub/badge)](https://scorecard.dev/viewer/?uri=github.com/ni-c/mcp-hub)
7
- <a href="https://socket.dev/npm/package/@ni-c/mcp-hub"><img src="https://socket.dev/api/badge/npm/package/@ni-c/mcp-hub" alt="Socket supply-chain report" height="20"></a>
7
+ [![Socket Badge](https://badge.socket.dev/npm/package/@ni-c/mcp-hub)](https://socket.dev/npm/package/@ni-c/mcp-hub)
8
8
  [![Glama score](https://glama.ai/mcp/servers/ni-c/mcp-hub/badges/score.svg)](https://glama.ai/mcp/servers/ni-c/mcp-hub)
9
9
  <br>
10
10
  [![npm version](https://img.shields.io/npm/v/%40ni-c%2Fmcp-hub)](https://www.npmjs.com/package/@ni-c/mcp-hub)
@@ -386,10 +386,11 @@ and log in once with the password. Claude Code:
386
386
  (OpenAI Responses API, xAI, Gemini API) use an admin-minted token instead —
387
387
  see [client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibility).
388
388
 
389
- Each client is confirmed once. Entering the password approves the client that
390
- asked; while a login session is still valid, a client you have not seen before
391
- gets an explicit _Approve / Deny_ page instead of a code. Approved clients
392
- reconnect silently from then on.
389
+ Each client is confirmed once per server it asks for. Entering the password
390
+ approves the client that asked, for the server shown on the page; while a login
391
+ session is still valid, a client you have not seen before — or one asking for
392
+ another server — gets an explicit _Approve / Deny_ page instead of a code.
393
+ Approved clients reconnect silently from then on.
393
394
 
394
395
  List clients or revoke one. The CLI shares `/data` with the running hub and
395
396
  both sides re-read the state file before they touch it, so this works against a
package/dist/admin.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import crypto from 'node:crypto';
3
3
  import { AuthStore, clientLimitsFromEnv } from './auth/store.js';
4
4
  import { isSafeRedirectUri } from './auth/redirect-uri.js';
5
- import { clampDisplayName } from './auth/text.js';
5
+ import { clampDisplayName, logSafe } from './auth/text.js';
6
6
  import { isClientIdMetadataUrl } from './auth/cimd.js';
7
7
  import { mintApiToken } from './auth/api-tokens.js';
8
8
  import { loadConfig } from './config.js';
@@ -31,8 +31,10 @@ function usage() {
31
31
  ].join('\n'));
32
32
  process.exit(2);
33
33
  }
34
+ /** Messages here can carry an upstream authorization server's own error
35
+ * text, so they are escaped before they reach the operator's terminal. */
34
36
  function fail(message) {
35
- console.error(message);
37
+ console.error(logSafe(message));
36
38
  process.exit(2);
37
39
  }
38
40
  function flag(args, name) {
@@ -65,6 +67,7 @@ if (group === 'clients' && action === 'list') {
65
67
  via: clientOrigin(id),
66
68
  registeredRedirectUris: client.redirect_uris,
67
69
  approvedRedirectUris: approvals[id]?.redirectUris ?? [],
70
+ approvedResources: approvals[id]?.resources ?? [],
68
71
  approvedAt: approvals[id] ? new Date(approvals[id].approvedAt * 1000).toISOString() : null
69
72
  }));
70
73
  // Metadata-document clients are never stored — the document is fetched fresh
@@ -80,6 +83,7 @@ if (group === 'clients' && action === 'list') {
80
83
  via: clientOrigin(id),
81
84
  registeredRedirectUris: [],
82
85
  approvedRedirectUris: approval.redirectUris,
86
+ approvedResources: approval.resources,
83
87
  approvedAt: new Date(approval.approvedAt * 1000).toISOString()
84
88
  });
85
89
  }
@@ -324,8 +328,9 @@ if (group === 'upstream') {
324
328
  // unreachable must never leave a credential behind here.
325
329
  const problems = await auth.revokeRemotely().catch(error => [error.message]);
326
330
  const forgotten = store.forgetUpstreamCredentials(name);
331
+ // A revocation failure can carry the authorization server's own text.
327
332
  for (const problem of problems)
328
- console.error(`Upstream revocation: ${problem}`);
333
+ console.error(`Upstream revocation: ${logSafe(problem)}`);
329
334
  console.log(JSON.stringify({ server: name, forgotten, revokedAtUpstream: problems.length === 0 }, null, 2));
330
335
  process.exit(problems.length > 0 ? 1 : 0);
331
336
  }
package/dist/auth/cimd.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isPrivateAddress, resolvePublicAddress } from './address.js';
2
2
  import { guardedRequest } from './pinned-fetch.js';
3
- import { isSafeRedirectUri } from './redirect-uri.js';
3
+ import { hasUserinfo, isPrintableAsciiUri, isSafeRedirectUri } from './redirect-uri.js';
4
4
  import { clampDisplayName, logSafe } from './text.js';
5
5
  // Lives in address.ts now — re-exported because it is part of this module's
6
6
  // established surface and the test suite reaches for it here.
@@ -59,8 +59,14 @@ export function setCimdFetch(fn) {
59
59
  * fragment, no credentials and no dot-segments. A `client_id` that fails this
60
60
  * is not a metadata document URL at all and is looked up as a locally
61
61
  * registered client instead — which is what keeps DCR working beside CIMD.
62
+ *
63
+ * The consent page shows this URL as the part of the client's identity that
64
+ * cannot be invented, so it is held to the same printable-ASCII and
65
+ * no-userinfo rules as a redirect URI.
62
66
  */
63
67
  export function isClientIdMetadataUrl(clientId) {
68
+ if (!isPrintableAsciiUri(clientId))
69
+ return false;
64
70
  let url;
65
71
  try {
66
72
  url = new URL(clientId);
@@ -70,7 +76,7 @@ export function isClientIdMetadataUrl(clientId) {
70
76
  }
71
77
  if (url.protocol !== 'https:')
72
78
  return false;
73
- if (url.hash || url.username || url.password)
79
+ if (url.hash || hasUserinfo(clientId, url))
74
80
  return false;
75
81
  if (url.pathname === '' || url.pathname === '/')
76
82
  return false;
@@ -5,9 +5,17 @@ import { renderLoginPage } from '../login-page.js';
5
5
  import { earlyRateLimit, LoginRateLimiter } from '../rate-limit.js';
6
6
  import { operatorCredential } from '../password.js';
7
7
  import { isLoopbackOnly } from '../redirect-uri.js';
8
+ import { approvalResourceKey } from '../resource.js';
8
9
  import { createSessionCookie, csrfToken, readSessionCookie, SESSION_TTL_MS, sessionCookieName, verifyCsrfToken } from '../session.js';
9
10
  import { logSafe } from '../text.js';
10
11
  import { HUB_ACCOUNT_ID } from './provider.js';
12
+ /** The resources the page showed under "Requested access", in the form
13
+ * loadExistingGrant looks an approval up by. */
14
+ function canonicalizeRequestedResources(resource, options) {
15
+ return [resource ?? []]
16
+ .flat()
17
+ .map(value => approvalResourceKey(String(value), options.externalUrl, options.resolveResource));
18
+ }
11
19
  /** Express 5 forwards a rejected handler promise on its own; this makes that explicit and typed. */
12
20
  const asyncHandler = (handler) => (req, res, next) => {
13
21
  handler(req, res, next).catch(next);
@@ -140,11 +148,13 @@ export function createOidcInteractionRoutes(options) {
140
148
  }
141
149
  rateLimiter.reset(ip);
142
150
  console.log(`mcp-hub: successful login from ${logSafe(ip)}`);
143
- // Typing the password is the consent for the client that triggered it.
151
+ // Typing the password is the consent for the client that triggered it:
152
+ // for its redirect target and for the resource the page showed.
144
153
  const clientId = String(params.client_id);
145
154
  const client = await provider.Client.find(clientId);
146
155
  const clientName = client?.metadata()?.client_name;
147
- store.saveApproval(clientId, redirectUri, typeof clientName === 'string' ? clientName : undefined);
156
+ const resources = canonicalizeRequestedResources(params.resource, options);
157
+ store.saveApproval(clientId, redirectUri, typeof clientName === 'string' ? clientName : undefined, resources);
148
158
  console.log(`mcp-hub: approved OAuth client ${logSafe(clientId)} for ${logSafe(redirectUri)}`);
149
159
  // `__Host-` behind HTTPS: the browser then refuses the cookie from any
150
160
  // other origin, path or domain, so nobody can fix a session into this
@@ -191,7 +201,8 @@ export function createOidcInteractionRoutes(options) {
191
201
  const clientId = String(params.client_id);
192
202
  const client = await provider.Client.find(clientId);
193
203
  const clientName = client?.metadata()?.client_name;
194
- store.saveApproval(clientId, String(params.redirect_uri ?? ''), typeof clientName === 'string' ? clientName : undefined);
204
+ const resources = canonicalizeRequestedResources(params.resource, options);
205
+ store.saveApproval(clientId, String(params.redirect_uri ?? ''), typeof clientName === 'string' ? clientName : undefined, resources);
195
206
  console.log(`mcp-hub: approved OAuth client ${logSafe(clientId)} for ${logSafe(String(params.redirect_uri ?? ''))}`);
196
207
  // The grant itself is minted by loadExistingGrant on the resumed request,
197
208
  // which is the same code path an already-approved client takes.
@@ -76,7 +76,12 @@ export function defaultRateLimits() {
76
76
  '/register/:id': [earlyRateLimit(60 * 60_000, 60, 600)],
77
77
  '/authorize': [earlyRateLimit(15 * 60_000, 100, 1_000)],
78
78
  '/authorize/:uid': [earlyRateLimit(15 * 60_000, 100, 1_000)],
79
- '/token': [earlyRateLimit(15 * 60_000, 50, 500)]
79
+ '/token': [earlyRateLimit(15 * 60_000, 50, 500)],
80
+ // '/revoke' authenticates the client exactly as '/token' does,
81
+ // private_key_jwt signature checks included, for any caller that has
82
+ // registered itself — so it gets the same budget, counted separately so a
83
+ // flood of one cannot starve the other.
84
+ '/revoke': [earlyRateLimit(15 * 60_000, 50, 500)]
80
85
  };
81
86
  }
82
87
  /**
@@ -1,5 +1,6 @@
1
1
  import Provider, { errors } from 'oidc-provider';
2
2
  import { isSafeRedirectUri, redirectUriMatches } from '../redirect-uri.js';
3
+ import { approvalResourceKey } from '../resource.js';
3
4
  import { clampDisplayName } from '../text.js';
4
5
  import { createOidcAdapter } from './adapter.js';
5
6
  import { HUB_SCOPE, installDiscoveryFixups, installThrowawaySecret } from './quirks.js';
@@ -103,6 +104,11 @@ export function buildOidcProvider(store, options) {
103
104
  ack: 'draft-02',
104
105
  allowFetch: () => false
105
106
  },
107
+ // On by default in oidc-provider. The resource server never checks a
108
+ // proof, so a `token_type: "DPoP"` token would promise a sender
109
+ // constraint nothing enforces. Off, clients get the bearer token they
110
+ // actually hold.
111
+ dPoP: { enabled: false },
106
112
  resourceIndicators: {
107
113
  enabled: true,
108
114
  useGrantedResource: () => true,
@@ -359,9 +365,24 @@ export function buildOidcProvider(store, options) {
359
365
  .filter(Boolean);
360
366
  if (scopes.length > 0)
361
367
  grant.addOIDCScope(scopes.join(' '));
368
+ /**
369
+ * The resource gets the same discipline as the redirect URI: the page
370
+ * named it under "Requested access", so the approval covers that
371
+ * resource and no other. One the operator has not approved is left off
372
+ * the grant, and oidc-provider's own `rs_scopes_missing` check then asks
373
+ * — the consent page with a live session, the login page without — and
374
+ * approving adds it before this runs again for the resumed request.
375
+ *
376
+ * Matched in raw and canonical form, since the interaction routes may
377
+ * run without a canonicaliser; added in raw form, because that is the
378
+ * key `rs_scopes_missing` looks the resource up by.
379
+ */
380
+ const approvedResources = approval?.resources ?? [];
362
381
  const requested = ctx.oidc.params?.resource;
363
- for (const resource of [requested ?? []].flat()) {
364
- grant.addResourceScope(String(resource), HUB_SCOPE);
382
+ for (const resource of [requested ?? []].flat().map(String)) {
383
+ const key = approvalResourceKey(resource, options.externalUrl, options.resolveResource);
384
+ if (approvedResources.includes(resource) || approvedResources.includes(key))
385
+ grant.addResourceScope(resource, HUB_SCOPE);
365
386
  }
366
387
  await grant.save();
367
388
  return grant;
@@ -1,5 +1,5 @@
1
1
  import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
2
- import { jwtVerify } from 'jose';
2
+ import { decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
3
3
  import { API_TOKEN_SUBJECT } from '../api-tokens.js';
4
4
  /**
5
5
  * The one error shape `requireBearerAuth` recognises.
@@ -31,9 +31,11 @@ function invalidToken(message) {
31
31
  * which is what `tokens revoke` deletes.
32
32
  *
33
33
  * Order matters. Opaque is tried first because it is the common case and needs
34
- * no cryptography; a value that is not a stored token then gets exactly one
35
- * signature check. Neither branch may report why it failed — an attacker must
36
- * not be able to tell "unknown" from "revoked" from "wrong audience".
34
+ * no cryptography; a value that is not a stored token then reaches at most one
35
+ * signature check, and only if its decoded claims name a live API token.
36
+ * Neither branch may report why it failed — an
37
+ * attacker must not be able to tell "unknown" from "revoked" from "wrong
38
+ * audience".
37
39
  */
38
40
  export class OidcTokenVerifier {
39
41
  store;
@@ -76,6 +78,12 @@ export class OidcTokenVerifier {
76
78
  };
77
79
  }
78
80
  async fromApiToken(token) {
81
+ // Anyone can send a well-formed, wrongly signed JWT for free, and an EdDSA
82
+ // verification costs about twenty times a decode. The decode proves
83
+ // nothing, so it can only reject early: a token naming a live jti still
84
+ // has its signature checked below before anything is trusted.
85
+ if (!this.mayBeLiveApiToken(token))
86
+ throw invalidToken('Invalid or expired access token');
79
87
  let payload;
80
88
  try {
81
89
  ({ payload } = await jwtVerify(token, this.store.publicKey, {
@@ -92,6 +100,9 @@ export class OidcTokenVerifier {
92
100
  // credential nothing can revoke.
93
101
  if (payload.sub !== API_TOKEN_SUBJECT)
94
102
  throw invalidToken('Invalid access token claims');
103
+ // Checked again after verification, not only in the pre-check: a
104
+ // `tokens revoke` that lands while the signature is being verified must
105
+ // still refuse this request.
95
106
  if (typeof payload.jti !== 'string' || !this.store.getApiToken(payload.jti)) {
96
107
  throw invalidToken('Access token has been revoked');
97
108
  }
@@ -107,6 +118,26 @@ export class OidcTokenVerifier {
107
118
  resource
108
119
  };
109
120
  }
121
+ /**
122
+ * Whether a token's unverified claims could belong to a live API token. The
123
+ * checks mirror what `fromApiToken` requires after verification —
124
+ * algorithm, subject, a live jti — so this never rejects a token that would
125
+ * otherwise pass.
126
+ */
127
+ mayBeLiveApiToken(token) {
128
+ let header;
129
+ let payload;
130
+ try {
131
+ header = decodeProtectedHeader(token);
132
+ payload = decodeJwt(token);
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ if (header.alg !== 'EdDSA' || payload.sub !== API_TOKEN_SUBJECT || typeof payload.jti !== 'string')
138
+ return false;
139
+ return this.store.getApiToken(payload.jti) !== undefined;
140
+ }
110
141
  resolve(audience) {
111
142
  let parsed;
112
143
  try {
package/dist/auth/page.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { escapeInvisibles } from './text.js';
1
2
  export function escapeHtml(value) {
2
3
  return value.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
3
4
  }
@@ -8,16 +9,21 @@ export function escapeHtml(value) {
8
9
  * identity that cannot be invented, which is why the specification asks for it
9
10
  * to be shown; a client whose redirect URIs are all local cannot be attributed
10
11
  * to anything at all, so that gets said outright.
12
+ *
13
+ * Each field also goes through `escapeInvisibles`: registration refuses bidi
14
+ * and zero-width characters now, but a client stored before that must not be
15
+ * able to reorder the line the operator judges it by. (`clientName` is
16
+ * cleaned by `clampDisplayName` before it gets here.)
11
17
  */
12
18
  export function renderIdentity(redirectUri, identity) {
13
19
  const lines = [
14
20
  ' <p class="label">Requested access</p>',
15
- ` <code class="target">${escapeHtml(identity.resource ?? 'Every server on this hub')}</code>`
21
+ ` <code class="target">${escapeHtml(escapeInvisibles(identity.resource ?? 'Every server on this hub'))}</code>`
16
22
  ];
17
23
  if (identity.clientId) {
18
- lines.push(' <p class="label">Identified by</p>', ` <code class="target">${escapeHtml(identity.clientId)}</code>`);
24
+ lines.push(' <p class="label">Identified by</p>', ` <code class="target">${escapeHtml(escapeInvisibles(identity.clientId))}</code>`);
19
25
  }
20
- lines.push(' <p class="label">Codes will be sent to</p>', ` <code class="target">${escapeHtml(redirectUri)}</code>`);
26
+ lines.push(' <p class="label">Codes will be sent to</p>', ` <code class="target">${escapeHtml(escapeInvisibles(redirectUri))}</code>`);
21
27
  if (identity.loopbackOnly) {
22
28
  lines.push(' <p class="error">This client only accepts codes on this machine, so any program running here could be the one asking.</p>');
23
29
  }
@@ -14,6 +14,38 @@ const DANGEROUS_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:', 'file:',
14
14
  function isLoopbackHostname(hostname) {
15
15
  return LOOPBACK_HOSTNAMES.has(hostname);
16
16
  }
17
+ /**
18
+ * Printable ASCII, 0x21 `!` through 0x7E `~`. A URI is an ASCII string by
19
+ * definition (RFC 3986): a control character, a space or any non-ASCII code
20
+ * point — a bidi override or a zero-width character included — belongs in one
21
+ * only as `%XX`, and that encoding is itself inside this range.
22
+ */
23
+ const PRINTABLE_ASCII_ONLY = /^[\x21-\x7E]+$/;
24
+ export function isPrintableAsciiUri(value) {
25
+ return PRINTABLE_ASCII_ONLY.test(value);
26
+ }
27
+ /**
28
+ * Whether a URI names a user before its host, `https://claude.ai@evil.example`
29
+ * being the reason: the reader's eye lands on the part before the `@`, the
30
+ * browser connects to the part after it.
31
+ *
32
+ * Two checks, because each misses a spelling the other catches. The raw string
33
+ * is needed for an empty userinfo, which the WHATWG parser drops without a
34
+ * trace (`https://:@host` parses to an empty username and password). The parsed
35
+ * URL is needed for authorities the parser finds by another spelling, such as
36
+ * a backslash in place of a slash (`https:/\user@host`). An `@` in the path
37
+ * or query, or percent-encoded, is not userinfo and stays allowed.
38
+ */
39
+ export function hasUserinfo(uri, parsed) {
40
+ if (parsed.username || parsed.password)
41
+ return true;
42
+ const marker = uri.indexOf('://');
43
+ if (marker === -1)
44
+ return false;
45
+ const afterMarker = uri.slice(marker + 3);
46
+ const authorityEnd = afterMarker.search(/[/?#\\]/);
47
+ return (authorityEnd === -1 ? afterMarker : afterMarker.slice(0, authorityEnd)).includes('@');
48
+ }
17
49
  /**
18
50
  * https anywhere, plain http only on this machine, and — where the policy
19
51
  * allows it — a private-use scheme for a native client.
@@ -21,8 +53,14 @@ function isLoopbackHostname(hostname) {
21
53
  * The point of refusing remote `http://` is that the code travels in the clear
22
54
  * on the final redirect: anyone on the path between the browser and the client
23
55
  * reads it, and a public client has nothing else to prove itself with.
56
+ *
57
+ * Every scheme is held to printable ASCII and refused with a userinfo part:
58
+ * this is the address the login and consent pages show as the one thing "not
59
+ * chosen by the application", so it must read as what it is.
24
60
  */
25
61
  export function isSafeRedirectUri(uri, policy) {
62
+ if (!PRINTABLE_ASCII_ONLY.test(uri))
63
+ return false;
26
64
  let parsed;
27
65
  try {
28
66
  parsed = new URL(uri);
@@ -30,6 +68,8 @@ export function isSafeRedirectUri(uri, policy) {
30
68
  catch {
31
69
  return false;
32
70
  }
71
+ if (hasUserinfo(uri, parsed))
72
+ return false;
33
73
  if (DANGEROUS_SCHEMES.has(parsed.protocol))
34
74
  return false;
35
75
  if (parsed.protocol === 'https:')
@@ -9,6 +9,22 @@ export function canonicalResourceUrl(resource, origin, config) {
9
9
  return undefined;
10
10
  return new URL(`/${match[1]}/mcp`, origin);
11
11
  }
12
+ /**
13
+ * The form an operator approval records a resource in, and is looked up by:
14
+ * canonical when a canonicaliser is available and the value parses, the raw
15
+ * value otherwise. The issuer itself — the audience of an unbound token —
16
+ * names no MCP route and is kept as it is.
17
+ */
18
+ export function approvalResourceKey(resource, issuer, canonicalize) {
19
+ if (resource === issuer || !canonicalize)
20
+ return resource;
21
+ try {
22
+ return canonicalize(new URL(resource))?.href ?? resource;
23
+ }
24
+ catch {
25
+ return resource;
26
+ }
27
+ }
12
28
  export function resourceUrlForRoute(origin, name) {
13
29
  return new URL(name === 'hub' ? '/hub' : `/${name}/mcp`, origin);
14
30
  }
@@ -296,6 +296,17 @@ export class AuthStore {
296
296
  this.state = next;
297
297
  this.signature = this.fileSignature();
298
298
  }
299
+ /** An approval as a state file may hold it: a missing or malformed field
300
+ * becomes empty — approved for nothing — rather than stopping the load. */
301
+ static normalizeApproval(approval) {
302
+ const source = (approval && typeof approval === 'object' ? approval : {});
303
+ return {
304
+ redirectUris: Array.isArray(source.redirectUris) ? source.redirectUris.filter((uri) => typeof uri === 'string') : [],
305
+ resources: Array.isArray(source.resources) ? source.resources.filter((r) => typeof r === 'string') : [],
306
+ ...(typeof source.clientName === 'string' ? { clientName: source.clientName } : {}),
307
+ approvedAt: typeof source.approvedAt === 'number' ? source.approvedAt : Math.floor(Date.now() / 1000)
308
+ };
309
+ }
299
310
  /**
300
311
  * Shapes a parsed state file, or undefined when it is unusable. Fields added
301
312
  * later default to empty: a state.json written before client approvals
@@ -310,7 +321,7 @@ export class AuthStore {
310
321
  cookieSecret: state.cookieSecret,
311
322
  clients: bare(state.clients),
312
323
  refreshTokens: bare(state.refreshTokens),
313
- approvals: bare(state.approvals),
324
+ approvals: bare(Object.fromEntries(Object.entries(bare(state.approvals)).map(([id, approval]) => [id, AuthStore.normalizeApproval(approval)]))),
314
325
  consumedRefreshTokens: bare(state.consumedRefreshTokens),
315
326
  revokedBefore: bare(state.revokedBefore),
316
327
  apiTokens: bare(state.apiTokens),
@@ -740,14 +751,20 @@ export class AuthStore {
740
751
  this.reloadIfChanged();
741
752
  return this.state.approvals[clientId];
742
753
  }
743
- /** Records consent for one client; a client may legitimately use several
744
- * redirect URIs, so they accumulate rather than replace each other. */
745
- saveApproval(clientId, redirectUri, clientName) {
754
+ /**
755
+ * Records consent for one client. A client may use several redirect URIs
756
+ * and be approved for several resources over time, so both accumulate.
757
+ * `resources` are the ones the page showed; `mcp-hub-admin clients add`
758
+ * has no page and passes none, so it approves the redirect target only.
759
+ */
760
+ saveApproval(clientId, redirectUri, clientName, resources = []) {
746
761
  this.mutate(() => {
747
762
  const existing = this.state.approvals[clientId];
748
763
  const redirectUris = existing ? [...new Set([...existing.redirectUris, redirectUri])] : [redirectUri];
764
+ const mergedResources = [...new Set([...(existing?.resources ?? []), ...resources])];
749
765
  this.state.approvals[clientId] = {
750
766
  redirectUris,
767
+ resources: mergedResources,
751
768
  clientName: clientName ?? existing?.clientName,
752
769
  approvedAt: existing?.approvedAt ?? Math.floor(Date.now() / 1000)
753
770
  };
@@ -772,6 +789,8 @@ export class AuthStore {
772
789
  revokeClientAccess(clientId) {
773
790
  return this.mutate(() => {
774
791
  delete this.state.approvals[clientId];
792
+ // Only a state file from before the oidc-provider migration still has
793
+ // entries here; nothing writes this map any more.
775
794
  let refreshTokens = 0;
776
795
  for (const [hash, record] of Object.entries(this.state.refreshTokens)) {
777
796
  if (record.clientId === clientId) {
@@ -779,6 +798,18 @@ export class AuthStore {
779
798
  refreshTokens++;
780
799
  }
781
800
  }
801
+ // The live refresh tokens are oidc-provider artifacts. revokedBefore
802
+ // below is what makes them unusable; this only counts them for the
803
+ // operator reading the command's output.
804
+ const now = Math.floor(Date.now() / 1000);
805
+ for (const record of Object.values(this.state.oidcArtifacts.RefreshToken ?? {})) {
806
+ if (record.consumedAt !== undefined)
807
+ continue;
808
+ if (record.expiresAt !== 0 && record.expiresAt < now)
809
+ continue;
810
+ if (record.payload.clientId === clientId)
811
+ refreshTokens++;
812
+ }
782
813
  const revokedBefore = Date.now();
783
814
  this.state.revokedBefore[clientId] = revokedBefore;
784
815
  return { refreshTokens, revokedBefore };
package/dist/auth/text.js CHANGED
@@ -33,6 +33,15 @@ export function logSafe(value, maxLength = MAX_LOGGED_LENGTH) {
33
33
  });
34
34
  return escaped.length > maxLength ? `${escaped.slice(0, maxLength)}…` : escaped;
35
35
  }
36
+ /**
37
+ * Control, format and line/paragraph-separator characters rewritten as a
38
+ * literal `\u{XXXX}`, so they print instead of acting: a bidi override no
39
+ * longer reorders the text around it, a zero-width character no longer hides.
40
+ * For text shown on the login and consent pages, before `escapeHtml`.
41
+ */
42
+ export function escapeInvisibles(value) {
43
+ return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, character => `\\u{${(character.codePointAt(0) ?? 0).toString(16)}}`);
44
+ }
36
45
  /**
37
46
  * A self-declared client name the login and consent pages can show without the
38
47
  * name becoming the page. Escaping alone is not enough: the text is still
package/dist/index.js CHANGED
@@ -194,7 +194,10 @@ export async function createHub(options) {
194
194
  externalUrl,
195
195
  password: options.password,
196
196
  passwordHash: options.passwordHash,
197
- cimd
197
+ cimd,
198
+ // Same canonicaliser as buildOidcProvider, so an approval is stored
199
+ // under the key the provider looks up.
200
+ resolveResource: resource => canonicalResourceUrl(resource, origin, watcher.current)
198
201
  }));
199
202
  // Ahead of the mount, so the hub's stricter RFC 7592 handlers win the
200
203
  // /register/:id route over oidc-provider's.
@@ -1,3 +1,4 @@
1
+ import { logSafe } from './auth/text.js';
1
2
  import { booleanEnv, nonNegativeIntegerEnv, positiveIntegerEnv } from './mcp-limits.js';
2
3
  /**
3
4
  * Carrying a child's change notifications to the client that asked for them.
@@ -71,6 +72,15 @@ export const MAX_STREAM_MS = nonNegativeIntegerEnv('MCP_SUBSCRIPTION_MAX_MS', 30
71
72
  * window would otherwise be a million map entries the hub holds for it.
72
73
  */
73
74
  const MAX_PENDING_EVENTS = 1024;
75
+ /**
76
+ * Longest `resources/updated` URI the hub holds or forwards; a longer one is
77
+ * dropped. A URI names what to re-read — nothing legitimate comes near this.
78
+ * `MAX_PENDING_EVENTS` bounds how many one window holds, this how large each
79
+ * may be, whichever transport the child sends it over.
80
+ */
81
+ export const MAX_RESOURCE_URI_BYTES = positiveIntegerEnv('MCP_SUBSCRIPTION_MAX_URI_BYTES', 8 * 1024);
82
+ /** At most one log line a minute per registry about dropped URIs. */
83
+ const OVERSIZED_URI_WARNING_INTERVAL_MS = 60_000;
74
84
  /** True unless an operator said otherwise, globally or for this server. */
75
85
  export function subscriptionsAllowed(config) {
76
86
  return SUBSCRIPTIONS_ENABLED && config.subscriptions !== 'off';
@@ -131,6 +141,7 @@ export class SubscriptionRegistry {
131
141
  nextId = 1;
132
142
  timer;
133
143
  closed = false;
144
+ lastOversizedUriWarningAt = 0;
134
145
  constructor(notifier, options = {}) {
135
146
  this.notifier = notifier;
136
147
  this.options = options;
@@ -172,6 +183,10 @@ export class SubscriptionRegistry {
172
183
  publish(event) {
173
184
  if (this.closed)
174
185
  return;
186
+ if (event.kind === 'resource_updated' && Buffer.byteLength(event.uri, 'utf8') > MAX_RESOURCE_URI_BYTES) {
187
+ this.warnOversizedUri(event.uri);
188
+ return;
189
+ }
175
190
  const debounceMs = this.options.debounceMs ?? DEBOUNCE_MS;
176
191
  if (debounceMs <= 0) {
177
192
  this.deliver(event);
@@ -236,6 +251,14 @@ export class SubscriptionRegistry {
236
251
  return;
237
252
  }
238
253
  }
254
+ /** The URI is the child's own text, so it is escaped and truncated. */
255
+ warnOversizedUri(uri) {
256
+ const now = Date.now();
257
+ if (now - this.lastOversizedUriWarningAt < OVERSIZED_URI_WARNING_INTERVAL_MS)
258
+ return;
259
+ this.lastOversizedUriWarningAt = now;
260
+ console.error(`mcp-hub: dropped a resources/updated notification whose uri exceeded ${MAX_RESOURCE_URI_BYTES} bytes: ${logSafe(uri)}`);
261
+ }
239
262
  close() {
240
263
  this.closed = true;
241
264
  clearTimeout(this.timer);
@@ -186,8 +186,16 @@ export class ManagedServer {
186
186
  restartTimer;
187
187
  pingTimer;
188
188
  stopping = false;
189
- /** Failed restarts since the last real use; wake() and markUsed() reset it. */
189
+ /** Failed restarts since the server was last up or used; a start that
190
+ * reaches 'up' and markUsed() reset it, wake() does not. */
190
191
  restartsSinceUse = 0;
192
+ /**
193
+ * When a request last revived a server that had given up. The first request
194
+ * after a give-up retries at once; another one within the current backoff is
195
+ * refused, so a caller repeating it cannot restart the server faster than its
196
+ * own crashes allow. Cleared by a start that reaches 'up'.
197
+ */
198
+ lastGiveUpRetryAt = 0;
191
199
  wakeWaiters = [];
192
200
  /**
193
201
  * Invalidates callbacks of an abandoned transport: sleep() and stop() tear a
@@ -250,6 +258,15 @@ export class ManagedServer {
250
258
  if (this.state === 'unauthorized') {
251
259
  return Promise.reject(new Error(`Server "${this.name}" needs an upstream login`));
252
260
  }
261
+ // A server that gave up revives on request, but no faster than its backoff.
262
+ // A request inside the window fails fast with the error the give-up
263
+ // reported, rather than queueing behind an attempt that is not coming.
264
+ if (this.state === 'sleeping' && this.restartsSinceUse > 0) {
265
+ if (Date.now() - this.lastGiveUpRetryAt < this.backoffMs) {
266
+ return Promise.reject(new Error(`Server "${this.name}" failed to start: ${this.lastError}`));
267
+ }
268
+ this.lastGiveUpRetryAt = Date.now();
269
+ }
253
270
  const timeoutMs = this.options.wakeTimeoutMs ?? WAKE_TIMEOUT_MS;
254
271
  const promise = new Promise((resolve, reject) => {
255
272
  const waiter = {
@@ -264,16 +281,20 @@ export class ManagedServer {
264
281
  waiter.timer.unref();
265
282
  this.wakeWaiters.push(waiter);
266
283
  });
267
- if (this.state === 'sleeping') {
268
- this.restartsSinceUse = 0;
284
+ if (this.state === 'sleeping' && this.restartsSinceUse === 0) {
285
+ // Idle sleep or a cache-hydrated boot: no crash history, wake at once.
269
286
  this.backoffMs = this.options.backoffInitialMs ?? BACKOFF_INITIAL_MS;
270
287
  void this.start();
271
288
  }
272
- else if (this.state === 'down') {
273
- // Someone is asking — no point in sitting out the rest of the backoff.
274
- clearTimeout(this.restartTimer);
289
+ else if (this.state === 'sleeping') {
290
+ // Given up earlier: this is the one attempt the window above allows.
291
+ // The backoff and the give-up count carry on from where the crashes
292
+ // left them instead of starting over.
275
293
  void this.start();
276
294
  }
295
+ // 'down': a restart is already scheduled by the backoff. The caller waits
296
+ // for it; cancelling the timer to start now would let requests set the
297
+ // pace of a crash loop.
277
298
  // 'starting': the in-flight start resolves the waiter.
278
299
  return promise;
279
300
  }
@@ -393,6 +414,9 @@ export class ManagedServer {
393
414
  // The start itself opens a full idle window, so a pre-warmed server is not
394
415
  // swept away just before the tool call it was warmed for.
395
416
  this.lastUsedAt = this.startedAt;
417
+ // Coming up is what clears the crash history — being asked for does not.
418
+ this.restartsSinceUse = 0;
419
+ this.lastGiveUpRetryAt = 0;
396
420
  // The child's declared identity, on its way into a file LOG_FILE mirrors
397
421
  // and fail2ban reads: escaped and bounded like any other stranger's text.
398
422
  console.log(`[${this.name}] up (${logSafe(this.serverInfo?.name ?? 'unknown', 100)} ${logSafe(this.serverInfo?.version ?? '', 40)})`.trim());
@@ -671,6 +695,9 @@ export class ManagedServer {
671
695
  // forever. Give up until the next wake, which starts fresh.
672
696
  console.error(`[${this.name}] down (${logSafe(reason, 500)}), giving up until next use after ${this.restartsSinceUse - 1} failed restarts`);
673
697
  this.state = 'sleeping';
698
+ // lastGiveUpRetryAt stays as it is: zero after a first give-up, so the
699
+ // next request retries at once; the time of that retry when the retry
700
+ // is what failed, so the one after it waits for the backoff.
674
701
  this.rejectWakeWaiters(new Error(`Server "${this.name}" failed to start: ${reason}`));
675
702
  return;
676
703
  }
@@ -1,3 +1,5 @@
1
+ import { StringDecoder } from 'node:string_decoder';
2
+ import { logSafe } from '../auth/text.js';
1
3
  import { buildCreateRequest, containerName } from '../sandbox/container-spec.js';
2
4
  import { StreamTransport, setTransportHandlers } from './stream.js';
3
5
  /** Guards against a corrupt header turning into a multi-gigabyte allocation. */
@@ -96,6 +98,9 @@ export class DockerTransport {
96
98
  stream;
97
99
  closing = false;
98
100
  stderrTail = '';
101
+ // A frame boundary can split a multi-byte UTF-8 character; the decoder
102
+ // carries the partial bytes into the next frame.
103
+ stderrDecoder = new StringDecoder('utf8');
99
104
  constructor(server, config, client, writeStderr = line => process.stderr.write(line)) {
100
105
  this.server = server;
101
106
  this.config = config;
@@ -162,6 +167,12 @@ export class DockerTransport {
162
167
  this.closing = true;
163
168
  await this.inner?.close();
164
169
  this.stream?.destroy();
170
+ // A last line without a newline, or a character cut off by the exit, would
171
+ // otherwise go down with the container — and it is often the reason.
172
+ const rest = this.stderrTail + this.stderrDecoder.end();
173
+ this.stderrTail = '';
174
+ if (rest)
175
+ this.writeStderr(`[${this.server}] ${logSafe(rest, Infinity)}\n`);
165
176
  await this.client.removeContainer(containerName(this.server)).catch(() => { });
166
177
  }
167
178
  async ensureImage() {
@@ -177,15 +188,20 @@ export class DockerTransport {
177
188
  * to the process's stderr rather than through console: stdio children use
178
189
  * `stderr: 'inherit'` and bypass console too, which is what keeps LOG_FILE
179
190
  * (read by fail2ban) free of server chatter.
191
+ *
192
+ * The container is untrusted, so each line is escaped like any other text
193
+ * from a child: an ESC or a bare CR would otherwise reach the terminal of
194
+ * whoever watches `docker logs -f`. No length cap from logSafe — the 64 KiB
195
+ * tail flush below already bounds a line.
180
196
  */
181
197
  logStderr(payload) {
182
- this.stderrTail += payload.toString('utf8');
198
+ this.stderrTail += this.stderrDecoder.write(payload);
183
199
  const lines = this.stderrTail.split('\n');
184
200
  this.stderrTail = lines.pop() ?? '';
185
201
  for (const line of lines)
186
- this.writeStderr(`[${this.server}] ${line}\n`);
202
+ this.writeStderr(`[${this.server}] ${logSafe(line, Infinity)}\n`);
187
203
  if (this.stderrTail.length > 64 * 1024) {
188
- this.writeStderr(`[${this.server}] ${this.stderrTail}\n`);
204
+ this.writeStderr(`[${this.server}] ${logSafe(this.stderrTail, Infinity)}\n`);
189
205
  this.stderrTail = '';
190
206
  }
191
207
  }
@@ -4,7 +4,7 @@ import net from 'node:net';
4
4
  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
- import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata } from './provider.js';
7
+ import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata, wellFormedOrUndefined } from './provider.js';
8
8
  import { boundedRedirectFetch } from './redirects.js';
9
9
  /**
10
10
  * Everything the hub needs to authenticate itself to one upstream MCP server.
@@ -70,10 +70,18 @@ export class UpstreamAuth {
70
70
  get record() {
71
71
  return this.store.getUpstreamCredentials(this.identity.serverName, this.fingerprint);
72
72
  }
73
- /** The full pair including the refresh token: this class is the only thing
74
- * allowed to spend it, which is why the provider withholds it. */
73
+ /**
74
+ * The full pair including the refresh token: this class is the only thing
75
+ * allowed to spend it, which is why the provider withholds it.
76
+ *
77
+ * A stored record may predate `saveTokens()`'s validation, or have been
78
+ * written by another process — `wellFormedOrUndefined` treats a malformed
79
+ * one as though nothing were stored, so it can never reach `send()`'s
80
+ * `headers.set()` below and throw with the token embedded in its own
81
+ * message.
82
+ */
75
83
  tokens() {
76
- return this.record?.tokens;
84
+ return wellFormedOrUndefined(this.record?.tokens);
77
85
  }
78
86
  /** The public key the upstream needs, but only when we sign assertions. */
79
87
  async publicJwkIfNeeded() {
@@ -229,7 +237,15 @@ export class UpstreamAuth {
229
237
  const resource = discovery.resourceMetadata?.resource ? new URL(discovery.resourceMetadata.resource) : undefined;
230
238
  if (this.identity.oauth.grant === 'client_credentials') {
231
239
  const tokens = await this.fetchClientCredentialsTokens(discovery, clientInformation, resource);
232
- this.provider().saveTokens(tokens);
240
+ try {
241
+ this.provider().saveTokens(tokens);
242
+ }
243
+ catch (error) {
244
+ // Same failure class as a refused refresh below: a human has to act,
245
+ // and restarting on a timer would only ask the same broken
246
+ // authorization server the same question forever.
247
+ throw new UpstreamLoginRequiredError(this.identity.serverName, error.message);
248
+ }
233
249
  return;
234
250
  }
235
251
  const refreshToken = this.tokens()?.refresh_token;
@@ -78,6 +78,57 @@ export function hubClientMetadata(identity, publicJwk) {
78
78
  }
79
79
  /** RFC 7523 §2.2: assertions are single-use and short-lived. */
80
80
  const ASSERTION_LIFETIME_S = 300;
81
+ /**
82
+ * The shape every token from an upstream has to fit before the hub stores it:
83
+ * printable ASCII without whitespace, at most 16 KiB.
84
+ *
85
+ * RFC 6749 leaves a token's format to the authorization server, but every real
86
+ * one issues an opaque printable string. The hub sends it back as a header
87
+ * value, and `Headers.set()` throws on CR or LF, quoting the value in its
88
+ * message. Stored anyway, such a token would break every later request to that
89
+ * server rather than this one, and put the value into whatever error reached a
90
+ * caller. Past 16 KiB a token is a payload, not an identifier.
91
+ */
92
+ const MAX_TOKEN_BYTES = 16 * 1024;
93
+ const TOKEN_PATTERN = /^[\x21-\x7e]+$/;
94
+ function isWellFormedToken(value) {
95
+ return typeof value === 'string' && value.length > 0 && value.length <= MAX_TOKEN_BYTES && TOKEN_PATTERN.test(value);
96
+ }
97
+ /**
98
+ * Refuses a token pair the upstream cannot have meant, before anything reaches
99
+ * the state file. Neither the log line nor the thrown message carries the
100
+ * value: a malformed token can still be somebody's secret.
101
+ */
102
+ function assertWellFormedTokens(tokens) {
103
+ const fields = [
104
+ ['access_token', tokens.access_token],
105
+ ['refresh_token', tokens.refresh_token]
106
+ ];
107
+ for (const [field, value] of fields) {
108
+ if (field === 'refresh_token' && value === undefined)
109
+ continue;
110
+ if (isWellFormedToken(value))
111
+ continue;
112
+ const length = typeof value === 'string' ? value.length : 0;
113
+ console.error(`mcp-hub: rejected a malformed ${field} from an upstream authorization server (${length} characters)`);
114
+ throw new Error(`upstream returned a malformed ${field}`);
115
+ }
116
+ }
117
+ /**
118
+ * A stored token pair, or undefined when it does not pass the same check. A
119
+ * record may predate the check or come from another process; treating it as
120
+ * absent means the next request refreshes or asks for a login instead of
121
+ * building a header that throws.
122
+ */
123
+ export function wellFormedOrUndefined(tokens) {
124
+ if (!tokens)
125
+ return undefined;
126
+ if (!isWellFormedToken(tokens.access_token))
127
+ return undefined;
128
+ if (tokens.refresh_token !== undefined && !isWellFormedToken(tokens.refresh_token))
129
+ return undefined;
130
+ return tokens;
131
+ }
81
132
  export class UpstreamAuthProvider {
82
133
  identity;
83
134
  store;
@@ -148,7 +199,9 @@ export class UpstreamAuthProvider {
148
199
  ...(typeof information.client_secret === 'string' ? { clientSecret: information.client_secret } : {}),
149
200
  // RFC 7592 credentials, when the upstream issued them — what `upstream
150
201
  // logout` needs to delete the registration again.
151
- ...(typeof information.registration_access_token === 'string'
202
+ // It goes out as a bearer header later, so it has to fit the same shape
203
+ // as any other token; a malformed one is dropped rather than stored.
204
+ ...(isWellFormedToken(information.registration_access_token)
152
205
  ? { registrationAccessToken: information.registration_access_token }
153
206
  : {}),
154
207
  ...(typeof information.registration_client_uri === 'string'
@@ -165,7 +218,7 @@ export class UpstreamAuthProvider {
165
218
  * the whole family. Refresh belongs to UpstreamAuth, which serializes it.
166
219
  */
167
220
  tokens() {
168
- const stored = this.record?.tokens;
221
+ const stored = wellFormedOrUndefined(this.record?.tokens);
169
222
  if (!stored)
170
223
  return undefined;
171
224
  const { refresh_token: _withheld, ...rest } = stored;
@@ -173,9 +226,10 @@ export class UpstreamAuthProvider {
173
226
  }
174
227
  /** The full pair, for the one caller that is allowed to spend it. */
175
228
  storedTokens() {
176
- return this.record?.tokens;
229
+ return wellFormedOrUndefined(this.record?.tokens);
177
230
  }
178
231
  saveTokens(tokens) {
232
+ assertWellFormedTokens(tokens);
179
233
  const expiresIn = typeof tokens.expires_in === 'number' ? tokens.expires_in : undefined;
180
234
  this.patch({
181
235
  tokens: tokens,
@@ -1,3 +1,4 @@
1
+ import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from '@modelcontextprotocol/server';
1
2
  import { logSafe } from '../auth/text.js';
2
3
  /**
3
4
  * Redirects on the data plane of a remote upstream, followed only within the
@@ -22,14 +23,108 @@ import { logSafe } from '../auth/text.js';
22
23
  export const MAX_REDIRECT_HOPS = 3;
23
24
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
24
25
  /**
25
- * Wraps a fetch so that every redirect it would follow is checked first.
26
+ * Ceiling on one message from a remote upstream: the same limit the
27
+ * byte-stream transports apply (`src/transports/stream.ts`), because a remote
28
+ * server is no more trusted than a sandboxed one. It bounds a JSON reply as a
29
+ * whole and an event stream per event, so a long-lived stream is never cut
30
+ * off for its length.
31
+ */
32
+ export const MAX_UPSTREAM_RESPONSE_BYTES = STDIO_DEFAULT_MAX_BUFFER_SIZE;
33
+ function isEventStream(response) {
34
+ const contentType = response.headers.get('content-type');
35
+ return contentType !== null && contentType.split(';')[0].trim().toLowerCase() === 'text/event-stream';
36
+ }
37
+ /**
38
+ * Passes bytes through until more than `maxBytes` have gone by, then errors
39
+ * the stream, so `.json()` or the SDK's SSE reader fails instead of buffering
40
+ * without end. For an event stream the count restarts at every blank line —
41
+ * the event separator, in any mix of CR, LF and CRLF. A CR at the end of a
42
+ * chunk may be the first half of a CRLF, so `sawCR` carries it into the next.
43
+ */
44
+ function budgetedStream(maxBytes, sse) {
45
+ let sinceBoundary = 0;
46
+ let sawCR = false;
47
+ let terminatorRun = 0;
48
+ // One line terminator (`\r`, `\n` or `\r\n`) completed. Two in a row with no
49
+ // content byte between them is a blank line — the event boundary.
50
+ const closeTerminator = () => {
51
+ if (++terminatorRun < 2)
52
+ return;
53
+ sinceBoundary = 0;
54
+ terminatorRun = 0;
55
+ };
56
+ return new TransformStream({
57
+ transform(chunk, controller) {
58
+ if (!sse) {
59
+ sinceBoundary += chunk.byteLength;
60
+ if (sinceBoundary > maxBytes) {
61
+ controller.error(new Error(`upstream response exceeds the ${maxBytes} byte limit`));
62
+ return;
63
+ }
64
+ controller.enqueue(chunk);
65
+ return;
66
+ }
67
+ for (let i = 0; i < chunk.byteLength; i++) {
68
+ sinceBoundary++;
69
+ if (sinceBoundary > maxBytes) {
70
+ controller.error(new Error(`upstream SSE event exceeds the ${maxBytes} byte limit`));
71
+ return;
72
+ }
73
+ const byte = chunk[i];
74
+ if (byte === 0x0d) {
75
+ if (sawCR)
76
+ closeTerminator();
77
+ sawCR = true;
78
+ }
79
+ else if (byte === 0x0a) {
80
+ closeTerminator();
81
+ sawCR = false;
82
+ }
83
+ else {
84
+ if (sawCR)
85
+ closeTerminator();
86
+ sawCR = false;
87
+ terminatorRun = 0;
88
+ }
89
+ }
90
+ controller.enqueue(chunk);
91
+ }
92
+ });
93
+ }
94
+ /**
95
+ * Puts the final response's body under the budget above. A JSON reply that
96
+ * declares more than the limit in `content-length` is refused before reading;
97
+ * an event stream's length describes the connection, not one event, so it is
98
+ * not consulted there.
99
+ */
100
+ function capResponseBody(response, maxBytes) {
101
+ if (!response.body)
102
+ return response;
103
+ const sse = isEventStream(response);
104
+ if (!sse) {
105
+ const declared = Number(response.headers.get('content-length'));
106
+ if (Number.isFinite(declared) && declared > maxBytes) {
107
+ void response.body.cancel().catch(() => { });
108
+ throw new Error(`upstream declared a ${declared} byte response, exceeding the ${maxBytes} byte limit`);
109
+ }
110
+ }
111
+ return new Response(response.body.pipeThrough(budgetedStream(maxBytes, sse)), {
112
+ status: response.status,
113
+ statusText: response.statusText,
114
+ headers: response.headers
115
+ });
116
+ }
117
+ /**
118
+ * Wraps a fetch so that every redirect it would follow is checked first and
119
+ * the response it returns is byte-budgeted.
26
120
  *
27
121
  * `origin` is the configured upstream's origin (`new URL(config.url).origin`).
28
122
  * The returned function has the platform's shape and can be handed to the SDK
29
123
  * transports as their `fetch`; every hop goes through `fetchImpl`, so a wrapper
30
- * that adds headers still adds them on each hop.
124
+ * that adds headers still adds them on each hop. `maxBytes` is a parameter so
125
+ * tests can exercise the budget without megabytes of fixtures.
31
126
  */
32
- export function boundedRedirectFetch(origin, fetchImpl = fetch) {
127
+ export function boundedRedirectFetch(origin, fetchImpl = fetch, maxBytes = MAX_UPSTREAM_RESPONSE_BYTES) {
33
128
  return async (input, init) => {
34
129
  let url = new URL(input instanceof Request ? input.url : String(input));
35
130
  let request = { ...init, redirect: 'manual' };
@@ -38,10 +133,10 @@ export function boundedRedirectFetch(origin, fetchImpl = fetch) {
38
133
  // lives there. Nothing in the hub does, but the shape is the platform's.
39
134
  const response = await fetchImpl(hop === 0 && input instanceof Request ? new Request(input, request) : url, request);
40
135
  if (!REDIRECT_STATUSES.has(response.status))
41
- return response;
136
+ return capResponseBody(response, maxBytes);
42
137
  const location = response.headers.get('location');
43
138
  if (location === null)
44
- return response;
139
+ return capResponseBody(response, maxBytes);
45
140
  // Nothing of the redirect's body is wanted, and holding the stream open
46
141
  // would keep the connection with it.
47
142
  await response.body?.cancel().catch(() => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ni-c/mcp-hub",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
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",
@@ -78,7 +78,7 @@
78
78
  "@types/node": "^26.2.0",
79
79
  "@types/oidc-provider": "^9.11.1",
80
80
  "@types/supertest": "^7.2.1",
81
- "@vitest/coverage-v8": "5.0.0",
81
+ "@vitest/coverage-v8": "5.0.1",
82
82
  "fast-check": "^4.9.0",
83
83
  "oxlint": "^1.80.0",
84
84
  "supertest": "^7.0.0",