@ni-c/mcp-hub 0.11.1 → 0.11.2

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.
@@ -1,6 +1,8 @@
1
1
  import { discoverOAuthServerInfo, exchangeAuthorization, refreshAuthorization, startAuthorization } from '@modelcontextprotocol/client';
2
+ import dns from 'node:dns/promises';
3
+ import net from 'node:net';
2
4
  import { isPrivateAddress, resolvePublicAddress } from '../auth/address.js';
3
- import { guardedRequest } from '../auth/pinned-fetch.js';
5
+ import { boundedResponse, guardedRequest } from '../auth/pinned-fetch.js';
4
6
  import { logSafe } from '../auth/text.js';
5
7
  import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata } from './provider.js';
6
8
  /**
@@ -81,12 +83,13 @@ export class UpstreamAuth {
81
83
  async privateAllowed() {
82
84
  if (this.allowPrivate === undefined) {
83
85
  const hostname = new URL(this.identity.serverUrl).hostname.replace(/^\[|\]$/g, '');
84
- try {
85
- this.allowPrivate = isPrivateAddress(hostname) || (await resolvePublicAddress(hostname).then(() => false, () => true));
86
- }
87
- catch {
88
- this.allowPrivate = true;
89
- }
86
+ // isPrivateAddress accepts numeric addresses only and rejects everything
87
+ // else. Passing a DNS name to it used to enable private access for every
88
+ // named upstream. Failed or mixed DNS answers must not relax the policy.
89
+ const addresses = net.isIP(hostname) ? [{ address: hostname }] : await dns.lookup(hostname, { all: true });
90
+ if (addresses.length === 0)
91
+ throw new Error('upstream hostname resolved to no addresses');
92
+ this.allowPrivate = addresses.every(entry => isPrivateAddress(entry.address));
90
93
  }
91
94
  return this.allowPrivate;
92
95
  }
@@ -95,9 +98,13 @@ export class UpstreamAuth {
95
98
  async asFetch(input, init) {
96
99
  const url = new URL(input instanceof Request ? input.url : String(input));
97
100
  const allowPrivate = await this.privateAllowed();
101
+ if (url.username || url.password || (url.protocol !== 'https:' && !(allowPrivate && url.protocol === 'http:'))) {
102
+ throw new Error('upstream OAuth endpoints must use HTTPS (HTTP is allowed only for private upstreams) without URL credentials');
103
+ }
98
104
  const pinned = await resolvePublicAddress(url.hostname, allowPrivate);
99
105
  if (!pinned) {
100
- return fetch(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(AS_TIMEOUT_MS) });
106
+ const response = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(AS_TIMEOUT_MS) });
107
+ return boundedResponse(response, AS_MAX_BYTES);
101
108
  }
102
109
  return guardedRequest(url, {
103
110
  pinnedAddress: pinned,
@@ -33,7 +33,7 @@ export function credentialFingerprint(identity) {
33
33
  const { oauth } = identity;
34
34
  return crypto
35
35
  .createHash('sha256')
36
- .update(JSON.stringify([identity.serverUrl, oauth.mode, oauth.grant, oauth.clientId ?? null, [...oauth.scopes].sort()]))
36
+ .update(JSON.stringify([identity.serverUrl, oauth.mode, oauth.grant, oauth.clientId ?? null, oauth.scopes.toSorted()]))
37
37
  .digest('hex');
38
38
  }
39
39
  export function callbackUrl(externalUrl) {
@@ -2,6 +2,7 @@ import { Router } from 'express';
2
2
  import { readSignedPayload } from '../auth/signed-token.js';
3
3
  import { renderPage, escapeHtml } from '../auth/page.js';
4
4
  import { logSafe } from '../auth/text.js';
5
+ import { earlyRateLimit } from '../auth/rate-limit.js';
5
6
  import { readSessionCookie } from '../auth/session.js';
6
7
  import { UPSTREAM_CALLBACK_PATH, UPSTREAM_CLIENT_METADATA_PREFIX, UpstreamAuthProvider, clientDocumentId, clientMetadataUrl, hubClientMetadata } from './provider.js';
7
8
  const page = (res, status, title, body) => {
@@ -10,6 +11,7 @@ const page = (res, status, title, body) => {
10
11
  export function createUpstreamRoutes(options) {
11
12
  const { store, registry, supervisor, watcher, externalUrl } = options;
12
13
  const router = Router();
14
+ const secure = new URL(externalUrl).protocol === 'https:';
13
15
  /**
14
16
  * One document per upstream, addressed by a derived identifier.
15
17
  *
@@ -17,28 +19,29 @@ export function createUpstreamRoutes(options) {
17
19
  * hot-reloadable, so a server can become `cimd` long after boot, and an
18
20
  * Express route cannot be added later.
19
21
  */
