@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 +110 -0
- package/README.md +6 -5
- package/dist/admin.js +8 -3
- package/dist/auth/cimd.js +8 -2
- package/dist/auth/oidc/interactions.js +14 -3
- package/dist/auth/oidc/mount.js +6 -1
- package/dist/auth/oidc/provider.js +23 -2
- package/dist/auth/oidc/verifier.js +35 -4
- package/dist/auth/page.js +9 -3
- package/dist/auth/redirect-uri.js +40 -0
- package/dist/auth/resource.js +16 -0
- package/dist/auth/store.js +35 -4
- package/dist/auth/text.js +9 -0
- package/dist/index.js +4 -1
- package/dist/subscriptions.js +23 -0
- package/dist/supervisor.js +58 -8
- package/dist/transports/docker.js +62 -18
- package/dist/transports/stream.js +35 -9
- package/dist/upstream/auth.js +26 -6
- package/dist/upstream/provider.js +57 -3
- package/dist/upstream/redirects.js +181 -0
- package/package.json +2 -2
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
|
[](https://github.com/ni-c/mcp-hub/actions/workflows/ci.yml)
|
|
6
6
|
[](https://scorecard.dev/viewer/?uri=github.com/ni-c/mcp-hub)
|
|
7
|
-
|
|
7
|
+
[](https://socket.dev/npm/package/@ni-c/mcp-hub)
|
|
8
8
|
[](https://glama.ai/mcp/servers/ni-c/mcp-hub)
|
|
9
9
|
<br>
|
|
10
10
|
[](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
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/auth/oidc/mount.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
35
|
-
* signature check
|
|
36
|
-
*
|
|
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:')
|
package/dist/auth/resource.js
CHANGED
|
@@ -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
|
}
|
package/dist/auth/store.js
CHANGED
|
@@ -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
|
-
/**
|
|
744
|
-
*
|
|
745
|
-
|
|
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.
|