@ni-c/mcp-hub 0.11.0 → 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.
Files changed (89) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +26 -11
  3. package/dist/admin.js +4 -4
  4. package/dist/auth/address.js +1 -1
  5. package/dist/auth/oidc/interactions.js +40 -25
  6. package/dist/auth/oidc/provider.js +12 -0
  7. package/dist/auth/password.js +50 -0
  8. package/dist/auth/pinned-fetch.js +30 -0
  9. package/dist/auth/registration.js +5 -5
  10. package/dist/auth/session.js +32 -5
  11. package/dist/auth/signed-token.js +6 -2
  12. package/dist/auth/store.js +37 -29
  13. package/dist/child-text.js +39 -0
  14. package/dist/config.js +1 -5
  15. package/dist/docker-proxy/index.js +9 -2
  16. package/dist/docker-proxy/secrets-watcher.js +1 -1
  17. package/dist/docker-proxy/secrets.js +5 -1
  18. package/dist/docker-proxy/server.js +14 -0
  19. package/dist/elicitation.js +0 -0
  20. package/dist/forward.js +0 -0
  21. package/dist/hub.js +14 -7
  22. package/dist/index.js +19 -2
  23. package/dist/stdio.js +3 -1
  24. package/dist/subscriptions.js +11 -0
  25. package/dist/supervisor.js +23 -15
  26. package/dist/transports/docker.js +11 -9
  27. package/dist/transports/stream.js +10 -0
  28. package/dist/upstream/auth.js +15 -8
  29. package/dist/upstream/provider.js +1 -1
  30. package/dist/upstream/routes.js +27 -22
  31. package/package.json +6 -3
  32. package/dist/admin.js.map +0 -1
  33. package/dist/auth/address.js.map +0 -1
  34. package/dist/auth/api-tokens.js.map +0 -1
  35. package/dist/auth/cimd.js.map +0 -1
  36. package/dist/auth/consent-page.js.map +0 -1
  37. package/dist/auth/headers.js.map +0 -1
  38. package/dist/auth/login-page.js.map +0 -1
  39. package/dist/auth/oidc/adapter.js.map +0 -1
  40. package/dist/auth/oidc/interactions.js.map +0 -1
  41. package/dist/auth/oidc/mount.js.map +0 -1
  42. package/dist/auth/oidc/provider.js.map +0 -1
  43. package/dist/auth/oidc/quirks.js.map +0 -1
  44. package/dist/auth/oidc/verifier.js.map +0 -1
  45. package/dist/auth/page.js.map +0 -1
  46. package/dist/auth/pinned-fetch.js.map +0 -1
  47. package/dist/auth/protected-resource.js.map +0 -1
  48. package/dist/auth/rate-limit.js.map +0 -1
  49. package/dist/auth/redirect-uri.js.map +0 -1
  50. package/dist/auth/registration.js.map +0 -1
  51. package/dist/auth/resource.js.map +0 -1
  52. package/dist/auth/session.js.map +0 -1
  53. package/dist/auth/signed-token.js.map +0 -1
  54. package/dist/auth/store.js.map +0 -1
  55. package/dist/auth/text.js.map +0 -1
  56. package/dist/config.js.map +0 -1
  57. package/dist/docker-proxy/index.js.map +0 -1
  58. package/dist/docker-proxy/policy.js.map +0 -1
  59. package/dist/docker-proxy/secrets-watcher.js.map +0 -1
  60. package/dist/docker-proxy/secrets.js.map +0 -1
  61. package/dist/docker-proxy/server.js.map +0 -1
  62. package/dist/elicitation.js.map +0 -1
  63. package/dist/forward.js.map +0 -1
  64. package/dist/health.js.map +0 -1
  65. package/dist/hub.js.map +0 -1
  66. package/dist/index.js.map +0 -1
  67. package/dist/limits.js.map +0 -1
  68. package/dist/logfile.js.map +0 -1
  69. package/dist/main-module.js.map +0 -1
  70. package/dist/mcp-limits.js.map +0 -1
  71. package/dist/mount-check.js.map +0 -1
  72. package/dist/proxy.js.map +0 -1
  73. package/dist/sandbox/container-spec.js.map +0 -1
  74. package/dist/sandbox/docker-client.js.map +0 -1
  75. package/dist/sandbox/policy-protocol.js.map +0 -1
  76. package/dist/stdio.js.map +0 -1
  77. package/dist/subscriptions.js.map +0 -1
  78. package/dist/supervisor.js.map +0 -1
  79. package/dist/timings.js.map +0 -1
  80. package/dist/tool-cache.js.map +0 -1
  81. package/dist/tool-filter.js.map +0 -1
  82. package/dist/transports/docker.js.map +0 -1
  83. package/dist/transports/socket.js.map +0 -1
  84. package/dist/transports/stream.js.map +0 -1
  85. package/dist/upstream/auth.js.map +0 -1
  86. package/dist/upstream/login.js.map +0 -1
  87. package/dist/upstream/provider.js.map +0 -1
  88. package/dist/upstream/routes.js.map +0 -1
  89. package/dist/version.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,179 @@ 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
