@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,141 @@ 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.2] - 2026-09-07
11
+
12
+ ### Security
13
+
14
+ - **An unset password was an open login.** With neither `PASSWORD` nor
15
+ `PASSWORD_HASH` configured, `checkPassword` compared the form field with an
16
+ empty buffer — which matches an empty field — so anyone who could reach the
17
+ port approved a client and minted tokens for every server. The hub now starts
18
+ with its login **disabled** in that case: a warning on the first log line,
19
+ `503` with the reason on the sign-in page, and every login refused, so
20
+ nothing can be approved. A `PASSWORD_HASH` that is not a bcrypt hash
21
+ disables the login the same way instead of falling back to `PASSWORD`; the
22
+ warning names the variable and its length, never the value. Refusing to
23
+ *start* was considered and rejected: a health check or a directory crawler
24
+ that runs the image without a password needs the process, not the login,
25
+ and the security outcome is the same.
26
+ - **The private-address guard for an upstream's authorization server was off
27
+ for every DNS-named upstream.** `privateAllowed()` asked `isPrivateAddress`
28
+ about the upstream's *hostname*; that function answers "refuse" for anything
29
+ that is not an address literal, and the caller read "refuse" as "the upstream
30
+ is private, so its authorization server may be too". A public upstream whose
31
+ metadata pointed the token or registration endpoint at `127.0.0.1` or
32
+ `169.254.169.254` was followed there. The hostname is now resolved and the
33
+ upstream counts as private only when every answer is; a resolution failure
34
+ or a mixed answer keeps the guard on; and the endpoints must be https unless
35
+ the upstream is private.
36
+ - **The native-fetch path of the upstream OAuth client read bodies without a
37
+ ceiling.** Literal-IP and private upstreams bypass the pinned transport and
38
+ its 256 KiB cap; `boundedResponse()` applies the same cap there, including to
39
+ error bodies and decoded responses.
40
+ - **A forged `state` on `/upstream/callback` hung the request.** A signature of
41
+ the right length in characters but not in bytes made `timingSafeEqual` throw
42
+ `RangeError`, and the route's `void (async …)` swallowed the rejection, so
43
+ the browser waited forever. `signatureMatches` checks the shape before
44
+ comparing, `readSessionCookie` no longer throws on broken percent-encoding,
45
+ and both routes are async handlers whose rejection Express answers.
46
+ - **PKCE is required of every client, confidential ones included.**
47
+ oidc-provider's default asks for a `code_challenge` only from public
48
+ clients; the discovery document and the standards page have said "S256
49
+ required" since the first release. A secret proves who redeems a code, the
50
+ challenge proves it was the one who asked for it.
51
+ - **The session cookie carries the `__Host-` prefix behind HTTPS**, as
52
+ `docs/guide/security.md` has promised since it was written. The browser then
53
+ refuses the cookie from any other origin, path or domain, so a session
54
+ somebody obtained cannot be fixed into another browser from a sibling host.
55
+ Behind plain http, where the prefix is not settable, the bare name stays.
56
+ - **Prototype names as identifiers no longer fault the authorization server.**
57
+ Every map in `state.json` is keyed by something a caller chose, and
58
+ `JSON.parse` handed them back with `Object.prototype` behind them:
59
+ `state.clients['constructor']` was `Object` itself, a truthy record that is
60
+ not a client, and `/authorize?client_id=constructor` answered
61
+ `500 server_error` with a fault in the log. The maps have a null prototype
62
+ now; an unknown name is `undefined` on every path.
63
+ - **A child's words are cleaned and bounded before they reach a tool result.**
64
+ `list_servers` carried a child's `title` verbatim, `list_tools` and
65
+ `get_tool_schema` its descriptions at any length, and every failure sentence
66
+ quoted the child's error message as received — a bidi override reverses the
67
+ line, a zero-width character hides text a model still reads, an ESC sequence
68
+ lands in the terminal. `childText()` strips the same characters an
69
+ elicitation prompt loses and cuts to the field's size; schemas and
70
+ annotations stay verbatim by contract. The supervisor's `up (name version)`
71
+ and failure lines, the client id in the activity warning and the upstream's
72
+ error on the callback page go through `logSafe` for the same reason.
73
+ - **A URL-mode elicitation is forwarded only for an https page.** The client
74
+ opens that page under the hub's own attribution line; `javascript:`, plain
75
+ http, private-use schemes and credentials in the URL are dropped and counted.
76
+ - **A variable called `__proto__` in a sandbox secrets file vanished without a
77
+ word**, and an elicitation keyed `__proto__` was swallowed: on an ordinary
78
+ object the assignment replaces the prototype instead of adding a key. Both
79
+ maps are null-prototype now.
80
+ - **Rotated upstream secrets and headers were never applied** to a running
81
+ hub: the credential manager was keyed on a fingerprint that deliberately
82
+ survives a secret rotation, so the old configuration stayed alive in memory
83
+ until a restart. The manager is rebuilt when the configuration changes; the
84
+ stored tokens survive, as they should.
85
+
86
+ ### Fixed
87
+
88
+ - **The nightly end-to-end suite had not run since the move to vitest 5.**
89
+ vitest 5 removed the `vitest/reporters` subpath; the budget reporter imported
90
+ its `Reporter` type from there, so `typecheck:e2e` failed before a single
91
+ test started, on every tier (#58). The type now comes from `vitest/node`.
92
+ - `SOCKET_MODE` is validated as three or four octal digits; `abc` used to reach
93
+ `chmodSync` as `NaN` and end the proxy with a stack trace.
94
+ - A `CIMD_ALLOWED_ORIGINS` entry that does not look like an origin is described
95
+ by its length in the startup error rather than printed — it sits a few lines
96
+ from `PASSWORD_HASH` in every compose file.
97
+ - The subscription debounce window flushes early once it holds 1024 distinct
98
+ events, instead of growing with the resource URIs a child announces.
99
+ - `/.well-known/mcp-hub-client/<id>.json` has a rate limit like every other
100
+ unauthenticated route.
101
+ - Four source files carried raw NUL, ESC, VT and FF bytes in string and regex
102
+ literals, which made git treat them as binary and hide every later change
103
+ from review. They are `\uXXXX` escapes now, with the same runtime meaning.
104
+ - Both images no longer ship yarn and corepack, which nothing in them runs,
105
+ and the hub image no longer copies `package-lock.json` into the runtime
106
+ layer, which nothing reads once the install has happened.
107
+
108
+ ### Changed
109
+
110
+ - oxlint's `suspicious` category is on; 95 findings resolved (mostly
111
+ `Array#toSorted()` over copy-and-sort, helpers that captured nothing hoisted
112
+ to module scope, and un-shadowed names). The MCP Transport callbacks are set
113
+ through one `setTransportHandlers()` helper, and `_meta` / `_requestHandlers`
114
+ are allowed as protocol-defined names. No runtime behaviour changed.
115
+ - The documentation says what the code does: the request pipeline parses the
116
+ body before the per-client gate and has no per-IP limiter on MCP routes;
117
+ `call_tool`'s deadline is absolute by default; `/revoke` revokes the
118
+ presented access token; the login and consent pages live under
119
+ `/interaction/<uid>/`; the reserved-name lists gained `jwks`, `interaction`,
120
+ `session` and `userinfo`; there are ten runtime dependencies; an unusable
121
+ upstream token is state `unauthorized`; the ten supervisor clocks in
122
+ `src/timings.ts` are listed as environment variables; and the 100-entry
123
+ ceiling on never-approved registrations is written down.
124
+
125
+ ### CI
126
+
127
+ - **A dependency bump could break the end-to-end suite without any pull
128
+ request noticing.** The E2E workflow ran on pull requests only for changes
129
+ under `e2e/` and its configs, and `ci.yml` never type-checked that tree, so
130
+ the vitest 5 bump was green and the nightly was the first to disagree. The
131
+ test job now runs `typecheck:e2e` (seconds, once per matrix), and changes to
132
+ `package.json` or `package-lock.json` trigger the E2E workflow on the pull
133
+ request itself.
134
+ - The unit tests are type-checked too (`typecheck:test`, `tsconfig.test.json`);
135
+ the twelve errors it surfaced were narrowing casts in tests.
136
+ - `dependency-review-action` (pinned, fails on high) checks what a pull request
137
+ changes in the dependency tree; `npm audit` only sees the tree as it is.
138
+ - The release workflow checks that the CHANGELOG has a section for the tag
139
+ *before* `npm publish`, not after it in the job that creates the GitHub
140
+ release. The nightly image build passes the same daily apt epoch `ci.yml`
141
+ does.
142
+ - The fast suite's client helpers list tools before every call, so the SDK's
143
+ client-side `structuredContent` check runs on every success path.
144
+
10
145
  ## [0.11.1] - 2026-09-06
