VYPR
Critical severity9.1GHSA Advisory· Published Jun 11, 2026· Updated Jun 11, 2026

Meta Ads MCP: Unauthenticated HTTP MCP Tool Execution Leaks Operator Meta Access Token

CVE-2026-48039

Description

Unauthenticated HTTP requests to the MCP server can trigger tool execution and leak the operator's Meta access token in error responses.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Unauthenticated HTTP requests to the MCP server can trigger tool execution and leak the operator's Meta access token in error responses.

Vulnerability

The AuthInjectionMiddleware.dispatch() method in http_auth_integration.py:272 unconditionally forwards unauthenticated Streamable HTTP requests to downstream MCP tool handlers without issuing a 401 response [1][2]. When no per-request credential is present, tool handlers fall back to the META_ACCESS_TOKEN environment variable. If the downstream Meta Graph API call fails, api.py:263–269 serialises the raw httpx request URL—including the operator's access_token as a query parameter—into the JSON-RPC response body, delivering the credential to the unauthenticated caller [1][2]. Affected versions are ≤ 1.0.101 (commits 496c988 through 7d14226); versions 1.0.102–1.0.105 lack git tags, so patch status is unconfirmed [1][2].

Exploitation

An attacker with network access to the MCP server (no authentication required) can send an HTTP POST request to the /mcp endpoint with any valid MCP tool invocation [1][2]. The server will execute the tool using the operator's META_ACCESS_TOKEN environment variable as the credential. If the tool call fails (e.g., due to invalid parameters or a Graph API error), the error response includes the full request URL, which contains the access_token query parameter [1][2]. The attacker can then extract the token from the response body.

Impact

Successful exploitation allows an unauthenticated attacker to invoke any MCP tool (e.g., reading ad account data) and obtain the operator's Meta access token [1][2]. With this token, the attacker can fully compromise the associated Meta Ads account, leading to unauthorized access to sensitive advertising data and the ability to modify campaigns. The CVSS score is 9.1 (Critical) with impacts on confidentiality and integrity, but no direct impact on availability [1].

Mitigation

The vulnerability is fixed in version 1.0.109, released on an unspecified date [3]. The fix introduces a 401 response with WWW-Authenticate: Bearer when no Authorization: Bearer or X-PIPEBOARD-API-TOKEN header is present, redacts access_token and appsecret_proof from Graph API error URLs, and removes the environment variable fallback for HTTP transport [3]. Operators should upgrade to 1.0.109 immediately. If an earlier version was exposed to an untrusted network, the Meta access token should be rotated and Graph API access logs reviewed [3].

AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
14d7371d4ed7