+
145
+ ## [0.11.1] - 2026-09-06
146
+
147
+ ### Security
148
+
149
+ - **The published image shipped a vulnerable `libssh2`, and no amount of
150
+ rescanning was going to change that.** `libssh2-1 1.10.0-3+b1` — pulled in as
151
+ a dependency of `git`, which the image installs so servers can be fetched
152
+ straight from a repository — is affected by CVE-2026-7598 and CVE-2026-58050,
153
+ both HIGH, both fixed in `bookworm-security` as `1.10.0-3+deb12u1` before the
154
+ scanner ever reported them. The fix could not reach the image because the apt
155
+ layer was never rebuilt: the base digest is pinned and the apt command is a
156
+ constant, so the layer cache answered every build with the packages installed
157
+ the day it was first built. The image scanned green one afternoon and red
158
+ three hours later from the same cached layer, and a workflow rerun reproduced
159
+ the finding exactly.
160
+
161
+ Both Dockerfiles now take an `APT_SECURITY_EPOCH` build argument that is
162
+ interpolated into the apt command itself, and CI passes the current UTC date.
163
+ The layer therefore expires once a day rather than never, while builds within
164
+ a day stay cached. The argument has to appear *in* the command — BuildKit keys
165
+ a `RUN` on its expanded command line, and an ARG that is merely declared
166
+ invalidates nothing. In the hub image the npm replacement step moved above
167
+ that boundary: it is the expensive one, it rots with its pins rather than with
168
+ time, and it needs neither apt nor the system CA store.
169
+
170
+ This is the second time the same cache has held back a Debian security fix
171
+ (CVE-2026-56408 in `libexpat1` was the first), which is why the remedy is a
172
+ mechanism rather than another rebuild.
173
+
174
+ ### Changed
175
+
176
+ - The npm tarball no longer carries the `.js.map` source maps — 61 files and
177
+ 360 kB unpacked, against 540 kB of actual code. `sourceMap` stays on and the
178
+ maps are still built: the integration tests and CI run `dist/index.js` out of
179
+ a checkout, where `src/` sits right next to it and a stack trace resolves
180
+ through them. In an installed package it cannot — the sources those maps point
181
+ at are not part of it.
182
+
10
183
  ## [0.11.0] - 2026-09-03
11
184
 
12
185
  ### Added
package/README.md CHANGED
@@ -1,13 +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)
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)
10
14
  [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)
15
+ <!-- badges: end -->
11
16
 
