Skip to content

Commit 56f3942

Browse files
alistair3149claude
andcommitted
Stop forwarding caller-supplied bearers, and stop advertising the wikis' issuers
With the hosted proxy disabled, an Authorization header was forwarded to MediaWiki verbatim and the protected-resource document named the wikis' own authorization servers. Together those steered a client into minting a token at the wiki and presenting it here - the shape MCP forbids, since a server must not accept tokens that were not issued for it. Forwarding now requires MCP_ALLOW_BEARER_PASSTHROUGH=true, is deprecated, and warns at startup. Without it a request carrying a bearer is refused with 401 rather than served: ignoring the header would run the request anonymously or as a configured identity while the caller believed it was acting as itself, which is worse than refusing it. The refusal is deliberately unconditional and NOT keyed on whether a wiki sets oauth2ClientId. That option describes how this server runs browser sign-in, not whether the wiki accepts bearers - any wiki with Extension:OAuth does - so keying on it would keep forwarding tokens on exactly the documented public read-only and manual-token deployments. A test covers that shape. The advertisement is removed unconditionally, including under the opt-in. Only the hosted proxy makes this server an authorization server, so with the proxy off /.well-known/oauth-protected-resource now answers 404. The documented use for forwarding is callers that already hold a token, so they need no discovery, and restoring it would reinstate the steering this closes. Answering before the upstream fetches also means an unauthenticated request no longer costs one outbound metadata fetch per OAuth wiki. Because that document can now be absent, the 401 challenge only advertises resource_metadata when it exists; RFC 6750 admits a challenge without it. Otherwise every challenge pointed a client at a URL that 404s. The all-OAuth 401 challenge is gated on the opt-in: it asks a caller to supply a wiki token, and with no way to forward one the same condition is an operator misconfiguration instead. So the server now warns at startup when a wiki needs a signed-in user but neither hosted sign-in nor forwarding can provide one, and the tool-layer error names the operator action rather than asking for a token the transport would refuse. Tests that cover the forwarding shape now run under the flag, since the behaviour still exists there. The multi-wiki issuer listing is deleted rather than gated - that behaviour is gone. The fake authorization server gained a metadata-request counter so the no-fetch claim is actually asserted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8075a68 commit 56f3942

25 files changed

Lines changed: 477 additions & 158 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
1313

1414
### Breaking changes
1515

16+
- The HTTP transport no longer forwards a caller's `Authorization: Bearer` header to MediaWiki. Such a request is refused with `401`, because a token minted by the wiki was not issued for this server. Use [hosted OAuth sign-in](docs/deployment.md#hosted-oauth-sign-in), or set `MCP_ALLOW_BEARER_PASSTHROUGH=true` to keep the old behaviour while you migrate; it is deprecated and will be removed. The server now warns at startup when a wiki requires a signed-in user but neither hosted sign-in nor forwarding is available, since no request could then succeed.
17+
- The server no longer advertises the wikis' own authorization servers, so a client can no longer discover where to mint a token to send here. Without hosted OAuth sign-in enabled, `/.well-known/oauth-protected-resource` now answers `404`, and `list-wikis` stops reporting each wiki's `authorizationServer`. Deployments running the hosted sign-in are unaffected.
1618
- The `Origin` header is now validated on every bind, and a request carrying an unlisted origin is refused with `403`. If you serve a browser-based client from a public bind, set `MCP_ALLOWED_ORIGINS` before upgrading. Clients that send no `Origin` header, which is most of them, are unaffected.
1719