20
- router.get(`/${UPSTREAM_CLIENT_METADATA_PREFIX}/:id.json`, (req, res) => {
21
- void (async () => {
22
- const wanted = String(req.params.id);
23
- for (const [name, server] of watcher.current) {
24
- if (server.kind !== 'remote' || server.oauth?.mode !== 'cimd')
25
- continue;
26
- if (clientDocumentId(name, store.cookieSecret) !== wanted)
27
- continue;
28
- const identity = { serverName: name, serverUrl: server.url, oauth: server.oauth, externalUrl };
29
- const provider = new UpstreamAuthProvider(identity, store);
30
- // The document must name itself byte-for-byte or the upstream refuses it.
31
- res.json({
32
- client_id: clientMetadataUrl(externalUrl, name, store.cookieSecret),
33
- ...hubClientMetadata(identity, server.oauth.clientAuth === 'private_key_jwt' ? await provider.publicJwk() : undefined)
34
- });
35
- return;
36
- }
37
- res.status(404).json({ error: 'not_found', error_description: 'No upstream publishes a document here' });
38
- })();
22
+ // Unauthenticated, and each hit derives one HMAC per configured upstream and
23
+ // may export a public key: cheap, but not free, and nothing legitimate asks
24
+ // for it more than once per login.
25
+ router.get(`/${UPSTREAM_CLIENT_METADATA_PREFIX}/:id.json`, earlyRateLimit(15 * 60_000, 60, 600), async (req, res) => {
26
+ const wanted = String(req.params.id);
27
+ for (const [name, server] of watcher.current) {
28
+ if (server.kind !== 'remote' || server.oauth?.mode !== 'cimd')
29
+ continue;
30
+ if (clientDocumentId(name, store.cookieSecret) !== wanted)
31
+ continue;
32
+ const identity = { serverName: name, serverUrl: server.url, oauth: server.oauth, externalUrl };
33
+ const provider = new UpstreamAuthProvider(identity, store);
34
+ // The document must name itself byte-for-byte or the upstream refuses it.
35
+ res.json({
36
+ client_id: clientMetadataUrl(externalUrl, name, store.cookieSecret),
37
+ ...hubClientMetadata(identity, server.oauth.clientAuth === 'private_key_jwt' ? await provider.publicJwk() : undefined)
38
+ });
39
+ return;
40
+ }
41
+ res.status(404).json({ error: 'not_found', error_description: 'No upstream publishes a document here' });
39
42
  });
40
43
  router.get(`/${UPSTREAM_CALLBACK_PATH}`, (req, res) => {
41
- void handleCallback(req.query, req.headers.cookie, res);
44
+ return handleCallback(req.query, req.headers.cookie, res);
42
45
  });
43
46
  async function handleCallback(query, cookie, res) {
44
47
  const state = typeof query.state === 'string' ? query.state : '';
@@ -51,7 +54,7 @@ export function createUpstreamRoutes(options) {
51
54
  // Proving the browser belongs to the operator, not just to whoever ended up
52
55
  // holding the redirect. The session cookie rides along because the upstream
53
56
  // sends a top-level navigation and the cookie is SameSite=Lax.
54
- if (readSessionCookie(cookie, store.cookieSecret) === undefined) {
57
+ if (readSessionCookie(cookie, store.cookieSecret, secure) === undefined) {
55
58
  page(res, 401, 'Not signed in', 'Sign in to this hub in the same browser, then run the login again.');
56
59
  return;
57
60
  }
@@ -64,7 +67,9 @@ export function createUpstreamRoutes(options) {
64
67
  }
65
68
  if (typeof query.error === 'string') {
66
69
  console.warn(`mcp-hub: upstream login for ${logSafe(login.serverName)} was declined: ${logSafe(query.error)}`);
67
- page(res, 400, 'Authorization declined', `The upstream reported "${query.error}". Nothing was changed.`);
70
+ // An OAuth error code is a short token; anything else is a page written
71
+ // by whoever answered as the upstream, and it does not get to write ours.
72
+ page(res, 400, 'Authorization declined', `The upstream reported "${logSafe(query.error, 80)}". Nothing was changed.`);
68
73
  return;
69
74
  }
70
75
  const code = typeof query.code === 'string' ? query.code : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ni-c/mcp-hub",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
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",
@@ -52,6 +52,7 @@
52
52
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
53
53
  "test:e2e:watch": "vitest --config vitest.e2e.config.ts",
54
54
  "typecheck:e2e": "tsc -p tsconfig.e2e.json",
55
+ "typecheck:test": "tsc -p tsconfig.test.json",
55
56
  "e2e:record": "tsx e2e/tools/record.ts",
56
57
  "e2e:curate": "tsx e2e/tools/curate.ts",
57
58
  "prepublishOnly": "npm test && npm run build",
@@ -77,13 +78,14 @@
77
78
  "@types/node": "^26.2.0",
78
79
  "@types/oidc-provider": "^9.11.1",
79
80
  "@types/supertest": "^7.2.1",
80
- "@vitest/coverage-v8": "4.1.11",
81
+ "@vitest/coverage-v8": "5.0.0",
82
+ "fast-check": "^4.9.0",
81
83
  "oxlint": "^1.80.0",
82
84
  "supertest": "^7.0.0",
83
85
  "svg-asset-set": "https://github.com/ni-c/svg-asset-set/releases/download/v0.1.0/svg-asset-set-0.1.0.tgz",
84
86
  "tsx": "^4.19.0",
85
87
  "typescript": "^7.0.2",
86
- "vitest": "^4.1.10"
88
+ "vitest": "^5.0.0"
87
89
  },
88
90
  "mcpName": "io.github.ni-c/mcp-hub"
89
91
  }