11
146
 
12
147
  ### Security
package/README.md CHANGED
@@ -1,14 +1,18 @@
1
1
  # mcp-hub
2
2
 
3
+ <!-- badges: start -->
4
+
3
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
+ [![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>
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
+ <br>
4
10
  [![npm version](https://img.shields.io/npm/v/%40ni-c%2Fmcp-hub)](https://www.npmjs.com/package/@ni-c/mcp-hub)
5
- [![npm downloads](https://img.shields.io/npm/dm/%40ni-c%2Fmcp-hub)](https://www.npmjs.com/package/@ni-c/mcp-hub)
6
- [![node](https://img.shields.io/node/v/%40ni-c%2Fmcp-hub)](https://nodejs.org)
7
- [![license](https://img.shields.io/npm/l/%40ni-c%2Fmcp-hub)](LICENSE)
8
- [![container](https://img.shields.io/badge/ghcr.io-ni--c%2Fmcp--hub-blue)](https://github.com/ni-c/mcp-hub/pkgs/container/mcp-hub)
9
- [![docs](https://img.shields.io/badge/docs-mcp--hub.ni--c.de-informational)](https://mcp-hub.ni-c.de)
10
- [![Glama](https://glama.ai/mcp/servers/ni-c/mcp-hub/badges/score.svg)](https://glama.ai/mcp/servers/ni-c/mcp-hub)
11
+ [![container image](https://img.shields.io/badge/ghcr.io-ni--c%2Fmcp--hub-4f46e5?logo=docker&logoColor=white)](https://github.com/ni-c/mcp-hub/pkgs/container/mcp-hub)
12
+ <br>
13
+ [![docs](https://img.shields.io/badge/docs-mcp--hub.ni--c.de-4f46e5?logo=readthedocs&logoColor=white)](https://mcp-hub.ni-c.de)
11
14
  [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)
15
+ <!-- badges: end -->
12
16
 
13
17
  A dual-era [Model Context Protocol](https://modelcontextprotocol.io) (MCP) gateway: it
14
18
  serves many stdio MCP servers from **one container**, published over HTTPS, and speaks
@@ -113,14 +117,14 @@ replaces N containers with one process:
113
117
  re-read on waking; `subscriptions: "off"` withdraws one server's right to
114
118
  push; [details](https://mcp-hub.ni-c.de/guide/subscriptions).
115
119
  - **Lightweight by design**: one Node process, no database (state is one JSON
116
- file plus a signing key under `/data`), six runtime dependencies, and
120
+ file plus a signing key under `/data`), ten runtime dependencies, and
117
121
  multi-arch images — a stated project goal is to run comfortably on a
118
122
  single-board computer like a Raspberry Pi.
119
123
 
120
124
  ## Servers to run behind it
121
125
 
122
126
  The hub is server-agnostic — it serves any stdio MCP server whose entry fits
123
- Claude Code's `mcpServers` format, which is most of them. These eighteen are
127
+ Claude Code's `mcpServers` format, which is most of them. These nineteen are
124
128
  built and maintained alongside it, so their documentation carries the hub entry
125
129
  you need and their tool filters line up with the hub's own `allowTools` /
126
130
  `denyTools`:
@@ -130,6 +134,7 @@ you need and their tool filters line up with the hub's own `allowTools` /
130
134
  | [audiobookshelf-mcp](https://audiobookshelf-mcp.ni-c.de) | `audiobookshelf-mcp` | Audiobookshelf — libraries, listening progress, collections and playlists |
131
135
  | [caldav-mcp](https://caldav-mcp.ni-c.de) | `@ni-c/caldav-mcp` | CalDAV — events, tasks and journal entries on any server that speaks it |
132
136
  | [calibreweb-mcp](https://calibreweb-mcp.ni-c.de) | `calibreweb-mcp` | Calibre-Web — read-only library access through the OPDS feed |
137
+ | [carddav-mcp](https://carddav-mcp.ni-c.de) | `@ni-c/carddav-mcp` | CardDAV — contacts, groups and photos on any server that speaks it |
133
138
  | [freshrss-mcp](https://freshrss-mcp.ni-c.de) | `@ni-c/freshrss-mcp` | FreshRSS — feeds, categories and articles as plain text, not stream ids |
134
139
  | [google-search-console-mcp](https://google-search-console-mcp.ni-c.de) | `@ni-c/google-search-console-mcp` | Google Search Console — properties, sitemaps, search analytics, URL inspection |
135
140
  | [healthchecks-mcp](https://healthchecks-mcp.ni-c.de) | `healthchecks-mcp` | Healthchecks — cron and uptime checks, and why one failed |
@@ -148,7 +153,7 @@ you need and their tool filters line up with the hub's own `allowTools` /
148
153
 
149
154
  Each one runs perfectly well on its own over stdio. Put them behind the hub when
150
155
  you want them reachable from a client that cannot spawn a local process, or when
151
- you would rather register one connector than eighteen.
156
+ you would rather register one connector than nineteen.
152
157
 
153
158
  ## Configuration
154
159
 
@@ -199,7 +204,8 @@ working. `allowTools` / `denyTools` cut finer and apply to every kind of
199
204
  server: a filtered tool is absent from both `tools/list` and `/hub`, and is
200
205
  refused if called anyway — before the server is woken.
201
206
  Reserved names: `mcp`, `hub`, `authorize`, `token`, `register`,
202
- `login`, `consent`, `health`, `livez`, `revoke`, `upstream`, `.well-known`.
207
+ `login`, `consent`, `health`, `livez`, `revoke`, `jwks`, `interaction`,
208
+ `session`, `userinfo`, `upstream`, `.well-known`.
203
209
 
204
210
  All stdio children share the hub's Unix user and can read its mounted files.
205
211
  Only install fully trusted stdio servers. A server with a different trust level
@@ -232,6 +238,13 @@ USER node
232
238
 
233
239
  ### Environment
234
240
 
241
+ Without a non-empty `PASSWORD` or a bcrypt `PASSWORD_HASH` the HTTP hub still
242
+ starts, but its login is disabled: the startup log says so, the sign-in page
243
+ answers `503` with the reason, and no client can be approved — so no token can
244
+ be issued. A configured hash takes precedence, and a hash that is not a bcrypt
245
+ hash disables the login the same way rather than falling back to `PASSWORD`.
246
+ The local `--stdio` mode uses neither variable.
247
+
235
248
  | Variable | Required | Description |
236
249
  | ------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
237
250
  | `EXTERNAL_URL` | yes | Public base URL, e.g. `https://mcp.example.net` (no path) |
@@ -419,7 +432,7 @@ immediately. Per-client recipes:
419
432
  | `/hub` | Bearer | aggregate endpoint with the 6 meta-tools |
420
433
  | `/livez` | none | minimal process liveness (`200`) |
421
434
  | `/health` | Bearer | per-server status (`200` all up / `503` degraded) |
422
- | `/authorize`, `/token`, `/register`, `/login`, `/consent`, `/revoke` | — | OAuth 2.1 · CIMD + DCR |
435
+ | `/authorize`, `/token`, `/register`, `/revoke`, `/interaction/<uid>/…` | — | OAuth 2.1 · CIMD + DCR; the login and consent pages live under `/interaction/` |
423
436
  | `/register/<client_id>` | registration token | RFC 7592: a client reads, changes or removes its own registration |
424
437
  | `/upstream/callback` | signed state + hub session | where an upstream returns after `upstream login` |
425
438
  | `/.well-known/mcp-hub-client/<id>.json` | none | the hub's own client metadata document, one per `cimd` upstream |
@@ -443,7 +456,7 @@ immediately. Per-client recipes:
443
456
  rotate; replaying one that was already rotated away is treated as a leak and
444
457
  revokes the whole grant, access tokens included.
445
458
  - Upstream auth is fully decoupled from the hub's own OAuth: an expired
446
- upstream token just marks that one server `down` (503 on its path, visible
459
+ upstream token just marks that one server `unauthorized` (503 on its path, visible
447
460
  in `/health`) — clients never see the upstream's 401.
448
461
  - One login can approve multiple connectors, but each token is valid only for
449
462
  its requested server or `/hub`. Registration remains open as the MCP
package/dist/admin.js CHANGED
@@ -31,6 +31,10 @@ function usage() {
31
31
  ].join('\n'));
32
32
  process.exit(2);
33
33
  }
34
+ function fail(message) {
35
+ console.error(message);
36
+ process.exit(2);
37
+ }
34
38
  function flag(args, name) {
35
39
  const index = args.indexOf(`--${name}`);
36
40
  if (index === -1)
@@ -238,10 +242,6 @@ if (group === 'upstream') {
238
242
  // value the hub recorded at boot is normally the one that applies.
239
243
  const externalUrl = process.env.EXTERNAL_URL ?? store.getExternalUrl();
240
244
  const name = rest.find(argument => !argument.startsWith('--'));
241
- const fail = message => {
242
- console.error(message);
243
- process.exit(2);
244
- };
245
245
  if (action === 'list' || action === 'status') {
246
246
  try {
247
247
  console.log(JSON.stringify(upstreamStatus(store, config, action === 'status' ? name : undefined), null, 2));
@@ -79,7 +79,7 @@ export async function resolvePublicAddress(hostname, allowPrivate = false) {
79
79
  addresses = await dns.lookup(hostname, { all: true });
80
80
  }
81
81
  catch (error) {
82
- throw new Error(`cannot resolve ${hostname}: ${error.message}`);
82
+ throw new Error(`cannot resolve ${hostname}: ${error.message}`, { cause: error });
83
83
  }
84
84
  if (addresses.some(entry => isPrivateAddress(entry.address))) {
85
85
  throw new Error(`${hostname} resolves to a private address`);
@@ -1,14 +1,20 @@
1
- import crypto from 'node:crypto';
2
- import bcrypt from 'bcryptjs';
3
1
  import express, { Router } from 'express';
4
2
  import { renderConsentPage } from '../consent-page.js';
5
3
  import { allowFormActionTo, authSecurityHeaders } from '../headers.js';
6
4
  import { renderLoginPage } from '../login-page.js';
7
5
  import { earlyRateLimit, LoginRateLimiter } from '../rate-limit.js';
6
+ import { operatorCredential } from '../password.js';
8
7
  import { isLoopbackOnly } from '../redirect-uri.js';
9
- import { createSessionCookie, csrfToken, readSessionCookie, SESSION_COOKIE, SESSION_TTL_MS, verifyCsrfToken } from '../session.js';
8
+ import { createSessionCookie, csrfToken, readSessionCookie, SESSION_TTL_MS, sessionCookieName, verifyCsrfToken } from '../session.js';
10
9
  import { logSafe } from '../text.js';
11
10
  import { HUB_ACCOUNT_ID } from './provider.js';
11
+ /** Express 5 forwards a rejected handler promise on its own; this makes that explicit and typed. */
12
+ const asyncHandler = (handler) => (req, res, next) => {
13
+ handler(req, res, next).catch(next);
14
+ };
15
+ function expired(res, status, message) {
16
+ res.status(status).type('html').send(`<p>${message}</p>`);
17
+ }
12
18
  /**
13
19
  * The hub's own login and consent pages, driven by oidc-provider's interaction
14
20
  * loop instead of by HubOAuthProvider's hand-rolled one.
@@ -40,13 +46,11 @@ export function createOidcInteractionRoutes(options) {
40
46
  // the single page that has a redirect target to allow.
41
47
  router.use(authSecurityHeaders);
42
48
  const secure = new URL(options.externalUrl).protocol === 'https:';
43
- const checkPassword = (password) => {
44
- if (options.passwordHash)
45
- return bcrypt.compareSync(password, options.passwordHash);
46
- const expected = Buffer.from(options.password ?? '');
47
- const given = Buffer.from(password);
48
- return expected.length === given.length && crypto.timingSafeEqual(expected, given);
49
- };
49
+ // Missing or unusable means nobody signs in — never that anybody does. The
50
+ // empty-buffer comparison this replaced let an empty form field through when
51
+ // no password was configured, which approved the client and minted tokens.
52
+ const credential = operatorCredential(options);
53
+ const disabledNotice = 'Sign-in is disabled: this hub has no usable operator password configured.';
50
54
  /** What the page may say about who is asking, and what it must not claim. */
51
55
  const identityOf = async (clientId, resource) => {
52
56
  const client = await provider.Client.find(clientId);
@@ -60,16 +64,13 @@ export function createOidcInteractionRoutes(options) {
60
64
  loopbackOnly: isLoopbackOnly(metadata?.redirect_uris)
61
65
  };
62
66
  };
63
- const expired = (res, status, message) => {
64
- res.status(status).type('html').send(`<p>${message}</p>`);
65
- };
66
67
  // The two POSTs below carry a limiter and this GET did not, which is the
67
68
  // asymmetry CodeQL noticed and it was right. The page is unauthenticated by
68
69
  // construction — the uid is all a caller has — and rendering it costs a
69
70
  // provider store lookup, so a flood is cheap to send and not free to serve.
70
71
  // The same window as its siblings; a person opening a login page a hundred
71
72
  // times in fifteen minutes has a different problem.
72
- router.get('/interaction/:uid', earlyRateLimit(15 * 60_000, 100, 500), async (req, res, next) => {
73
+ router.get('/interaction/:uid', earlyRateLimit(15 * 60_000, 100, 500), asyncHandler(async (req, res) => {
73
74
  try {
74
75
  const details = await provider.interactionDetails(req, res);
75
76
  const params = details.params;
@@ -77,10 +78,13 @@ export function createOidcInteractionRoutes(options) {
77
78
  const identity = await identityOf(String(params.client_id), params.resource);
78
79
  allowFormActionTo(res, redirectUri);
79
80
  if (details.prompt.name === 'login') {
80
- res.status(200).type('html').send(renderLoginPage(details.uid, redirectUri, identity));
81
+ res
82
+ .status(credential.enabled ? 200 : 503)
83
+ .type('html')
84
+ .send(renderLoginPage(details.uid, redirectUri, identity, credential.enabled ? undefined : disabledNotice));
81
85
  return;
82
86
  }
83
- const session = readSessionCookie(req.headers.cookie, store.cookieSecret);
87
+ const session = readSessionCookie(req.headers.cookie, store.cookieSecret, secure);
84
88
  if (!session) {
85
89
  expired(res, 401, 'Session expired. Close this window and connect again.');
86
90
  return;
@@ -94,10 +98,9 @@ export function createOidcInteractionRoutes(options) {
94
98
  // interactionDetails throws for an unknown or expired uid, which is not
95
99
  // an error worth a stack trace: the window was left open too long.
96
100
  expired(res, 400, 'Authorization request expired. Close this window and connect again.');
97
- void next;
98
101
  }
99
- });
100
- router.post('/interaction/:uid/login', earlyRateLimit(15 * 60_000, 100, 500), express.urlencoded({ extended: false }), async (req, res) => {
102
+ }));
103
+ router.post('/interaction/:uid/login', earlyRateLimit(15 * 60_000, 100, 500), express.urlencoded({ extended: false }), asyncHandler(async (req, res) => {
101
104
  const ip = req.ip ?? 'unknown';
102
105
  const { password, request } = req.body;
103
106
  let details;
@@ -119,7 +122,15 @@ export function createOidcInteractionRoutes(options) {
119
122
  }
120
123
  const params = details.params;
121
124
  const redirectUri = String(params.redirect_uri ?? '');
122
- if (typeof password !== 'string' || !checkPassword(password)) {
125
+ if (!credential.enabled) {
126
+ // Not a failed guess: there is nothing to guess. Counting it would let
127
+ // a misconfigured hub fill the fail2ban log with its own visitors.
128
+ const identity = await identityOf(String(params.client_id), params.resource);
129
+ allowFormActionTo(res, redirectUri);
130
+ res.status(503).type('html').send(renderLoginPage(details.uid, redirectUri, identity, disabledNotice));
131
+ return;
132
+ }
133
+ if (typeof password !== 'string' || !credential.check(password)) {
123
134
  rateLimiter.recordFailure(ip);
124
135
  console.warn(`mcp-hub: authentication failure from ${logSafe(ip)}`);
125
136
  const identity = await identityOf(String(params.client_id), params.resource);
@@ -135,7 +146,11 @@ export function createOidcInteractionRoutes(options) {
135
146
  const clientName = client?.metadata()?.client_name;
136
147
  store.saveApproval(clientId, redirectUri, typeof clientName === 'string' ? clientName : undefined);
137
148
  console.log(`mcp-hub: approved OAuth client ${logSafe(clientId)} for ${logSafe(redirectUri)}`);
138
- res.cookie(SESSION_COOKIE, createSessionCookie(store.cookieSecret), {
149
+ // `__Host-` behind HTTPS: the browser then refuses the cookie from any
150
+ // other origin, path or domain, so nobody can fix a session into this
151
+ // browser from a sibling host. No `domain`, and `path: '/'`, are what the
152
+ // prefix requires.
153
+ res.cookie(sessionCookieName(secure), createSessionCookie(store.cookieSecret), {
139
154
  httpOnly: true,
140
155
  secure,
141
156
  sameSite: 'lax',
@@ -143,8 +158,8 @@ export function createOidcInteractionRoutes(options) {
143
158
  path: '/'
144
159
  });
145
160
  await provider.interactionFinished(req, res, { login: { accountId: HUB_ACCOUNT_ID } }, { mergeWithLastSubmission: false });
146
- });
147
- router.post('/interaction/:uid/consent', earlyRateLimit(15 * 60_000, 100, 500), express.urlencoded({ extended: false }), async (req, res) => {
161
+ }));
162
+ router.post('/interaction/:uid/consent', earlyRateLimit(15 * 60_000, 100, 500), express.urlencoded({ extended: false }), asyncHandler(async (req, res) => {
148
163
  const { request, csrf, action } = req.body;
149
164
  let details;
150
165
  try {
@@ -158,7 +173,7 @@ export function createOidcInteractionRoutes(options) {
158
173
  expired(res, 400, 'Authorization request expired. Close this window and connect again.');
159
174
  return;
160
175
  }
161
- const session = readSessionCookie(req.headers.cookie, store.cookieSecret);
176
+ const session = readSessionCookie(req.headers.cookie, store.cookieSecret, secure);
162
177
  if (!session) {
163
178
  expired(res, 401, 'Session expired. Close this window and connect again.');
164
179
  return;
@@ -181,7 +196,7 @@ export function createOidcInteractionRoutes(options) {
181
196
  // The grant itself is minted by loadExistingGrant on the resumed request,
182
197
  // which is the same code path an already-approved client takes.
183
198
  await provider.interactionFinished(req, res, { consent: {} }, { mergeWithLastSubmission: true });
184
- });
199
+ }));
185
200
  return router;
186
201
  }
187
202
  //# sourceMappingURL=interactions.js.map
@@ -157,6 +157,18 @@ export function buildOidcProvider(store, options) {
157
157
  * and an authentication mechanism nobody uses is only an attack surface.
158
158
  */
159
159
  clientAuthMethods: ['client_secret_post', 'none', 'private_key_jwt'],
160
+ /**
161
+ * PKCE for every client, not only the public ones.
162
+ *
163
+ * oidc-provider's default requires it when `token_endpoint_auth_method`
164
+ * is `none` and lets a confidential client skip it. OAuth 2.1 and the MCP
165
+ * authorization specification require it of all clients, the discovery
166
+ * document and docs/reference/standards.md have said "S256 required"
167
+ * since the first release — and a confidential client's secret proves who
168
+ * redeems a code, not that the redeemer is the one who asked for it. The
169
+ * challenge is what binds the two halves of the flow.
170
+ */
171
+ pkce: { required: () => true },
160
172
  // The set the hub advertised before. oidc-provider's default is narrower
161
173
  // (one algorithm per family), which would refuse a private_key_jwt client
162
174
  // that signs with RS384 -- something the old document promised to accept.
@@ -0,0 +1,50 @@
1
+ import crypto from 'node:crypto';
2
+ import bcrypt from 'bcryptjs';
3
+ /**
4
+ * The one operator credential the HTTP hub has, and what to do when it is
5
+ * missing or unusable.
6
+ *
7
+ * The answer is "nobody can sign in", not "refuse to start". Both keep every
8
+ * token out of reach — approving a client is the only way to one, and approval
9
+ * needs the password — so the security outcome is the same. What differs is
10
+ * what an operator, an inspector or a health check sees: a container that
11
+ * boots, serves `/livez` and the discovery documents, and says loudly in its
12
+ * log and on its login page why nobody can get further, rather than a process
13
+ * that exits before it has said anything. Directory crawlers that start the
14
+ * image without a password to see what it offers (Glama does) fall in the
15
+ * same category as a health check: they need the process, not the login.
16
+ *
17
+ * `PASSWORD_HASH` wins over `PASSWORD` when both are set, and a hash that is
18
+ * not a bcrypt hash disables the login rather than falling back to the
19
+ * plain-text sibling: a hash was configured on purpose, and silently using the
20
+ * weaker variable instead would be a surprise in the wrong direction.
21
+ */
22
+ /** `$2a$`, `$2b$` or `$2y$`, a cost of 04–31, then 53 characters of salt+digest. */
23
+ const BCRYPT_HASH = /^\$2[aby]\$(?:0[4-9]|[12]\d|3[01])\$[./A-Za-z0-9]{53}$/;
24
+ export function operatorCredential(options) {
25
+ const hash = options.passwordHash?.trim();
26
+ if (hash) {
27
+ if (!BCRYPT_HASH.test(hash)) {
28
+ return disabled(`PASSWORD_HASH is not a bcrypt hash (expected $2a$, $2b$ or $2y$, a cost and 53 characters, ${hash.length} characters given) — the operator login is disabled, no client can be approved`);
29
+ }
30
+ return { enabled: true, check: password => bcrypt.compareSync(password, hash) };
31
+ }
32
+ // Trimmed for the test only: a value of whitespace is an unset variable that
33
+ // somebody quoted, not a password. The comparison itself is on the raw value.
34
+ const password = options.password ?? '';
35
+ if (password.trim().length === 0) {
36
+ return disabled('neither PASSWORD_HASH nor PASSWORD is set — the operator login is disabled, no client can be approved');
37
+ }
38
+ const expected = Buffer.from(password);
39
+ return {
40
+ enabled: true,
41
+ check: given => {
42
+ const buffer = Buffer.from(given);
43
+ return buffer.length === expected.length && crypto.timingSafeEqual(buffer, expected);
44
+ }
45
+ };
46
+ }
47
+ function disabled(problem) {
48
+ return { enabled: false, problem, check: () => false };
49
+ }
50
+ //# sourceMappingURL=password.js.map
@@ -28,6 +28,36 @@ export function pinnedLookup(address) {
28
28
  done(null, address, family);
29
29
  });
30
30
  }
31
+ /** Apply the same byte ceiling to native fetch, including decoded/compressed
32
+ * bodies and error responses. Literal IPs and private upstreams use this path. */
33
+ export async function boundedResponse(response, maxBytes) {
34
+ const reader = response.body?.getReader();
35
+ const chunks = [];
36
+ let size = 0;
37
+ try {
38
+ if (Number(response.headers.get('content-length')) > maxBytes) {
39
+ throw new Error(`response exceeds ${maxBytes} bytes`);
40
+ }
41
+ if (!reader)
42
+ return response;
43
+ for (;;) {
44
+ const { done, value } = await reader.read();
45
+ if (done)
46
+ break;
47
+ size += value.byteLength;
48
+ if (size > maxBytes)
49
+ throw new Error(`response exceeds ${maxBytes} bytes`);
50
+ chunks.push(value);
51
+ }
52
+ const headers = new Headers(response.headers);
53
+ headers.delete('content-encoding');
54
+ headers.delete('content-length');
55
+ return new Response(Buffer.concat(chunks), { status: response.status, statusText: response.statusText, headers });
56
+ }
57
+ finally {
58
+ await reader?.cancel().catch(() => { });
59
+ }
60
+ }
31
61
  export async function guardedRequest(url, options) {
32
62
  // Only https reaches this in production — the caller rejects anything else
33
63
  // before resolving. Plain http is here so the transport can be exercised
@@ -40,9 +40,6 @@ export function createRegistrationManagementRoutes(options) {
40
40
  // can register; these routes sit in front of it and need their own. Opening
41
41
  // them is safe: the credential is a bearer token in a header, never a cookie,
42
42
  // so no browser attaches it to a cross-site request on its own.
43
- const allowCrossOrigin = (res) => {
44
- res.set('Access-Control-Allow-Origin', '*');
45
- };
46
43
  router.options(path, limit, (_req, res) => {
47
44
  allowCrossOrigin(res);
48
45
  res.set({
@@ -137,10 +134,13 @@ export function createRegistrationManagementRoutes(options) {
137
134
  });
138
135
  return router;
139
136
  }
137
+ function allowCrossOrigin(res) {
138
+ res.set('Access-Control-Allow-Origin', '*');
139
+ }
140
140
  /** Order is not part of the meaning of a redirect URI list. */
141
141
  function sameUris(before, after) {
142
- const a = [...(before ?? [])].sort();
143
- const b = [...(after ?? [])].sort();
142
+ const a = (before ?? []).toSorted();
143
+ const b = (after ?? []).toSorted();
144
144
  return a.length === b.length && a.every((uri, index) => uri === b[index]);
145
145
  }
146
146
  //# sourceMappingURL=registration.js.map
@@ -2,6 +2,20 @@ import { sign, signatureMatches } from './signed-token.js';
2
2
  /** Deliberately short: it only has to outlive a connector's authorization. */
3
3
  export const SESSION_TTL_MS = 30 * 60_000;
4
4
  export const SESSION_COOKIE = 'mcp_hub_session';
5
+ /**
6
+ * The cookie's name, which behind HTTPS carries the `__Host-` prefix.
7
+ *
8
+ * The prefix is a promise the browser enforces: such a cookie is only accepted
9
+ * from a secure origin, with `Path=/` and without a `Domain`, and can therefore
10
+ * not be planted by a sibling subdomain or over plain http on the same host.
11
+ * The value is signed either way, so a planted cookie could not be a forged
12
+ * session — but it could be a *real* one an attacker obtained, fixed into
13
+ * somebody else's browser. Behind plain http (a development hub, the test
14
+ * suite) the prefix is not settable at all, so the bare name is used there.
15
+ */
16
+ export function sessionCookieName(secure) {
17
+ return secure ? `__Host-${SESSION_COOKIE}` : SESSION_COOKIE;
18
+ }
5
19
  /**
6
20
  * The operator's browser session, carried entirely by the client.
7
21
  *
@@ -19,13 +33,26 @@ export function createSessionCookie(secret) {
19
33
  const expires = String(Date.now() + SESSION_TTL_MS);
20
34
  return `${expires}.${sign(expires, secret)}`;
21
35
  }
22
- /** The verified cookie value, or undefined when absent, forged or expired. */
23
- export function readSessionCookie(cookieHeader, secret) {
24
- const match = cookieHeader?.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`));
36
+ /**
37
+ * The verified cookie value, or undefined when absent, forged or expired.
38
+ *
39
+ * Only the name for this deployment is read: behind HTTPS the bare name is not
40
+ * the session cookie, whatever it carries.
41
+ */
42
+ export function readSessionCookie(cookieHeader, secret, secure = false) {
43
+ const match = cookieHeader?.match(new RegExp(`(?:^|;\\s*)${sessionCookieName(secure)}=([^;]+)`));
25
44
  if (!match)
26
45
  return undefined;
27
- const value = decodeURIComponent(match[1]);
28
- const [expires, signature] = value.split('.');
46
+ let value;
47
+ try {
48
+ value = decodeURIComponent(match[1]);
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ const [expires, signature, extra] = value.split('.');
54
+ if (extra !== undefined)
55
+ return undefined;
29
56
  if (!expires || !signature)
30
57
  return undefined;
31
58
  if (!signatureMatches(expires, signature, secret))
@@ -21,7 +21,9 @@ export function sign(value, secret) {
21
21
  */
22
22
  export function signatureMatches(value, signature, secret) {
23
23
  const expected = sign(value, secret);
24
- if (signature.length !== expected.length)
24
+ // Character lengths alone do not bound the UTF-8 buffers timingSafeEqual
25
+ // compares. A multibyte forgery must be refused, never throw a RangeError.
26
+ if (!/^[A-Za-z0-9_-]{43}$/.test(signature))
25
27
  return false;
26
28
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
27
29
  }
@@ -34,7 +36,9 @@ export function signPayload(payload, secret) {
34
36
  /** Undefined for anything that was not signed with this secret, or is not the
35
37
  * shape it claims. Never throws on malformed input. */
36
38
  export function readSignedPayload(token, secret) {
37
- const [encoded, signature] = token.split('.');
39
+ const [encoded, signature, extra] = token.split('.');
40
+ if (extra !== undefined)
41
+ return undefined;
38
42
  if (!encoded || !signature)
39
43
  return undefined;
40
44
  if (!signatureMatches(encoded, signature, secret))