1820
### Changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ For the full field reference, env-var substitution, secret sources, change tags,
166166
Tools marked 🔐 require authentication. Write tools (including extension-pack writes) are hidden from `tools/list` when the configured default wiki has `readOnly: true` — see [Deployment](#deployment).
167167

168168
- **Browser-based OAuth (recommended).** Sign in through a browser tab the first time a tool needs auth. Set `oauth2ClientId` and `oauth2CallbackPort` per wiki — see [docs/configuration.md — OAuth (browser-based)](docs/configuration.md#oauth-browser-based).
169-
- **Per-request bearer token (HTTP).** Each request carries `Authorization: Bearer <token>`; the server forwards it to MediaWiki. See [docs/deployment.md — per-request bearer token](docs/deployment.md#per-request-bearer-token-http-transport).
169+
- **Per-request bearer token (HTTP), deprecated.** Each request carries `Authorization: Bearer <token>` and the server forwards it to MediaWiki. Off by default, because an MCP server must not accept tokens that were not issued for it. See [docs/deployment.md — per-request bearer token](docs/deployment.md#per-request-bearer-token-http-transport-deprecated).
170170
- **Hosted OAuth proxy (HTTP).** The server fronts one MediaWiki consumer as an OAuth 2.1 Authorization Server, so an OAuth-aware client signs each user in — no manual tokens. Point it at `https://<wiki>/mcp`; anonymous read still works. See [docs/deployment.md — hosted OAuth sign-in](docs/deployment.md#hosted-oauth-sign-in).
171171
- **Manual OAuth2 access token.** Paste a long-lived token into `config.json`. See [docs/configuration.md — manual OAuth2 access token](docs/configuration.md#manual-oauth2-access-token).
172172
- **Bot password.** Fallback when Extension:OAuth isn't installed. See [docs/configuration.md — bot password](docs/configuration.md#bot-password).
@@ -284,7 +284,7 @@ Running the server as a remote HTTP endpoint for other users has its own configu
284284

285285
Defaults are safe for single-user use. Before exposing the HTTP transport to others, lock down three things:
286286

287-
- **Trust the proxy, not the header.** The server forwards any `Authorization: Bearer` header straight to MediaWiki — authentication is the reverse proxy's job. Terminate TLS there, and don't expose the MCP port directly on an untrusted network. See [docs/deployment.md — security checklist](docs/deployment.md#security-checklist).
287+
- **Terminate TLS at your reverse proxy.** Don't expose the MCP port directly on an untrusted network. See [docs/deployment.md — security checklist](docs/deployment.md#security-checklist).
288288
- **Pair `MCP_BIND` with `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS`.** The HTTP transport binds to `127.0.0.1` by default. When you open it up with `MCP_BIND=0.0.0.0`, set `MCP_ALLOWED_HOSTS` to the hostnames your proxy forwards and `MCP_ALLOWED_ORIGINS` to the browser origins allowed to call the server — these block DNS-rebinding and cross-origin attacks respectively.
289289
- **Uploads are opt-in.** `upload-file` is disabled until you list allowed directories in `uploadDirs` or `MCP_UPLOAD_DIRS`. See [docs/configuration.md — upload directories](docs/configuration.md#upload-directories).
290290
- **Internal destinations need `MCP_TRUSTED_HOSTS`.** Outbound fetches are SSRF-guarded: a destination resolving to a private or loopback address (e.g. a Docker-network alias like `mediawiki.svc`) is refused until you list its host in `MCP_TRUSTED_HOSTS`. See [docs/deployment.md — outbound SSRF guard](docs/deployment.md#outbound-ssrf-guard).

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ If your wiki doesn't have an OAuth consumer set up, omit `oauth2ClientId`. Stati
201201

202202
#### HTTP transport behaviour
203203

204-
Over the HTTP transport, OAuth runs through discovery and `401` challenges instead of a local browser flow, and each request carries the bearer for its target wiki. That behaviour is in [deployment.md — per-request bearer token](deployment.md#per-request-bearer-token-http-transport).
204+
Over the HTTP transport, OAuth runs through discovery and `401` challenges instead of a local browser flow, and each request is served with whichever identity the deployment provides. That behaviour is in [deployment.md — per-request bearer token](deployment.md#per-request-bearer-token-http-transport-deprecated).
205205

206206
#### Hosted OAuth proxy environment variables
207207

docs/deployment.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,9 @@ The `GIT_SHA` build arg populates the image's `org.opencontainers.image.revision
206206

207207
Defaults are safe for a localhost bind. Before exposing the HTTP transport to others, confirm all of these:
208208

209-
- **Terminate TLS at a reverse proxy; never expose the port directly.** The server trusts any `Authorization: Bearer` header it receives without origin checks, so authentication is the proxy's job. Run it behind Caddy, nginx, or Traefik, or bind it to `127.0.0.1`; never put the raw HTTP port on an untrusted network.
210-
- **Forward the `Authorization` header intact.** Proxy configs that strip or consume it (`header_up -Authorization`, `proxy_set_header Authorization ""`, a proxy-level basic-auth handler on the MCP route) leave the server with no token, falling back to config or anonymous. On any untrusted inbound path, strip the client-supplied `Authorization` instead, so a caller cannot inject a bearer the server would trust.
209+
- **Terminate TLS at a reverse proxy; never expose the port directly.** Run it behind Caddy, nginx, or Traefik, or bind it to `127.0.0.1`; never put the raw HTTP port on an untrusted network.
210+
- **A caller-supplied `Authorization` header is refused, not trusted.** The server no longer forwards one to the wiki, so a caller cannot inject a bearer it would act on. Only enable `MCP_ALLOW_BEARER_PASSTHROUGH` if you have callers holding their own wiki tokens.
211+
- **Let the `Authorization` header reach the server.** With hosted OAuth sign-in it carries a token this server issued, and with `MCP_ALLOW_BEARER_PASSTHROUGH` set it carries the caller's own; either way `header_up -Authorization`, `proxy_set_header Authorization ""` and a proxy-level basic-auth handler on the MCP route all break sign-in.
211212
- **Set `MCP_ALLOWED_HOSTS`** to the hostnames your proxy forwards (e.g. `wiki.example.org`). This engages the SDK's DNS-rebinding check; requests to `/mcp` with a non-matching `Host` get a 403. Unset on a public bind turns the check off (with a startup warning); unset on a localhost bind is safe.
212213
- **Set `MCP_ALLOWED_ORIGINS`** to the browser origins allowed to call `/mcp` (e.g. `https://app.example.org`). A present-but-unlisted `Origin` gets a 403. The match is on hostname; see [Host and Origin matching](#host-and-origin-matching). Leaving it unset on a public bind refuses every browser request, so set it if you serve one. This is a separate decision from `MCP_ALLOWED_HOSTS`, which names hosts this server answers to rather than origins allowed to script it.
213214
- **List internal destinations in `MCP_TRUSTED_HOSTS`.** Outbound fetches are SSRF-guarded, so a wiki `server` on a private or Docker-internal address (e.g. `mediawiki.svc`) is refused until you exempt it; otherwise extension tools silently disappear. See [outbound SSRF guard](#outbound-ssrf-guard).
@@ -240,6 +241,7 @@ Set `MCP_TRANSPORT=http` to select this transport (the Docker image defaults to
240241
| `MCP_ALLOWED_ORIGINS` | auto on localhost | Comma-separated `Origin`-header allowlist. Unset on a public bind refuses every browser request. See [Security checklist](#security-checklist). |
241242
| `MCP_TRUSTED_HOSTS` | unset | Comma-separated **outbound** SSRF-guard exemptions for internal destinations (e.g. `mediawiki.svc`). See [Outbound SSRF guard](#outbound-ssrf-guard). |
242243
| `MCP_ALLOW_STATIC_FALLBACK` | unset | Allow HTTP startup when a wiki has static credentials, making them a shared fallback identity. See [Security checklist](#security-checklist). |
244+
| `MCP_ALLOW_BEARER_PASSTHROUGH` | unset | Deprecated. Forward a caller's `Authorization` header to MediaWiki as that caller. Without it such a request is refused with `401`. See [Per-request bearer token](#per-request-bearer-token-http-transport-deprecated). |
243245

244246
`MCP_MAX_REQUEST_BODY` matches nginx's `client_max_body_size 1m`. Raise it if `update-page` calls return 413 on legitimately large edits or your wiki has raised `$wgMaxArticleSize` (MediaWiki default 2 MB). Lower it for a tighter DoS guard.
245247

@@ -259,7 +261,7 @@ Set `MCP_TRANSPORT=http` to select this transport (the Docker image defaults to
259261

260262
When enabled, the [hosted OAuth sign-in](#hosted-oauth-sign-in) setup makes this server the OAuth authorization server the MCP client talks to, through the endpoints routed in [step 4](#4-route-the-oauth-endpoints-through-your-proxy). The bearer a client sends to `/mcp` is a token the proxy minted, not a MediaWiki token. The user's MediaWiki token stays server-side, keyed to that bearer, and is refreshed server-to-server through the confidential consumer — this is what keeps users signed in past the wiki's ~1-hour access-token lifetime, and it is the state the [store file](#proxy-state-persistence) persists.
261263

262-
How the sign-in challenge is issued depends on the wiki. On a **public wiki**, a tokenless request is served anonymously; a write that needs authentication returns an authentication error, and an invalid or expired bearer gets a `401` + `WWW-Authenticate` challenge. A **private wiki** (`private: true`, MediaWiki's `$wgGroupPermissions['*']['read'] = false`) answers every request, including the initial connection, with that challenge, so a client prompts for sign-in at connect. The connection-time challenge requires the wiki's `oauth2ClientId`; without it, the `401` advertises an authorization server the wiki does not have, and the server logs a warning at startup.
264+
How the sign-in challenge is issued depends on the wiki. On a **public wiki**, a tokenless request is served anonymously; a write that needs authentication returns an authentication error, and an invalid or expired bearer gets a `401` + `WWW-Authenticate` challenge. A **private wiki** (`private: true`, MediaWiki's `$wgGroupPermissions['*']['read'] = false`) answers every request, including the initial connection, with that challenge, so a client prompts for sign-in at connect. That challenge names an authorization server only when [hosted OAuth sign-in](#hosted-oauth-sign-in) is configured; otherwise it is a bare `Bearer` challenge, because there is no document for a client to fetch. A private wiki with no hosted sign-in and no forwarding cannot serve any request, and the server says so at startup.
263265

264266
#### Three-base topology
265267

@@ -321,9 +323,11 @@ The proxy persists its sign-in state to a local file so a restart or deploy does
321323

322324
**In Docker, mount a persistent volume at the store path.** The image declares one at `/app/data`, but you must mount a named volume or a writable host path there, or a container restart wipes it. A host-path bind mount must be writable by the container's non-root user; a named volume handles that automatically.
323325

324-
### Per-request bearer token (HTTP transport)
326+
### Per-request bearer token (HTTP transport, deprecated)
325327

326-
For **programmatic or non-interactive clients that already hold a MediaWiki OAuth2 access token** (a script, a CI job, an automation backend), the HTTP transport also accepts the token directly, with no browser flow. Most deployments serving humans should use [Hosted OAuth sign-in](#hosted-oauth-sign-in) instead; this is the lower-level primitive it is built on.
328+
> **Deprecated.** An MCP server must not accept tokens that were not issued for it, so forwarding a caller's MediaWiki token is off by default and will be removed. Use [Hosted OAuth sign-in](#hosted-oauth-sign-in), which gets this server its own tokens. Set `MCP_ALLOW_BEARER_PASSTHROUGH=true` to keep the old behaviour while you migrate; the server logs a warning at startup while it is set.
329+
330+
For **programmatic or non-interactive clients that already hold a MediaWiki OAuth2 access token** (a script, a CI job, an automation backend), the HTTP transport can accept the token directly, with no browser flow.
327331

328332
The server accepts a standard OAuth 2.1 `Authorization: Bearer` header on each request, as described in the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization):
329333

@@ -333,13 +337,13 @@ Authorization: Bearer <oauth2-access-token>
333337

334338
Use a MediaWiki OAuth2 access token obtained from `Special:OAuthConsumerRegistration/propose/oauth2` on the target wiki, with [Extension:OAuth](https://www.mediawiki.org/wiki/Extension:OAuth) installed. The server forwards it to MediaWiki as that caller's token, so writes are attributable and MediaWiki's per-user rate limits apply. A bearer is scoped to a single MediaWiki OAuth2 realm, and the server pins nothing across requests: one client can address wikis on different authorization servers by sending the right token per request. `list-wikis` reports each OAuth wiki's `authorizationServer`.
335339

336-
When a wiki sets `oauth2ClientId` (see [configuration.md: OAuth (browser-based)](configuration.md#oauth-browser-based)), the server also advertises OAuth discovery on this path: the protected-resource document lists every OAuth-configured wiki's authorization server, and a capable client can run the authorization-code flow against the wiki's **own** authorization server and fetch that token itself instead of you pasting one in. A bearer-less request is challenged with `401` only when no configured wiki is usable without a token; a deployment that mixes OAuth and non-OAuth wikis still serves tokenless clients on the wikis that allow anonymous access.
340+
The server no longer advertises the wikis' own authorization servers, so a client cannot discover where to mint such a token: obtain it yourself and configure it on the caller. Only [Hosted OAuth sign-in](#hosted-oauth-sign-in) publishes a protected-resource document, naming this server. While `MCP_ALLOW_BEARER_PASSTHROUGH=true` is set, a bearer-less request is challenged with `401` when no configured wiki is usable without a token; a deployment mixing OAuth and non-OAuth wikis still serves tokenless clients on the wikis that allow anonymous access.
337341

338-
**Precedence:** request header → `config.json` `token``config.json` `username`/`password` → anonymous. The HTTP transport refuses to start with static credentials in `config.json` unless `MCP_ALLOW_STATIC_FALLBACK=true` is set; see [the Security checklist](#security-checklist) for why.
342+
**Precedence:** request header (only while `MCP_ALLOW_BEARER_PASSTHROUGH=true`; otherwise refused with `401`) `config.json` `token``config.json` `username`/`password` → anonymous. The HTTP transport refuses to start with static credentials in `config.json` unless `MCP_ALLOW_STATIC_FALLBACK=true` is set; see [the Security checklist](#security-checklist) for why.
339343

340344
HTTP serving is per-request, following MCP protocol revision 2026-07-28: the server issues no session ids, serves 2026-07-28 clients natively, and serves earlier 2025-era clients statelessly. Each request builds an independent MediaWiki session from the token it carries, so rotation and revocation take effect on the very next request; run the transport behind TLS so bearers stay confidential in transit.
341345

342-
Example with Claude Code:
346+
Example with Claude Code, which works only on a server started with `MCP_ALLOW_BEARER_PASSTHROUGH=true`:
343347

344348
```sh
345349
claude mcp add --transport http my-wiki https://wiki.example.org/mcp \

docs/operations.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ Fields you'll filter on:
2828
One line on server boot — a snapshot of the effective configuration that's safe to paste into a support ticket:
2929

3030
```json
31-
{"ts":"...","level":"info","event":"startup","version":"0.8.0","transport":"http","host":"0.0.0.0","port":8080,"auth_shape":"bearer-passthrough","default_wiki":"example.org","wikis":["example.org"],"allow_wiki_management":false,"allowed_hosts":["wiki.example.org"],"allowed_origins":["https://wiki.example.org"],"max_request_body":"1mb","upload_dirs_configured":false}
31+
{"ts":"...","level":"info","event":"startup","version":"0.8.0","transport":"http","host":"0.0.0.0","port":8080,"auth_shape":"anonymous","default_wiki":"example.org","wikis":["example.org"],"allow_wiki_management":false,"allowed_hosts":["wiki.example.org"],"allowed_origins":["https://wiki.example.org"],"max_request_body":"1mb","upload_dirs_configured":false}
3232
```
3333

34-
- **`auth_shape`**`anonymous`, `static-credential`, or `bearer-passthrough`.
34+
- **`auth_shape`**`anonymous`, `static-credential`, `oauth-proxy` (hosted sign-in configured), or `bearer-passthrough` (only while the deprecated `MCP_ALLOW_BEARER_PASSTHROUGH` is set).
3535
- **`host`, `port`, `allowed_hosts`, `allowed_origins`** — HTTP transport only. `allowed_hosts` is omitted when not configured. `allowed_origins` is always present: an empty array means every browser request is refused, not that the check is off.
3636
- **`upload_dirs_configured`**`true` when `uploadDirs` (config) or `MCP_UPLOAD_DIRS` (env) is set. The actual paths are not logged.
3737
- **`max_request_body`** — HTTP transport only. The resolved `MCP_MAX_REQUEST_BODY` value.

src/auth/protectedResource.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ export interface ProtectedResourceInput {
1414
* authorization server. Pass the proxy issuer(s) here to advertise self
1515
* instead of the per-wiki upstream issuers derived from `metadatas`.
1616
*/
17-
authorizationServersOverride?: readonly string[];
17+
// The authorization servers this document names. Required: only the hosted
18+
// proxy makes this server an authorization server, and it names itself. There is
19+
// no per-wiki fallback — naming the wikis' own issuers is what steered clients
20+
// into minting tokens this server must not accept.
21+
authorizationServers: readonly string[];
1822
}
1923

2024
export interface ProtectedResourceDoc {
@@ -75,10 +79,7 @@ export function buildProtectedResource(
7579
// not match expected .../mcp"). resolvePublicBase keeps its trailing slash for
7680
// building the resource_metadata URL; strip it for the identifier only.
7781
const resource = resolvePublicBase(input.requestHost, input.requestProto).replace(/\/+$/, '');
78-
const issuers =
79-
input.authorizationServersOverride !== undefined
80-
? [...input.authorizationServersOverride]
81-
: [...new Set(input.metadatas.map((m) => m.issuer))];
82+
const issuers = [...input.authorizationServers];
8283
const scopes = [...new Set(input.metadatas.flatMap((m) => m.scopes_supported ?? []))];
8384

8485
const doc: ProtectedResourceDoc = {

0 commit comments

Comments
 (0)