@ni-c/mcp-hub 0.11.2 → 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,116 @@ 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
+
84
+ ## [0.11.3] - 2026-09-12
85
+
86
+ ### Security
87
+
88
+ - **A remote upstream could redirect the hub anywhere.** The MCP requests to
89
+ a remote server — `tools/call`, the event stream, `subscriptions/listen` —
90
+ went out with the platform's default of following every redirect, and
91
+ neither the SDK's transports nor the hub's own fetch wrappers said
92
+ otherwise. An upstream answering with a `Location` on an internal address
93
+ had the hub connect there, send the JSON-RPC body and every configured
94
+ header except `Authorization` and `Cookie`, and read the answer as MCP. The
95
+ guard the authorization server always had now covers the data plane too: a
96
+ redirect is followed only within the origin of the configured `url` (same
97
+ scheme, host and port — `/mcp` to `/mcp/` keeps working), at most three
98
+ times, with the platform's method and body rules; anything else fails the
99
+ request with a reason that names the refused origin and nothing more.
100
+ Reported by the 2026-09-12 internal review.
101
+
102
+ ### Fixed
103
+
104
+ - **A peer could make the hub copy its input quadratically.** The byte-stream
105
+ transports — `type: "unix"`, `"tcp"` and the docker attach stream — appended
106
+ every chunk to one growing buffer, so a line or a frame delivered in small
107
+ pieces cost the event loop a copy of everything so far, per piece: ten
108
+ megabytes in 4 KiB pieces took close to a second, a 16 MiB docker frame two
109
+ and a half, and a peer chooses its piece size. Both decoders now collect the
110
+ pieces and join them once, which is linear; the existing caps (10 MiB per
111
+ line, 16 MiB per frame) are unchanged, and a property test holds the framing
112
+ identical however the bytes are cut.
113
+ - **A remote server that failed to connect was logged as "connection closed".**
114
+ The SDK closes the transport before `connect()` rejects, so the generic
115
+ close reason won the race and the rejection — which names the cause and may
116
+ carry a verdict no restart can fix — was thrown away. The exit is now
117
+ reported once, from the rejection, so a refused redirect, a TLS failure or
118
+ an unauthorized upstream reads as what it is.
119
+
10
120
  ## [0.11.2] - 2026-09-07
11
121
 
12
122
  ### 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.