fix(security): reject unauthenticated HTTP requests + redact tokens in error URLs (GHSA-9gw6-46qc-99vr) (#125)

https://github.com/pipeboard-co/meta-ads-mcpYves JunqueiraMay 20, 2026Fixed in 1.0.109via ghsa-release-walk
7 files changed · +256 13
  • meta_ads_mcp/core/api.py+34 3 modified
    @@ -8,10 +8,40 @@
     import asyncio
     import functools
     import os
    +from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
     from . import auth
     from .auth import needs_authentication, auth_manager, start_callback_server, shutdown_callback_server
     from .utils import logger
     
    +
    +# Query-string params that must never leak to the caller in error payloads.
    +# access_token is the operator credential; appsecret_proof is derived from
    +# the app secret + access token via HMAC and is similarly sensitive.
    +# See GHSA-9gw6-46qc-99vr.
    +_SENSITIVE_QUERY_PARAMS = frozenset({"access_token", "appsecret_proof"})
    +
    +
    +def _redact_url(url: str) -> str:
    +    """Strip sensitive query params (access_token, appsecret_proof) from a URL.
    +
    +    Used to scrub Graph API URLs before they are returned to MCP callers in
    +    error responses.
    +    """
    +    if not url:
    +        return url
    +    try:
    +        parts = urlsplit(url)
    +        if not parts.query:
    +            return url
    +        scrubbed = [
    +            (k, "REDACTED" if k in _SENSITIVE_QUERY_PARAMS else v)
    +            for k, v in parse_qsl(parts.query, keep_blank_values=True)
    +        ]
    +        return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(scrubbed), parts.fragment))
    +    except Exception:
    +        # Be conservative: if parsing fails, drop the query string entirely.
    +        return url.split("?", 1)[0]
    +
     class McpToolError(Exception):
         """Base class for MCP tool errors that must set isError: true.
     
    @@ -259,14 +289,15 @@ async def make_api_request(
                     logger.warning(f"Detected authentication error ({e.response.status_code})")
                     auth_manager.invalidate_token()
                 
    -            # Include full details for technical users
    +            # Include full details for technical users. URLs are scrubbed of
    +            # access_token/appsecret_proof — see GHSA-9gw6-46qc-99vr.
                 full_response = {
                     "headers": dict(e.response.headers),
                     "status_code": e.response.status_code,
    -                "url": str(e.response.url),
    +                "url": _redact_url(str(e.response.url)),
                     "reason": getattr(e.response, "reason_phrase", "Unknown reason"),
                     "request_method": e.request.method,
    -                "request_url": str(e.request.url)
    +                "request_url": _redact_url(str(e.request.url))
                 }
                 
                 # Return a properly structured error object
    
  • meta_ads_mcp/core/http_auth_integration.py+29 7 modified
    @@ -244,30 +244,52 @@ def new_patched_app_provider_method(*args, **kwargs):
     # --- AuthInjectionMiddleware definition ---
     from starlette.middleware.base import BaseHTTPMiddleware
     from starlette.requests import Request
    +from starlette.responses import Response
     import json # Ensure json is imported if not already at the top
     
     class AuthInjectionMiddleware(BaseHTTPMiddleware):
         async def dispatch(self, request: Request, call_next):
             logger.debug(f"HTTP Auth Middleware: Processing request to {request.url.path}")
             logger.debug(f"HTTP Auth Middleware: Request headers: {list(request.headers.keys())}")
    -        
    +
             # Extract both types of tokens for dual-header authentication
             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.
    +            logger.warning(
    +                "HTTP Auth Middleware: rejecting request to %s — no Authorization "
    +                "Bearer or X-PIPEBOARD-API-TOKEN header present", request.url.path,
    +            )
    +            return Response(
    +                content=json.dumps({
    +                    "error": "Unauthorized",
    +                    "message": (
    +                        "Authentication required. Provide an Authorization: Bearer "
    +                        "<token> header (Meta access token) or an X-PIPEBOARD-API-TOKEN "
    +                        "header. See STREAMABLE_HTTP_SETUP.md."
    +                    ),
    +                }),
    +                status_code=401,
    +                media_type="application/json",
    +                headers={"WWW-Authenticate": "Bearer"},
    +            )
    +
             if auth_token:
                 logger.debug(f"HTTP Auth Middleware: Extracted auth token: {auth_token[:10]}...")
                 logger.debug("Injecting auth token into request context")
                 FastMCPAuthIntegration.set_auth_token(auth_token)
    -        
    +
             if pipeboard_token:
                 logger.debug(f"HTTP Auth Middleware: Extracted Pipeboard token: {pipeboard_token[:10]}...")
                 logger.debug("Injecting Pipeboard token into request context")
                 FastMCPAuthIntegration.set_pipeboard_token(pipeboard_token)
    -        
    -        if not auth_token and not pipeboard_token:
    -            logger.warning("HTTP Auth Middleware: No authentication tokens found in headers")
    -        
    +
             try:
                 response = await call_next(request)
                 return response
    
  • meta_ads_mcp/__init__.py+1 1 modified
    @@ -6,7 +6,7 @@
     
     from meta_ads_mcp.core.server import main
     
    -__version__ = "1.0.107"
    +__version__ = "1.0.109"
     
     __all__ = [
         'get_ad_accounts',
    
  • pyproject.toml+1 1 modified
    @@ -4,7 +4,7 @@ build-backend = "hatchling.build"
     
     [project]
     name = "meta-ads-mcp"
    -version = "1.0.108"
    +version = "1.0.109"
     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"
    
  • SECURITY.md+46 0 added
    @@ -0,0 +1,46 @@
    +# Security
    +
    +## Reporting a vulnerability
    +
    +Please report security issues privately via GitHub: open a draft advisory
    +from the [Security tab](https://github.com/pipeboard-co/meta-ads-mcp/security/advisories/new)
    +("Report a vulnerability"). Do not file public issues for unpatched
    +vulnerabilities.
    +
    +## Advisories
    +
    +### GHSA-9gw6-46qc-99vr — Unauthenticated HTTP MCP tool execution leaks operator Meta access token
    +
    +- **Severity:** Critical (CVSS 3.1 9.1 — `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N`)
    +- **Affected versions:** `<= 1.0.108` when run with `--transport streamable-http`
    +  and a `META_ACCESS_TOKEN` environment variable.
    +- **Fixed in:** `1.0.109`
    +- **Affected configurations:** Self-hosted deployments that expose the
    +  streamable-HTTP port on a reachable network interface. The hosted MCP at
    +  `*.mcp.pipeboard.co` was not affected — it sits behind an authenticating
    +  proxy and the Python process is bound to localhost.
    +
    +**What went wrong.** `AuthInjectionMiddleware.dispatch()` logged a warning when
    +a request arrived with no `Authorization: Bearer` / `X-PIPEBOARD-API-TOKEN`
    +header and then forwarded the request to the tool handler anyway. Tool handlers
    +fall back to `META_ACCESS_TOKEN` when no per-request token is set, so any
    +network-reachable caller could invoke any MCP tool as the operator. When the
    +downstream Graph API call returned a 4xx, `make_api_request()` serialized
    +`e.request.url` — including `access_token` as a query parameter — verbatim into
    +the JSON-RPC error payload, exposing the long-lived operator credential.
    +
    +**Fix.**
    +1. `AuthInjectionMiddleware` now returns `401 Unauthorized` with
    +   `WWW-Authenticate: Bearer` when neither token header is present.
    +2. `make_api_request()` redacts `access_token` and `appsecret_proof` from any
    +   URLs returned in error payloads (`_redact_url` helper).
    +
    +**Action for operators.**
    +- Upgrade to `1.0.109` or later.
    +- HTTP clients must send `Authorization: Bearer <meta-access-token>` (or the
    +  legacy `X-PIPEBOARD-API-TOKEN` header) on every request. The
    +  `META_ACCESS_TOKEN` env var is no longer used as an implicit fallback for
    +  HTTP transport.
    +- If you exposed an earlier version to an untrusted network, rotate the Meta
    +  access token (`https://developers.facebook.com/tools/debug/accesstoken/`)
    +  and review Graph API access logs for unexpected calls.
    
  • server.json+1 1 modified
    @@ -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.108",
    +  "version": "1.0.109",
       "remotes": [
         {
           "type": "streamable-http",
    
  • tests/test_http_auth_security.py+144 0 added
    @@ -0,0 +1,144 @@
    +"""Regression tests for GHSA-9gw6-46qc-99vr.
    +
    +Covers two fixes:
    +1. AuthInjectionMiddleware rejects unauthenticated HTTP requests with 401
    +   instead of falling through to tool handlers that would use the
    +   META_ACCESS_TOKEN env var.
    +2. make_api_request() scrubs access_token/appsecret_proof from URLs returned
    +   in error payloads.
    +"""
    +
    +import asyncio
    +import json
    +from unittest.mock import patch, AsyncMock, MagicMock
    +
    +import httpx
    +import pytest
    +from starlette.applications import Starlette
    +from starlette.routing import Route
    +from starlette.responses import JSONResponse
    +from starlette.testclient import TestClient
    +
    +from meta_ads_mcp.core.api import _redact_url, make_api_request
    +from meta_ads_mcp.core.http_auth_integration import AuthInjectionMiddleware
    +
    +
    +def _build_app():
    +    async def downstream(request):
    +        # If middleware ever lets the request through unauthenticated, this
    +        # endpoint would be reached and return a token-shaped body.
    +        return JSONResponse({"reached_handler": True})
    +
    +    app = Starlette(routes=[Route("/mcp", downstream, methods=["POST", "GET"])])
    +    app.add_middleware(AuthInjectionMiddleware)
    +    return app
    +
    +
    +def test_middleware_rejects_unauthenticated_request():
    +    client = TestClient(_build_app())
    +    resp = client.post(
    +        "/mcp",
    +        json={"jsonrpc": "2.0", "method": "tools/call", "id": 1,
    +              "params": {"name": "get_ad_accounts", "arguments": {}}},
    +        headers={"Accept": "application/json, text/event-stream"},
    +    )
    +    assert resp.status_code == 401
    +    assert resp.headers.get("WWW-Authenticate") == "Bearer"
    +    body = resp.json()
    +    assert body["error"] == "Unauthorized"
    +    assert "reached_handler" not in resp.text
    +
    +
    +def test_middleware_accepts_bearer_token():
    +    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"},
    +    )
    +    assert resp.status_code == 200
    +    assert resp.json() == {"reached_handler": True}
    +
    +
    +def test_middleware_accepts_pipeboard_token():
    +    client = TestClient(_build_app())
    +    resp = client.post(
    +        "/mcp",
    +        json={"jsonrpc": "2.0", "method": "tools/list", "id": 1},
    +        headers={"X-PIPEBOARD-API-TOKEN": "pb-token-abc"},
    +    )
    +    assert resp.status_code == 200
    +
    +
    +def test_redact_url_strips_access_token():
    +    url = (
    +        "https://graph.facebook.com/v24.0/me/adaccounts"
    +        "?fields=id&limit=1&access_token=SECRET_TOKEN_VALUE_123456789"
    +    )
    +    redacted = _redact_url(url)
    +    assert "SECRET_TOKEN_VALUE_123456789" not in redacted
    +    assert "access_token=REDACTED" in redacted
    +    assert "fields=id" in redacted
    +    assert "limit=1" in redacted
    +
    +
    +def test_redact_url_strips_appsecret_proof():
    +    url = "https://graph.facebook.com/v24.0/me?access_token=T&appsecret_proof=PROOF"
    +    redacted = _redact_url(url)
    +    assert "PROOF" not in redacted
    +    assert "appsecret_proof=REDACTED" in redacted
    +    assert "access_token=REDACTED" in redacted
    +
    +
    +def test_redact_url_no_query_string():
    +    url = "https://graph.facebook.com/v24.0/me/adaccounts"
    +    assert _redact_url(url) == url
    +
    +
    +def test_redact_url_empty():
    +    assert _redact_url("") == ""
    +
    +
    +@pytest.mark.asyncio
    +async def test_make_api_request_error_response_does_not_leak_token():
    +    """End-to-end check: 4xx from Graph API must not echo the access token."""
    +    secret = "FAKE_ACCESS_TOKEN_VALUE_FOR_TEST_123"
    +
    +    # Mock httpx response: 400 with a Graph-style error body.
    +    error_body = {"error": {"message": "Invalid OAuth access token data.",
    +                            "type": "OAuthException", "code": 190}}
    +
    +    fake_request = httpx.Request(
    +        "GET",
    +        f"https://graph.facebook.com/v24.0/me/adaccounts?fields=id&access_token={secret}",
    +    )
    +    fake_response = httpx.Response(
    +        status_code=400,
    +        request=fake_request,
    +        headers={"content-type": "application/json"},
    +        content=json.dumps(error_body).encode(),
    +    )
    +
    +    async def fake_get(self, url, params=None, headers=None, timeout=None):
    +        # httpx.AsyncClient.get is called with params separately; build the
    +        # final URL the same way httpx would for an accurate test.
    +        req = httpx.Request("GET", url, params=params, headers=headers)
    +        resp = httpx.Response(
    +            status_code=400,
    +            request=req,
    +            headers={"content-type": "application/json"},
    +            content=json.dumps(error_body).encode(),
    +        )
    +        return resp
    +
    +    with patch("httpx.AsyncClient.get", new=fake_get):
    +        result = await make_api_request("me/adaccounts", secret, {"fields": "id"})
    +
    +    assert "error" in result
    +    full = result["error"]["full_response"]
    +    serialized = json.dumps(result)
    +    assert secret not in serialized, (
    +        f"access_token leaked in error payload: {serialized}"
    +    )
    +    assert "access_token=REDACTED" in full["request_url"]
    +    assert "access_token=REDACTED" in full["url"]
    

Vulnerability mechanics

Root cause

"Missing authentication check in `AuthInjectionMiddleware.dispatch()` allows unauthenticated requests to reach MCP tool handlers, which then use the operator's `META_ACCESS_TOKEN` environment variable and leak it in Graph API error responses."

Attack vector

An attacker who can reach the MCP server's HTTP port (default 8080) sends a `POST /mcp` request with no authentication headers. The middleware logs a warning but calls `call_next(request)` without returning 401 [CWE-287]. Tool handlers fall back to the `META_ACCESS_TOKEN` environment variable and invoke the Meta Graph API with the token in the query string. When the Graph API returns a 4xx error, the server echoes the full `request_url`—including `access_token=…`—in a 200 OK JSON-RPC response, exfiltrating the operator's long-lived credential. [ref_id=1] [ref_id=2]

Affected code

`AuthInjectionMiddleware.dispatch()` at `http_auth_integration.py:272` unconditionally forwarded requests to tool handlers even when no `Authorization` or `X-PIPEBOARD-API-TOKEN` header was present. `make_api_request()` at `api.py:136` appended the operator's `access_token` as a URL query parameter, and the error-handling path at `api.py:263–269` serialised the raw `httpx` request URL—including the token—verbatim into the JSON-RPC response body. [ref_id=1] [ref_id=2]

What the fix does

The patch adds an early return of `401 Unauthorized` with `WWW-Authenticate: Bearer` in `AuthInjectionMiddleware.dispatch()` when neither token header is present, preventing unauthenticated requests from reaching tool handlers. It also introduces `_redact_url()` in `api.py` which strips `access_token` and `appsecret_proof` query parameters from URLs returned in error payloads, replacing their values with `REDACTED`. Together these changes close both the authentication bypass and the credential leakage. [patch_id=5595003]

Preconditions

  • configThe MCP server must be running with `--transport streamable-http` and have a `META_ACCESS_TOKEN` environment variable set.
  • networkThe server's HTTP port (default 8080) must be reachable from the attacker's network.
  • authNo authentication headers (`Authorization: Bearer` or `X-PIPEBOARD-API-TOKEN`) are required — the attacker sends a bare request.
  • inputThe attacker sends a `tools/call` JSON-RPC request that triggers a downstream Meta Graph API call that returns a 4xx error.

Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.