Skip to content

Commit ef2592d

Browse files
authored
fix(security): attach HTTP auth middleware to served app under --sse-response (GHSA-8353-5qhw-8hfw) (#151)
* fix(security): attach HTTP auth middleware to served app under --sse-response (GHSA-8353-5qhw-8hfw) The streamable-http auth gate was attached to the response app selected by json_response, so under --sse-response it landed on an app that is not served and the auth check did not run. Attach AuthInjectionMiddleware to the served streamable_http_app unconditionally (independent of response format), with the SSE app covered for defense-in-depth. Default JSON mode was unaffected. Adds regression tests asserting the served app rejects unauthenticated requests in both JSON and --sse-response modes, plus a fix for a latent late-binding closure in the app-provider patch loop. Documents the HTTP per-request auth model in STREAMABLE_HTTP_SETUP.md and the advisory in SECURITY.md. Bump to 1.0.119. * chore(release): bump server.json to 1.0.119 to match pyproject
1 parent 3b612c7 commit ef2592d

7 files changed

Lines changed: 202 additions & 26 deletions

File tree

‎SECURITY.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,38 @@ supplementary token only and cannot, on its own, admit a request.
113113
`META_ACCESS_TOKEN` set, rotate the Meta access token
114114
(`https://developers.facebook.com/tools/debug/accesstoken/`) and review Graph
115115
API access logs for unexpected calls.
116+
117+
### GHSA-8353-5qhw-8hfw — Unauthenticated HTTP MCP tool execution under `--sse-response` (auth middleware installed on the unserved app)
118+
119+
- **Severity:** Critical (CVSS 3.1 9.1 — `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N`)
120+
- **Affected versions:** `<= 1.0.118` when run with
121+
`--transport streamable-http --sse-response` and a `META_ACCESS_TOKEN`
122+
environment variable, on a network-reachable interface.
123+
- **Fixed in:** `1.0.119`
124+
- **Affected configurations:** Self-hosted deployments that pass
125+
`--sse-response` and expose the streamable-HTTP port on a reachable network
126+
interface with `META_ACCESS_TOKEN` set. The **default JSON response mode is
127+
not affected**. The hosted MCP at `*.mcp.pipeboard.co` was not affected — it
128+
runs in the default JSON mode, does not set `META_ACCESS_TOKEN`, and binds the
129+
Python process to localhost behind an authenticating proxy.
130+
131+
**What went wrong.** This is a third instance of the class fixed in
132+
GHSA-9gw6-46qc-99vr and GHSA-2v2f-mvfg-ph56. Those fixes hardened the HTTP auth
133+
middleware's request handling, but under `--sse-response` the middleware was not
134+
attached to the Starlette app that the streamable-http transport actually
135+
serves. As a result the served endpoint carried no auth gate in that
136+
configuration, so requests could reach tool handlers and fall back to the
137+
operator's `META_ACCESS_TOKEN`. The default JSON response mode was unaffected.
138+
139+
**Fix.** The HTTP auth middleware is now attached to the served app
140+
unconditionally (independent of the response-format setting), with the SSE app
141+
covered as well for defense-in-depth. Regression tests assert the served app
142+
rejects unauthenticated requests in both JSON and `--sse-response` modes.
143+
144+
**Action for operators.**
145+
- Upgrade to `1.0.119` or later.
146+
- If you exposed an earlier version with `--transport streamable-http
147+
--sse-response` and `META_ACCESS_TOKEN` set on a reachable network, rotate the
148+
Meta access token (`https://developers.facebook.com/tools/debug/accesstoken/`)
149+
and review Graph API access logs for unexpected calls.
150+
- Credited to zx (Jace) — GitHub [@manus-use](https://github.com/manus-use).

‎STREAMABLE_HTTP_SETUP.md‎

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ curl -X POST http://localhost:8080/mcp \
5353
| `--transport` | Transport mode | `stdio` |
5454
| `--host` | Server host address | `localhost` |
5555
| `--port` | Server port | `8080` |
56+
| `--sse-response` | Return responses as SSE streams instead of JSON | `false` (JSON) |
5657

5758
### Examples
5859

@@ -67,6 +68,42 @@ python -m meta_ads_mcp --transport streamable-http --host 0.0.0.0 --port 8080
6768
python -m meta_ads_mcp --transport streamable-http --port 9000
6869
```
6970

71+
## Security Model (read before exposing the port)
72+
73+
The HTTP transport uses a **per-request** authentication model that is
74+
deliberately different from stdio. Understanding it is the difference between a
75+
safe deployment and leaking your Meta account to the internet.
76+
77+
- **stdio (local):** the server acts as whatever credential is configured in its
78+
environment — `META_ACCESS_TOKEN`, `PIPEBOARD_API_TOKEN`, or a stored login.
79+
Only the local process that launched it can talk to it, so this is expected
80+
and safe. *If all you want is to use the MCP locally, prefer stdio.*
81+
82+
- **streamable-http (network):** every request must carry **its own** token
83+
header — `Authorization: Bearer <token>`, `X-META-ACCESS-TOKEN`, or the legacy
84+
`X-PIPEBOARD-API-TOKEN`. Requests without one are rejected with
85+
`401 Unauthorized`. The server-side `META_ACCESS_TOKEN` environment variable is
86+
**intentionally not** used as an implicit fallback for HTTP requests — if it
87+
were, any caller who can reach the port would act as you. This gating is
88+
enforced by `AuthInjectionMiddleware`.
89+
90+
**Operational rules:**
91+
92+
1. **Do not expose the raw port to an untrusted network.** `--host 0.0.0.0`
93+
binds to every interface. Put the server behind an authenticating reverse
94+
proxy (or a private network / firewall), exactly as the hosted MCP at
95+
`*.mcp.pipeboard.co` does (localhost-bound Python process behind a proxy).
96+
2. **Avoid setting `META_ACCESS_TOKEN` on a network-exposed HTTP server.** It is
97+
an operator-wide, long-lived credential. Have callers pass their own tokens
98+
in headers instead. If you must set it (e.g. single-tenant behind a trusted
99+
proxy), ensure the port is unreachable by untrusted callers.
100+
3. **The auth gate applies in both response modes** — default JSON and
101+
`--sse-response`. (Historically `--sse-response` had a bug where the gate was
102+
not attached to the served app; see `SECURITY.md`, GHSA-8353-5qhw-8hfw. Fixed
103+
in `1.0.119` — upgrade if you use `--sse-response`.)
104+
4. **If you ever exposed an unauthenticated server, rotate the Meta access
105+
token** and review Graph API access logs.
106+
70107
## Authentication
71108

72109
### Primary Method: Bearer Token (Recommended)
@@ -295,10 +332,18 @@ client.callTool('get_ad_accounts', { limit: 5 })
295332

296333
### Security Considerations
297334

335+
See **[Security Model](#security-model-read-before-exposing-the-port)** above for
336+
the per-request auth model and why it matters. In short:
337+
298338
1. **Use HTTPS**: In production, run behind a reverse proxy with SSL/TLS
299-
2. **Authentication**: Always use valid Bearer tokens.
300-
3. **Network Security**: Configure firewalls and access controls appropriately
301-
4. **Rate Limiting**: Consider implementing rate limiting for public APIs
339+
2. **Authentication**: Every request must carry its own token header; the server
340+
returns `401` otherwise. Do not rely on a server-side `META_ACCESS_TOKEN` as
341+
an implicit credential for HTTP callers — it is not used as a fallback.
342+
3. **Network Security**: Never expose the raw port to an untrusted network. Bind
343+
to localhost behind an authenticating proxy, or restrict with firewalls.
344+
4. **Avoid `META_ACCESS_TOKEN` on network-exposed servers**: it is an
345+
operator-wide credential; prefer per-request header tokens.
346+
5. **Rate Limiting**: Consider implementing rate limiting for public APIs
302347

303348
### Docker Deployment
304349

@@ -325,7 +370,11 @@ export PIPEBOARD_API_TOKEN=your_pipeboard_token
325370
export META_APP_ID=your_app_id
326371
export META_APP_SECRET=your_app_secret
327372

328-
# Optional (for direct Meta token)
373+
# Optional (for direct Meta token).
374+
# WARNING: on the HTTP transport this is NOT used as a fallback credential for
375+
# incoming requests — callers must pass their own token header (see "Security
376+
# Model"). Setting it on a network-exposed server is an operator-wide credential
377+
# risk; prefer stdio, or keep the port unreachable by untrusted callers.
329378
export META_ACCESS_TOKEN=your_access_token
330379
```
331380

‎meta_ads_mcp/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from meta_ads_mcp.core.server import main
88

9-
__version__ = "1.0.118"
9+
__version__ = "1.0.119"
1010

1111
__all__ = [
1212
'get_ad_accounts',

‎meta_ads_mcp/core/http_auth_integration.py‎

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -196,36 +196,48 @@ def setup_fastmcp_http_auth(mcp_server):
196196
# 2. Patch the methods that provide the Starlette app instance
197197
# This ensures our middleware is added to the app Uvicorn will actually serve.
198198

199+
# The streamable-http transport ALWAYS serves the app returned by
200+
# streamable_http_app(): the SDK's run_streamable_http_async() calls
201+
# self.streamable_http_app() regardless of the json_response setting.
202+
# json_response is only forwarded to StreamableHTTPSessionManager as an
203+
# SSE-vs-JSON wire-format switch — it does NOT change which Starlette app is
204+
# served. Selecting the app to patch by json_response therefore attaches the
205+
# auth middleware to the wrong (unserved) app under --sse-response, leaving
206+
# the served app without an auth gate. See GHSA-8353-5qhw-8hfw.
207+
#
208+
# Patch streamable_http_app unconditionally (it is the served app), and
209+
# also sse_app when present as defense-in-depth against future SDK routing
210+
# changes. Both response formats are served by streamable_http_app today.
199211
app_provider_methods = []
200-
if mcp_server.settings.json_response:
201-
if hasattr(mcp_server, "streamable_http_app") and callable(mcp_server.streamable_http_app):
202-
app_provider_methods.append("streamable_http_app")
203-
else:
204-
logger.warning("mcp_server.streamable_http_app not found or not callable, cannot patch for JSON responses.")
205-
else: # SSE
206-
if hasattr(mcp_server, "sse_app") and callable(mcp_server.sse_app):
207-
app_provider_methods.append("sse_app")
208-
else:
209-
logger.warning("mcp_server.sse_app not found or not callable, cannot patch for SSE responses.")
212+
if hasattr(mcp_server, "streamable_http_app") and callable(mcp_server.streamable_http_app):
213+
app_provider_methods.append("streamable_http_app")
214+
else:
215+
logger.error("mcp_server.streamable_http_app not found or not callable — the served streamable-http app cannot be protected with AuthInjectionMiddleware.")
216+
if hasattr(mcp_server, "sse_app") and callable(mcp_server.sse_app):
217+
app_provider_methods.append("sse_app")
210218

211219
if not app_provider_methods:
212220
logger.error("No suitable app provider method (streamable_http_app or sse_app) found on mcp_server. Cannot add HTTP Auth middleware.")
213221
# Fallback or error handling might be needed here if this is critical
214-
222+
215223
for method_name in app_provider_methods:
216224
original_app_provider_method = getattr(mcp_server, method_name)
217-
218-
def new_patched_app_provider_method(*args, **kwargs):
225+
226+
# Bind method_name / original via default args so each patched closure
227+
# captures its own iteration's values. Late-binding by reference would
228+
# make every patched method call the LAST iteration's original — a real
229+
# bug now that more than one method is patched.
230+
def new_patched_app_provider_method(*args, _method_name=method_name, _original=original_app_provider_method, **kwargs):
219231
# Call the original method to get/create the Starlette app
220-
app = original_app_provider_method(*args, **kwargs)
232+
app = _original(*args, **kwargs)
221233
if app:
222-
logger.debug(f"Original {method_name} returned app: {type(app)}. Adding AuthInjectionMiddleware.")
234+
logger.debug(f"Original {_method_name} returned app: {type(app)}. Adding AuthInjectionMiddleware.")
223235
# Now, add our middleware to this specific app instance
224-
setup_starlette_middleware(app)
236+
setup_starlette_middleware(app)
225237
else:
226-
logger.error(f"Original {method_name} returned None or a non-app object.")
238+
logger.error(f"Original {_method_name} returned None or a non-app object.")
227239
return app
228-
240+
229241
setattr(mcp_server, method_name, new_patched_app_provider_method)
230242
logger.debug(f"Patched mcp_server.{method_name} to inject AuthInjectionMiddleware.")
231243

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "meta-ads-mcp"
7-
version = "1.0.118"
7+
version = "1.0.119"
88
description = "Model Context Protocol (MCP) server for Meta Ads - Use Remote MCP at pipeboard.co for easiest setup"
99
readme = "README.md"
1010
requires-python = ">=3.10"

‎server.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
33
"name": "co.pipeboard/meta-ads-mcp",
44
"description": "Facebook / Meta Ads automation with AI: analyze performance, test creatives, optimize spend.",
5-
"version": "1.0.118",
5+
"version": "1.0.119",
66
"remotes": [
77
{
88
"type": "streamable-http",

‎tests/test_http_auth_security.py‎

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
from starlette.testclient import TestClient
2121

2222
from meta_ads_mcp.core.api import _redact_url, make_api_request
23-
from meta_ads_mcp.core.http_auth_integration import AuthInjectionMiddleware
23+
from meta_ads_mcp.core.http_auth_integration import (
24+
AuthInjectionMiddleware,
25+
setup_fastmcp_http_auth,
26+
)
2427

2528

2629
def _build_app():
@@ -102,6 +105,83 @@ def test_middleware_accepts_supplementary_token_with_primary_credential():
102105
assert resp.json() == {"reached_handler": True}
103106

104107

108+
# ---------------------------------------------------------------------------
109+
# GHSA-8353-5qhw-8hfw: AuthInjectionMiddleware must be attached to the app that
110+
# the streamable-http transport actually serves (streamable_http_app), in BOTH
111+
# JSON mode and --sse-response mode. The two tests above exercise the
112+
# middleware's dispatch() logic in isolation; these exercise the wiring in
113+
# setup_fastmcp_http_auth() that decides which served app the middleware lands
114+
# on. That wiring is where the vulnerability lived.
115+
# ---------------------------------------------------------------------------
116+
117+
def _has_auth_middleware(app):
118+
return any(m.cls == AuthInjectionMiddleware for m in app.user_middleware)
119+
120+
121+
def _fresh_fastmcp():
122+
# Imported lazily so the rest of the suite runs even if the SDK is absent.
123+
from mcp.server.fastmcp import FastMCP
124+
125+
return FastMCP("meta-ads-test")
126+
127+
128+
@pytest.mark.parametrize("sse_response", [False, True], ids=["json_mode", "sse_response_mode"])
129+
def test_served_app_has_auth_middleware(sse_response):
130+
"""The served streamable_http_app must carry AuthInjectionMiddleware in
131+
both wire-format modes. Under --sse-response the previous code attached the
132+
middleware to sse_app only (never served), leaving /mcp unauthenticated."""
133+
server = _fresh_fastmcp()
134+
# server.py:339 -> json_response = not args.sse_response
135+
server.settings.json_response = not sse_response
136+
137+
setup_fastmcp_http_auth(server)
138+
139+
# This is the exact app run_streamable_http_async() serves.
140+
served_app = server.streamable_http_app()
141+
assert _has_auth_middleware(served_app), (
142+
"streamable_http_app (the served app) is missing AuthInjectionMiddleware "
143+
f"with sse_response={sse_response}; unauthenticated callers could reach tools"
144+
)
145+
146+
147+
def test_served_app_rejects_unauthenticated_request_in_sse_mode():
148+
"""End-to-end: with --sse-response wiring, an unauthenticated POST /mcp to
149+
the served app is rejected with 401 rather than reaching a tool handler."""
150+
server = _fresh_fastmcp()
151+
server.settings.json_response = False # simulate --sse-response
152+
153+
setup_fastmcp_http_auth(server)
154+
served_app = server.streamable_http_app()
155+
156+
client = TestClient(served_app)
157+
resp = client.post(
158+
"/mcp",
159+
json={"jsonrpc": "2.0", "method": "tools/call", "id": 1,
160+
"params": {"name": "get_ad_accounts", "arguments": {}}},
161+
headers={"Accept": "application/json, text/event-stream"},
162+
)
163+
assert resp.status_code == 401
164+
assert resp.headers.get("WWW-Authenticate") == "Bearer"
165+
166+
167+
def test_patched_app_providers_do_not_cross_wire():
168+
"""Regression for the late-binding closure: patching both streamable_http_app
169+
and sse_app must not make one provider call the other's original (which would
170+
swap which app gets served / recurse). Each served app must be its own type
171+
and carry the middleware."""
172+
server = _fresh_fastmcp()
173+
server.settings.json_response = True
174+
175+
setup_fastmcp_http_auth(server)
176+
177+
streamable = server.streamable_http_app()
178+
sse = server.sse_app()
179+
# Both are Starlette apps but distinct instances with the middleware present.
180+
assert streamable is not sse
181+
assert _has_auth_middleware(streamable)
182+
assert _has_auth_middleware(sse)
183+
184+
105185
def test_redact_url_strips_access_token():
106186
url = (
107187
"https://graph.facebook.com/v24.0/me/adaccounts"

0 commit comments

Comments
 (0)