Please report security issues privately via GitHub: open a draft advisory from the Security tab ("Report a vulnerability"). Do not file public issues for unpatched vulnerabilities.
GHSA-45gf-fjxp-cjpq — Server-Side Request Forgery (SSRF) in upload_ad_image via unrestricted image_url fetch
- Severity: High (CVSS 3.1 8.3 —
AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:L) - Affected versions:
<= 1.0.114when run with--transport streamable-http. - Fixed in:
1.0.115 - Affected configurations: Self-hosted deployments that expose the
streamable-HTTP port on a reachable network interface, especially when
co-located with internal services or a cloud metadata endpoint. The hosted
MCP at
*.mcp.pipeboard.cobinds the Python process to localhost behind an authenticating proxy, so it was not reachable by unauthenticated callers; the fix is still applied there as defense-in-depth.
What went wrong. upload_ad_image (and the image-viewing tools) passed a
caller-supplied URL straight to an HTTP client (download_image /
try_multiple_download_methods) with follow_redirects=True and no scheme,
host, or IP validation. A caller could supply http://127.0.0.1/..., an RFC
1918 address, or http://169.254.169.254/ (cloud instance metadata) and make
the server issue outbound requests to those targets. On the streamable-HTTP
transport the image fetch ran before Meta credential validation, so any
non-empty bearer token reached the sink.
Fix.
- A new
validate_public_url()guard restricts fetches tohttp/httpsURLs whose host resolves only to public addresses. Private, loopback, link-local (incl.169.254.169.254), reserved, multicast, and unspecified addresses are rejected; IPv4-mapped IPv6 addresses are unwrapped first. - An httpx request event hook re-validates every redirect hop, so a public URL cannot redirect into a private/internal address.
Action for operators.
- Upgrade to
1.0.115or later. - If you exposed an earlier version on a reachable interface, review outbound request and access logs for unexpected internal fetches, and ensure the cloud metadata service is hardened (e.g. IMDSv2 / metadata not reachable from the app process).
- 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.108when run with--transport streamable-httpand aMETA_ACCESS_TOKENenvironment 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.cowas 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.
AuthInjectionMiddlewarenow returns401 UnauthorizedwithWWW-Authenticate: Bearerwhen neither token header is present.make_api_request()redactsaccess_tokenandappsecret_prooffrom any URLs returned in error payloads (_redact_urlhelper).
Action for operators.
- Upgrade to
1.0.109or later. - HTTP clients must send
Authorization: Bearer <meta-access-token>(or the legacyX-PIPEBOARD-API-TOKENheader) on every request. TheMETA_ACCESS_TOKENenv 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.
- Severity: High (CVSS 3.1 7.4 —
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N) - Affected versions:
<= 1.0.113when run with--transport streamable-httpand aMETA_ACCESS_TOKENenvironment variable. - Fixed in:
1.0.115 - Affected configurations: Self-hosted deployments that expose the
streamable-HTTP port on a reachable network interface with
META_ACCESS_TOKENset. The hosted MCP at*.mcp.pipeboard.cowas not affected — it does not setMETA_ACCESS_TOKENand binds the Python process to localhost behind an authenticating proxy.
What went wrong. This is a follow-up to GHSA-9gw6-46qc-99vr. After that fix,
AuthInjectionMiddleware rejected a request only when both a primary token and
a supplementary token were absent. X-Pipeboard-Token is a supplementary service
token (used only for the duplication callback) and is read into the supplementary
slot, so a request carrying X-Pipeboard-Token alone — with any arbitrary value —
satisfied the guard and passed through. No request auth context was set, so tool
handlers fell back to the operator's META_ACCESS_TOKEN, letting a
network-reachable caller act as the operator.
Fix. AuthInjectionMiddleware now requires a primary access-token credential
(Authorization: Bearer, X-META-ACCESS-TOKEN, or X-PIPEBOARD-API-TOKEN) and
returns 401 Unauthorized otherwise. X-Pipeboard-Token is treated as a
supplementary token only and cannot, on its own, admit a request.
Action for operators.
- Upgrade to
1.0.115or later. - If you exposed an earlier version on a reachable network with
META_ACCESS_TOKENset, rotate the Meta access token (https://developers.facebook.com/tools/debug/accesstoken/) and review Graph API access logs for unexpected calls.
GHSA-8353-5qhw-8hfw — Unauthenticated HTTP MCP tool execution under --sse-response (auth middleware installed on the unserved app)
- 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.118when run with--transport streamable-http --sse-responseand aMETA_ACCESS_TOKENenvironment variable, on a network-reachable interface. - Fixed in:
1.0.119 - Affected configurations: Self-hosted deployments that pass
--sse-responseand expose the streamable-HTTP port on a reachable network interface withMETA_ACCESS_TOKENset. The default JSON response mode is not affected. The hosted MCP at*.mcp.pipeboard.cowas not affected — it runs in the default JSON mode, does not setMETA_ACCESS_TOKEN, and binds the Python process to localhost behind an authenticating proxy.
What went wrong. This is a third instance of the class fixed in
GHSA-9gw6-46qc-99vr and GHSA-2v2f-mvfg-ph56. Those fixes hardened the HTTP auth
middleware's request handling, but under --sse-response the middleware was not
attached to the Starlette app that the streamable-http transport actually
serves. As a result the served endpoint carried no auth gate in that
configuration, so requests could reach tool handlers and fall back to the
operator's META_ACCESS_TOKEN. The default JSON response mode was unaffected.
Fix. The HTTP auth middleware is now attached to the served app
unconditionally (independent of the response-format setting), with the SSE app
covered as well for defense-in-depth. Regression tests assert the served app
rejects unauthenticated requests in both JSON and --sse-response modes.
Action for operators.
- Upgrade to
1.0.119or later. - If you exposed an earlier version with
--transport streamable-http --sse-responseandMETA_ACCESS_TOKENset on a reachable network, rotate the Meta access token (https://developers.facebook.com/tools/debug/accesstoken/) and review Graph API access logs for unexpected calls. - Credited to zx (Jace) — GitHub @manus-use.
- Severity: Medium
- Affected versions:
<= 1.0.120when the local OAuth login flow is used (the callback server listens on127.0.0.1:8080-8089). - Fixed in:
1.0.121 - Affected configurations: Anyone completing an interactive OAuth login on
a desktop where a browser can reach
localhostduring the 180-second authorization window. The hosted MCP at*.mcp.pipeboard.codoes not run the local callback server.
What went wrong. The callback server interpolated the error query
parameter straight into its HTML error page, so a page open in the user's
browser could navigate to the callback URL with script in error and execute
it on the localhost origin. The server also exposed an unauthenticated
/token endpoint that returned the stored OAuth authorization code to any
same-origin page — the escalation target for the XSS above.
Fix.
- The
errorvalue is HTML-escaped (and truncated) before being reflected. - Every callback response now carries
Content-Security-Policy: default-src 'none',X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrerandCache-Control: no-store; the success page's inline script runs under a per-response nonce. - The unreferenced
/tokenendpoint was removed.
Action for operators.
- Upgrade to
1.0.121or later. - If you completed an OAuth login on a machine where an untrusted page may have been open, reconnect the Meta account so a fresh token is issued.
- Credited to zx — GitHub @manus-pi.
GHSA-j7p8-g5m6-3wv5 — search/fetch deep-research tools returned one caller's cached records to another
- Severity: High (CVSS 3.1 7.5 —
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) - Affected versions:
>= 0.6.0, <= 1.0.121when run with--transport streamable-httpand serving more than one caller from one process. - Fixed in:
1.0.122 - Affected configurations: Multi-caller deployments only. The default stdio
transport, and any deployment where a process serves a single caller, has no
cross-caller boundary to cross. The hosted MCP at
*.mcp.pipeboard.cointerceptssearchand answers it with its own implementation, so the Python cache was never populated there andfetchhad nothing to return — measured over production traffic, every hostedfetchcall returned "Record not found" or a schema-validation error.
What went wrong. openai_deep_research.py cached Meta Ads records in a
module-level MetaAdsDataManager singleton, keyed only by "<type>:<id>" with
no caller, session or tenant component. search populated that cache under the
calling user's credential; fetch read it back with no credential resolution
and no ownership check, so any caller who knew (or guessed) a record id could
read another caller's cached ad-account, campaign, ad, page or business records
— including account name, currency, total spend, balance and the raw Graph
objects. fetch never called the Graph API, so nothing downstream validated
the reader's token either; the HTTP auth gate gets a request past on any
non-empty bearer token, which is enough for every other tool because Graph
rejects a bad token, but not for a tool that answers from local state.
Fix. The search and fetch tools and the module that backed them were
removed outright. They had no working use left to preserve, so scoping the
cache per caller would have kept the machinery without the benefit.
Action for operators.
- Upgrade to
1.0.122or later. - If you ran an earlier version as a shared multi-caller service, treat the record types listed above as potentially disclosed to other callers of that process.
- Clients that relied on the deep-research
search/fetchpair should use the typed tools instead:get_ad_accounts,get_campaigns,get_campaign_details,get_adsets,get_ads,get_ad_details. - Credited to @BarakSrour.
- Severity: Low (reported as Medium; see Reachability below)
- Affected versions:
<= 1.0.122 - Fixed in:
1.0.123 - Affected configurations: Would have applied to multi-caller
--transport streamable-httpdeployments. The hosted MCP at*.mcp.pipeboard.codoes not expose the MCP resources interface to clients.
What was wrong. utils.ad_creative_images was a module-level dict of ad
creative images keyed by image hash alone, with no caller, session or tenant
component and no eviction. Two MCP resources registered on the server read it
directly: meta-ads://resources (list_resources) enumerated every entry, and
meta-ads://images/{resource_id} (get_resource) returned any entry's raw
bytes. Neither handler resolved or used the request credential, so the shape of
the code was a cross-caller read with an enumeration primitive attached.
Reachability. No shipped version could populate that cache. The only write
was create_resource_from_image(), which has had no call sites since 0.4.0
(July 2025) — get_ad_image returns the image directly and never cached it.
Verified against the running server: meta-ads://resources returns
{"resources": []} and every meta-ads://images/... read returns
"Resource not found". No creative image of any caller was ever exposed through
this interface. The finding is accurate about the code and does not describe a
disclosure that could occur in practice.
Fix. The cache, its writer, the resources module and both resource
registrations were removed. An unscoped global with live readers is one commit
away from being exploitable the moment a write path returns.
Action for operators. None required. Upgrade to 1.0.123 to drop the dead
interface. Clients that enumerated meta-ads://resources (it only ever returned
an empty list) should use get_ad_image / get_ad_creatives instead.
- Credited to @Gal3m and @mohammad228.
GHSA-75j5-qp3x-mx37 — OAuth callback server: reflected XSS, /token disclosure, and missing CSRF state
- Severity: Medium
- Affected versions:
<= 1.0.120for the XSS and/tokendisclosure;<= 1.0.123for the missingstatecheck. - Fixed in:
1.0.121(XSS,/token) and1.0.124(state, bind address, server lifetime). - Affected configurations: Installs that run the local OAuth login flow
(
--login, theget_login_linktool, orauthenticate()), which listens on127.0.0.1:8080-8089during the authorization window. The hosted MCP at*.mcp.pipeboard.codoes not run the callback server.
What went wrong. Three weaknesses in callback_server.py were reported
together as a credential-theft chain:
- The
errorquery parameter was reflected into the HTML error page without escaping — fixed in1.0.121, tracked as GHSA-6v2r-2m4r-768m. GET /tokenreturned the stored OAuth authorization artifact to any same-origin page — the endpoint was removed in1.0.121.get_auth_urlemitted nostateparameter and the callback validated none (RFC 6749 §10.12), so a page that could reach the callback server could hand it an authorization code of the attacker's choosing.
Fix for item 3 (1.0.124). A cryptographically random state is minted per
authorization request, carried in the authorization URL, and required on the
callback: a code whose state is missing, mismatched, or replayed is rejected
and never stored. The state is single-use. The server now binds 127.0.0.1
explicitly rather than whatever localhost resolves to, and shuts down as soon
as it has handled a valid callback instead of idling out the 180-second window.
On the report's fourth suggestion (random high port). The callback port
cannot be randomized: Meta validates the redirect URI against the list
registered on the app, so the flow only works on pre-registered ports. The
server stays on 8080-8089 by necessity; the state check, not port secrecy,
is what makes an unsolicited callback useless.
Action for operators.
- Upgrade to
1.0.124or later. - If you completed an interactive login on a machine where an untrusted page may have been open, reconnect the Meta account so a fresh token is issued.
- Credited to @Gal3m and @mohammad228.
- Severity: Medium
- Affected versions:
>= 1.0.115, <= 1.0.124— i.e. every version carrying the original SSRF guard. - Fixed in:
1.0.125 - Affected configurations: Deployments where non-public services live in
100.64.0.0/10— GKE and other Kubernetes clusters default pod/service CIDRs into that range, as do cloud internal endpoints and ISP CGNAT — and where a caller can driveupload_ad_imageor the image-viewing tools (including an agent acting on injected instructions).
What went wrong. validate_public_url classified a resolved address with a
category denylist: is_private or is_loopback or is_link_local or is_reserved or is_multicast or is_unspecified. 100.64.0.0/10 (RFC 6598 shared address space)
satisfies none of those — it is is_global == False but neither private nor
reserved — so a URL resolving into it passed the guard and was fetched
server-side, and the redirect re-validation hook allowed redirects that stayed
inside the range. Loopback, RFC 1918 and link-local (including the cloud
metadata endpoint) remained blocked, so the exposure was a partial internal-range
SSRF confined to shared address space.
Fix. The guard now tests positively for ip.is_global and rejects anything
else, so non-public ranges no longer have to be enumerated to be blocked. The
original category checks are kept alongside it, plus an explicit list of
non-public networks (100.64.0.0/10, 192.0.0.0/24, 198.18.0.0/15, the RFC
5737 TEST-NETs, 240.0.0.0/4, 2001:db8::/32, 64:ff9b:1::/48), because
is_global's backing table has shifted across CPython patch releases. IPv4-mapped
IPv6 addresses are still unwrapped first, so ::ffff:100.64.1.1 is rejected too.
Action for operators.
- Upgrade to
1.0.125or later. - If you ran an earlier version where
100.64.0.0/10reaches internal services, review outbound request logs for fetches into that range. - Credited to @Gal3m and @mohammad228.
- Severity: Medium
- Affected versions:
<= 1.0.125, and only when the tool is enabled. - Fixed in:
1.0.126 - Affected configurations: Installs that set
META_ADS_ENABLE_SAVE_AD_IMAGE_LOCALLY— the tool is not registered without it. The hosted MCP at*.mcp.pipeboard.codoes not set it, so the tool has never been exposed there.
What went wrong. save_ad_image_locally built its destination from two
caller-supplied tool arguments with no validation:
filename = f"{ad_id}_{image_hashes[0]}.jpg"
filepath = os.path.join(output_dir, filename)
os.makedirs(output_dir) # built whatever tree was neededos.path.join returns an absolute second argument unchanged and does not
neutralize .., so output_dir="/etc/cron.d", ad_id="/tmp/PWNED" or
ad_id="../../../../tmp/evil" all wrote the downloaded image outside the
intended ad_images directory, with the server process's privileges, creating
directories along the way. The bytes are an ad image, which a caller who
controls the creative can influence.
Fix. A new resolve_ad_image_save_path() decides the destination before
anything touches the disk: ad_id must be a Meta object id (digits), the image
hash must be filename-safe, and output_dir is resolved with os.path.realpath
and must land on or under an allowed base directory — so absolute paths, ..
and symlinks pointing out of the base are all rejected. The base is the working
directory the server was started in, which is where the documented default
(ad_images) has always resolved; operators who want images elsewhere set
META_ADS_IMAGE_OUTPUT_DIR. Directories are only created after the path passes.
Action for operators.
- Upgrade to
1.0.126or later. - If you enabled the tool on an earlier version, check for unexpected files
matching
*_<hash>.jpgoutside your image directory. - Credited to @Gal3m and @mohammad228.
- Severity: Medium
- Affected versions:
<= 1.0.126 - Fixed in:
1.0.127 - Affected configurations: Local installs that authenticate through the
OAuth flow and cache a token (
~/.config/meta-ads-mcp/token_cache.json, or~/Library/Application Support/meta-ads-mcp/on macOS). Matters most on shared hosts — CI runners, bastion boxes, multi-UID containers. The hosted MCP at*.mcp.pipeboard.codoes not cache tokens to disk.
What went wrong. The cache file was created with a bare open(path, "w")
and its directory with mkdir() with no mode, so under the usual 0022 umask
the long-lived Meta access token (~60 days) landed in a 0644 file inside a
0755 directory. Any other local user could read it and act as the operator
against the Graph API.
Fix. The file is created with os.open(..., O_CREAT, 0o600) and the
directory with mode 0o700. Because O_CREAT's mode applies only at creation
and mkdir's mode is masked by the umask (and is a no-op for a directory that
already exists), both are also narrowed explicitly afterwards — which repairs a
cache written by an earlier version. The same narrowing runs when a cache is
loaded, so an existing install stops being readable as soon as the server
starts, with no need to re-authenticate. On Windows the chmod step is skipped:
it cannot express "owner only" there, where access is governed by ACLs.
Action for operators.
- Upgrade to
1.0.127or later; the permissions are repaired on the next load or save. - If you ran an earlier version on a shared host, treat the cached token as
exposed: rotate it at
https://developers.facebook.com/tools/debug/accesstoken/and review Graph API access logs. - Credited to @Gal3m and @mohammad228.
- Severity: Medium
- Affected versions:
<= 1.0.127 - Fixed in:
1.0.128 - Affected configurations: Any install that makes Graph API calls. The log
file lives at
~/.config/meta-ads-mcp/meta_ads_debug.log(~/Library/Application Support/meta-ads-mcp/on macOS) and is never rotated, so records accumulate indefinitely. It matters most where logs are shipped somewhere else — container stdout collection, a log aggregator, a SIEM — or on a shared host.
What the report described, and what was still live. The advisory cites
pipeboard_auth.py, which put PIPEBOARD_API_TOKEN in a URL query string and
logged the full URL. That module was removed in 1.0.121, and no credential is
placed in a query string by this package today, so that part no longer applies.
The logging half did still apply, through a different route. setup_logging()
called logging.basicConfig(level=DEBUG, filename=...), which installs a handler
on the root logger — so every third-party record was captured too, including
httpx's INFO line:
httpx - INFO - HTTP Request: GET https://graph.facebook.com/v24.0/me/adaccounts?access_token=<the operator's token> "HTTP/1.1 200 OK"
The package masks the token in its own request logging, but the Graph API takes
access_token as a query parameter, so httpx logged it in full on every call.
The file was created at 0644.
Fix.
- The file handler is attached to this package's logger with
propagate = Falseinstead of the root logger, so third-party records are no longer captured. httpxandhttpcoreare held atWARNING, so the URL line is not emitted into logging the host application configures either.- The default level is
INFOrather thanDEBUG; setMETA_ADS_LOG_LEVEL=DEBUGto restore the old verbosity. - The log file is created
0600inside a0700directory, and both are narrowed on startup if an earlier version left them wider. - Credential prefixes (
token[:10]) in log lines and in theget_login_linkresponse are replaced byredact_secret(), which reports the length only. Ten characters is still credential material in a file that is never rotated, and enough to correlate one caller's requests on a shared deployment.
Action for operators.
- Upgrade to
1.0.128or later. - Existing
meta_ads_debug.logfiles contain access tokens in full. Delete them, and if the logs were shipped anywhere or the host is shared, rotate the token athttps://developers.facebook.com/tools/debug/accesstoken/. - Credited to @Gal3m and @mohammad228.