12
17
  A dual-era [Model Context Protocol](https://modelcontextprotocol.io) (MCP) gateway: it
13
18
  serves many stdio MCP servers from **one container**, published over HTTPS, and speaks
@@ -112,14 +117,14 @@ replaces N containers with one process:
112
117
  re-read on waking; `subscriptions: "off"` withdraws one server's right to
113
118
  push; [details](https://mcp-hub.ni-c.de/guide/subscriptions).
114
119
  - **Lightweight by design**: one Node process, no database (state is one JSON
115
- file plus a signing key under `/data`), six runtime dependencies, and
120
+ file plus a signing key under `/data`), ten runtime dependencies, and
116
121
  multi-arch images — a stated project goal is to run comfortably on a
117
122
  single-board computer like a Raspberry Pi.
118
123
 
119
124
  ## Servers to run behind it
120
125
 
121
126
  The hub is server-agnostic — it serves any stdio MCP server whose entry fits
122
- Claude Code's `mcpServers` format, which is most of them. These seventeen are
127
+ Claude Code's `mcpServers` format, which is most of them. These nineteen are
123
128
  built and maintained alongside it, so their documentation carries the hub entry
124
129
  you need and their tool filters line up with the hub's own `allowTools` /
125
130
  `denyTools`:
@@ -127,7 +132,9 @@ you need and their tool filters line up with the hub's own `allowTools` /
127
132
  | Server | npm | What it reaches |
128
133
  | ---------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------ |
129
134
  | [audiobookshelf-mcp](https://audiobookshelf-mcp.ni-c.de) | `audiobookshelf-mcp` | Audiobookshelf — libraries, listening progress, collections and playlists |
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 |
130
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 |
131
138
  | [freshrss-mcp](https://freshrss-mcp.ni-c.de) | `@ni-c/freshrss-mcp` | FreshRSS — feeds, categories and articles as plain text, not stream ids |
132
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 |
133
140
  | [healthchecks-mcp](https://healthchecks-mcp.ni-c.de) | `healthchecks-mcp` | Healthchecks — cron and uptime checks, and why one failed |
@@ -146,7 +153,7 @@ you need and their tool filters line up with the hub's own `allowTools` /
146
153
 
147
154
  Each one runs perfectly well on its own over stdio. Put them behind the hub when
148
155
  you want them reachable from a client that cannot spawn a local process, or when
149
- you would rather register one connector than seventeen.
156
+ you would rather register one connector than nineteen.
150
157
 
151
158
  ## Configuration
152
159
 
@@ -197,7 +204,8 @@ working. `allowTools` / `denyTools` cut finer and apply to every kind of
197
204
  server: a filtered tool is absent from both `tools/list` and `/hub`, and is
198
205
  refused if called anyway — before the server is woken.
199
206
  Reserved names: `mcp`, `hub`, `authorize`, `token`, `register`,
200
- `login`, `consent`, `health`, `livez`, `revoke`, `upstream`, `.well-known`.
207
+ `login`, `consent`, `health`, `livez`, `revoke`, `jwks`, `interaction`,
208
+ `session`, `userinfo`, `upstream`, `.well-known`.
201
209
 
202
210
  All stdio children share the hub's Unix user and can read its mounted files.
203
211
  Only install fully trusted stdio servers. A server with a different trust level
@@ -230,6 +238,13 @@ USER node
230
238
 
231
239
  ### Environment
232
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
+
233
248
  | Variable | Required | Description |
234
249
  | ------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
235
250
  | `EXTERNAL_URL` | yes | Public base URL, e.g. `https://mcp.example.net` (no path) |
@@ -417,7 +432,7 @@ immediately. Per-client recipes:
417
432
  | `/hub` | Bearer | aggregate endpoint with the 6 meta-tools |
418
433
  | `/livez` | none | minimal process liveness (`200`) |
419
434
  | `/health` | Bearer | per-server status (`200` all up / `503` degraded) |
420
- | `/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/` |
421
436
  | `/register/<client_id>` | registration token | RFC 7592: a client reads, changes or removes its own registration |
422
437
  | `/upstream/callback` | signed state + hub session | where an upstream returns after `upstream login` |
423
438
  | `/.well-known/mcp-hub-client/<id>.json` | none | the hub's own client metadata document, one per `cimd` upstream |
@@ -441,7 +456,7 @@ immediately. Per-client recipes:
441
456
  rotate; replaying one that was already rotated away is treated as a leak and
442
457
  revokes the whole grant, access tokens included.
443
458
  - Upstream auth is fully decoupled from the hub's own OAuth: an expired
444
- 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
445
460
  in `/health`) — clients never see the upstream's 401.
446
461
  - One login can approve multiple connectors, but each token is valid only for
447
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