From 5273a042809334f2b640592f91050d37e4afaedf Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:03:21 +0200 Subject: [PATCH] fix(http): require 'audience' for jwt auth (RFC 8707, confused-deputy defense) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fastmcp's JWTVerifier accepts audience=None and then SKIPS audience validation. mk_auth_provider passed the caller's audience straight through, so omitting it silently disabled the RFC 8707 audience binding — a token minted for another service could be replayed against this server (confused-deputy). For a helper whose whole job is to build a SECURE resource server, that fail-open default is wrong: now 'audience' is required for type='jwt' and a missing one raises a clear error. Also sync pyproject version to 0.1.5 (PyPI already has 0.1.5 from the http merge; the wads auto-bump never pushed the bump back) so the next publish is 0.1.6. Found by an adversarial security review of the remote-connector work. +1 test. --- py2mcp/http.py | 17 ++++++++++++++--- pyproject.toml | 2 +- tests/test_http.py | 8 ++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/py2mcp/http.py b/py2mcp/http.py index 15f995d..a777055 100644 --- a/py2mcp/http.py +++ b/py2mcp/http.py @@ -56,8 +56,10 @@ def mk_auth_provider(auth: Optional[dict]) -> Optional[Any]: - ``jwks_uri`` *or* ``public_key`` — where to get the IdP's signing key(s). - ``issuer`` — the IdP issuer URL (the token's ``iss``). - - ``audience`` — **this** server's resource id (the token's ``aud``; RFC 8707 - audience binding that stops a token for another service being replayed here). + - ``audience`` (**required**) — **this** server's resource id (the token's + ``aud``). RFC 8707 audience binding is mandatory: it stops a token minted for + another service being replayed here (the confused-deputy defense), so this + helper refuses to build a verifier that would skip it. - ``authorization_servers`` (or a single ``issuer``) — IdP issuer URL(s) advertised in the RFC 9728 protected-resource metadata. - ``base_url`` — this server's public base URL. @@ -96,6 +98,15 @@ def mk_auth_provider(auth: Optional[dict]) -> Optional[Any]: "jwt auth needs 'base_url' (this server's public base URL, for the " "RFC 9728 protected-resource metadata)." ) + audience = auth.get("audience") + if not audience: + raise ValueError( + "jwt auth needs 'audience' (this server's resource id). Without it the " + "token's audience is NOT validated, which re-opens the confused-deputy " + "vulnerability (a token minted for another service could be replayed " + "here) — RFC 8707 audience binding is mandatory for an MCP resource " + "server. Set it to this connector's public resource URL." + ) authorization_servers = auth.get("authorization_servers") if not authorization_servers and auth.get("issuer"): authorization_servers = [auth["issuer"]] @@ -110,7 +121,7 @@ def mk_auth_provider(auth: Optional[dict]) -> Optional[Any]: jwks_uri=jwks_uri, public_key=public_key, issuer=auth.get("issuer"), - audience=auth.get("audience"), + audience=audience, required_scopes=required_scopes, base_url=base_url, ) diff --git a/pyproject.toml b/pyproject.toml index 83cf458..f5215a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "py2mcp" -version = "0.1.4" +version = "0.1.5" description = "Quick MCP server creation from Python functions" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_http.py b/tests/test_http.py index b13c4e7..0e27d18 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -57,6 +57,14 @@ def test_missing_base_url_raises(): mk_auth_provider(auth) +def test_missing_audience_raises(): + # RFC 8707 audience binding is mandatory — without it JWTVerifier would skip + # audience validation (confused-deputy risk), so the helper must refuse. + auth = {k: v for k, v in _AUTH.items() if k != "audience"} + with pytest.raises(ValueError, match="audience"): + mk_auth_provider(auth) + + def test_missing_authorization_servers_raises(): auth = { k: v