diff --git a/meta_ads_mcp/__init__.py b/meta_ads_mcp/__init__.py index bcbae37a..9134c0d7 100644 --- a/meta_ads_mcp/__init__.py +++ b/meta_ads_mcp/__init__.py @@ -6,7 +6,7 @@ from meta_ads_mcp.core.server import main -__version__ = "1.0.113" +__version__ = "1.0.114" __all__ = [ 'get_ad_accounts', diff --git a/meta_ads_mcp/core/http_auth_integration.py b/meta_ads_mcp/core/http_auth_integration.py index cfc9cc8a..d5684f77 100644 --- a/meta_ads_mcp/core/http_auth_integration.py +++ b/meta_ads_mcp/core/http_auth_integration.py @@ -256,12 +256,15 @@ async def dispatch(self, request: Request, call_next): auth_token = FastMCPAuthIntegration.extract_token_from_headers(dict(request.headers)) pipeboard_token = FastMCPAuthIntegration.extract_pipeboard_token_from_headers(dict(request.headers)) - if not auth_token and not pipeboard_token: - # Reject unauthenticated requests. Without this, the request would fall - # through to tool handlers which transparently use the META_ACCESS_TOKEN - # env var, allowing any network-reachable caller to invoke tools and - # exfiltrate the operator's credential via Graph API error responses. - # See GHSA-9gw6-46qc-99vr. + if not auth_token: + # A request must carry a primary access-token credential: an + # Authorization: Bearer header, X-META-ACCESS-TOKEN, or + # X-PIPEBOARD-API-TOKEN (all resolved by extract_token_from_headers). + # X-Pipeboard-Token is only a supplementary service token for the + # duplication callback — on its own it establishes no request auth + # context, so it must not admit a request. Otherwise the request + # would fall through to tool handlers that use the META_ACCESS_TOKEN + # env var. See GHSA-9gw6-46qc-99vr. logger.warning( "HTTP Auth Middleware: rejecting request to %s — no Authorization " "Bearer or X-PIPEBOARD-API-TOKEN header present", request.url.path, diff --git a/pyproject.toml b/pyproject.toml index d971d4fd..9e964f7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meta-ads-mcp" -version = "1.0.113" +version = "1.0.114" description = "Model Context Protocol (MCP) server for Meta Ads - Use Remote MCP at pipeboard.co for easiest setup" readme = "README.md" requires-python = ">=3.10" diff --git a/server.json b/server.json index bf95fd52..cc3b081f 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "co.pipeboard/meta-ads-mcp", "description": "Facebook / Meta Ads automation with AI: analyze performance, test creatives, optimize spend.", - "version": "1.0.113", + "version": "1.0.114", "remotes": [ { "type": "streamable-http", diff --git a/tests/test_http_auth_security.py b/tests/test_http_auth_security.py index 5791f61f..afd1fb09 100644 --- a/tests/test_http_auth_security.py +++ b/tests/test_http_auth_security.py @@ -70,6 +70,38 @@ def test_middleware_accepts_pipeboard_token(): assert resp.status_code == 200 +def test_middleware_rejects_supplementary_token_alone(): + """X-Pipeboard-Token is a supplementary service token (duplication + callback) and must not, on its own, admit a request — without a primary + access-token credential the request must be rejected with 401.""" + client = TestClient(_build_app()) + resp = client.post( + "/mcp", + json={"jsonrpc": "2.0", "method": "tools/list", "id": 1}, + headers={"X-Pipeboard-Token": "anything-not-validated"}, + ) + assert resp.status_code == 401 + assert resp.headers.get("WWW-Authenticate") == "Bearer" + assert resp.json()["error"] == "Unauthorized" + assert "reached_handler" not in resp.text + + +def test_middleware_accepts_supplementary_token_with_primary_credential(): + """The legitimate flow forwards X-Pipeboard-Token alongside a primary + credential; the request must still be admitted.""" + client = TestClient(_build_app()) + resp = client.post( + "/mcp", + json={"jsonrpc": "2.0", "method": "tools/list", "id": 1}, + headers={ + "Authorization": "Bearer some-meta-token-value-xyz", + "X-Pipeboard-Token": "pb-supplementary-token", + }, + ) + assert resp.status_code == 200 + assert resp.json() == {"reached_handler": True} + + def test_redact_url_strips_access_token(): url = ( "https://graph.facebook.com/v24.0/me/adaccounts"