Skip to content

Commit 9365b94

Browse files
authored
fix(security): require an OAuth state on the callback (GHSA-75j5-qp3x-mx37) (#164)
get_auth_url emitted no state and the callback validated none (RFC 6749 10.12), so any page that could reach the local callback server could hand it an authorization code of the attacker's choosing. A random state is now minted per authorization request, carried in the auth URL and required on the callback: missing, mismatched or replayed state means the code is rejected and never stored. The server also binds 127.0.0.1 explicitly and shuts down once it has handled a valid callback rather than idling out the 180s window. The report's fourth item (random high port) is not actionable: Meta validates redirect URIs against the list registered on the app, so the flow only works on pre-registered ports. Bumps to 1.0.124 and documents the advisory in SECURITY.md.
1 parent 8b6b0fd commit 9365b94

8 files changed

Lines changed: 259 additions & 9 deletions

File tree

‎SECURITY.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,46 @@ an empty list) should use `get_ad_image` / `get_ad_creatives` instead.
258258

259259
- Credited to [@Gal3m](https://github.com/Gal3m) and
260260
[@mohammad228](https://github.com/mohammad228).
261+
262+
### GHSA-75j5-qp3x-mx37 — OAuth callback server: reflected XSS, `/token` disclosure, and missing CSRF `state`
263+
264+
- **Severity:** Medium
265+
- **Affected versions:** `<= 1.0.120` for the XSS and `/token` disclosure;
266+
`<= 1.0.123` for the missing `state` check.
267+
- **Fixed in:** `1.0.121` (XSS, `/token`) and `1.0.124` (`state`, bind address,
268+
server lifetime).
269+
- **Affected configurations:** Installs that run the local OAuth login flow
270+
(`--login`, the `get_login_link` tool, or `authenticate()`), which listens on
271+
`127.0.0.1:8080-8089` during the authorization window. The hosted MCP at
272+
`*.mcp.pipeboard.co` does not run the callback server.
273+
274+
**What went wrong.** Three weaknesses in `callback_server.py` were reported
275+
together as a credential-theft chain:
276+
277+
1. The `error` query parameter was reflected into the HTML error page without
278+
escaping — fixed in `1.0.121`, tracked as GHSA-6v2r-2m4r-768m.
279+
2. `GET /token` returned the stored OAuth authorization artifact to any
280+
same-origin page — the endpoint was removed in `1.0.121`.
281+
3. `get_auth_url` emitted no `state` parameter and the callback validated none
282+
(RFC 6749 §10.12), so a page that could reach the callback server could hand
283+
it an authorization code of the attacker's choosing.
284+
285+
**Fix for item 3 (`1.0.124`).** A cryptographically random `state` is minted per
286+
authorization request, carried in the authorization URL, and required on the
287+
callback: a code whose `state` is missing, mismatched, or replayed is rejected
288+
and never stored. The state is single-use. The server now binds `127.0.0.1`
289+
explicitly rather than whatever `localhost` resolves to, and shuts down as soon
290+
as it has handled a valid callback instead of idling out the 180-second window.
291+
292+
**On the report's fourth suggestion (random high port).** The callback port
293+
cannot be randomized: Meta validates the redirect URI against the list
294+
registered on the app, so the flow only works on pre-registered ports. The
295+
server stays on `8080-8089` by necessity; the `state` check, not port secrecy,
296+
is what makes an unsolicited callback useless.
297+
298+
**Action for operators.**
299+
- Upgrade to `1.0.124` or later.
300+
- If you completed an interactive login on a machine where an untrusted page may
301+
have been open, reconnect the Meta account so a fresh token is issued.
302+
- Credited to [@Gal3m](https://github.com/Gal3m) and
303+
[@mohammad228](https://github.com/mohammad228).

‎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.123"
9+
__version__ = "1.0.124"
1010

1111
__all__ = [
1212
'get_ad_accounts',

‎meta_ads_mcp/core/auth.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
start_callback_server,
1717
shutdown_callback_server,
1818
token_container,
19-
callback_server_port
19+
callback_server_port,
20+
get_oauth_state,
21+
new_oauth_state
2022
)
2123

2224
# Auth constants
@@ -219,12 +221,18 @@ def _save_token_to_cache(self) -> None:
219221

220222
def get_auth_url(self) -> str:
221223
"""Generate the Facebook OAuth URL for desktop app flow"""
224+
# The callback only accepts a redirect carrying this state back, so the
225+
# URL must not be built without one. start_callback_server() mints a
226+
# fresh state per flow; mint here too for callers that print the URL
227+
# without starting the server.
228+
state = get_oauth_state() or new_oauth_state()
222229
return (
223230
f"https://www.facebook.com/v24.0/dialog/oauth?"
224231
f"client_id={self.app_id}&"
225232
f"redirect_uri={self.redirect_uri}&"
226233
f"scope={AUTH_SCOPE}&"
227-
f"response_type={AUTH_RESPONSE_TYPE}"
234+
f"response_type={AUTH_RESPONSE_TYPE}&"
235+
f"state={state}"
228236
)
229237

230238
def authenticate(self, force_refresh: bool = False) -> Optional[str]:

‎meta_ads_mcp/core/callback_server.py‎

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,52 @@
2828
# Timeout in seconds before shutting down the callback server
2929
CALLBACK_SERVER_TIMEOUT = 180 # 3 minutes timeout
3030

31+
# CSRF state for the authorization request currently in flight (RFC 6749 §10.12).
32+
# The callback only accepts a redirect that echoes back the state we minted for
33+
# the flow we started, so a page that navigates the browser to /callback with an
34+
# attacker-supplied code cannot bind the attacker's Meta account to this install.
35+
_oauth_state = None
36+
_oauth_state_lock = threading.Lock()
37+
38+
# The redirect URI names localhost, so bind the loopback interface explicitly
39+
# rather than whatever "localhost" resolves to on this host.
40+
CALLBACK_SERVER_HOST = "127.0.0.1"
41+
3142
# Maximum number of characters of an OAuth error echoed back to the browser
3243
MAX_ERROR_DISPLAY_LENGTH = 200
3344

3445

46+
def new_oauth_state() -> str:
47+
"""Mint and store the state parameter for a new authorization request."""
48+
global _oauth_state
49+
with _oauth_state_lock:
50+
_oauth_state = secrets.token_urlsafe(32)
51+
return _oauth_state
52+
53+
54+
def get_oauth_state() -> Optional[str]:
55+
"""Return the state of the authorization request in flight, if any."""
56+
with _oauth_state_lock:
57+
return _oauth_state
58+
59+
60+
def consume_oauth_state(received: Optional[str]) -> bool:
61+
"""Check the state echoed by a callback and burn it, so it is single-use.
62+
63+
Returns False when no flow is in flight, when the callback carries no state,
64+
or when the values differ.
65+
"""
66+
global _oauth_state
67+
with _oauth_state_lock:
68+
expected = _oauth_state
69+
if not expected or not received:
70+
return False
71+
if not secrets.compare_digest(expected, received):
72+
return False
73+
_oauth_state = None
74+
return True
75+
76+
3577
class CallbackHandler(BaseHTTPRequestHandler):
3678
def do_GET(self):
3779
try:
@@ -92,6 +134,23 @@ def _handle_oauth_callback(self):
92134
</html>
93135
"""
94136
logger.error(f"OAuth authorization failed: {error}")
137+
elif code and not consume_oauth_state(state):
138+
# The redirect does not belong to an authorization request this
139+
# process started, so the code it carries is not ours to store.
140+
logger.warning(
141+
"OAuth callback rejected: state parameter is missing or does not "
142+
"match the authorization request in flight"
143+
)
144+
html = """
145+
<html>
146+
<head><title>Authorization Rejected</title></head>
147+
<body>
148+
<h1>Authorization Rejected</h1>
149+
<p>This response does not match the sign-in this application started.</p>
150+
<p>Close this window and start the login again from your application.</p>
151+
</body>
152+
</html>
153+
"""
95154
elif code:
96155
# Success case - we have the authorization code
97156
logger.info(f"Received authorization code: {code[:10]}...")
@@ -103,6 +162,11 @@ def _handle_oauth_callback(self):
103162
"state": state,
104163
"timestamp": time.monotonic()
105164
})
165+
166+
# The code is in hand, so stop listening. Deferred to a timer thread
167+
# because shutdown() from the serving thread would deadlock, and so
168+
# this response still reaches the browser.
169+
threading.Timer(1.0, shutdown_callback_server).start()
106170

107171
# The success page needs its own inline script, so allow just that
108172
# one script via a per-response nonce rather than loosening the CSP.
@@ -208,14 +272,15 @@ def start_callback_server() -> int:
208272
try:
209273
# Test if port is available
210274
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
211-
s.bind(('localhost', port))
275+
s.bind((CALLBACK_SERVER_HOST, port))
212276
break
213277
except OSError:
214278
port += 1
215279
else:
216280
raise Exception(f"Could not find an available port after {max_attempts} attempts")
217281

218282
callback_server_port = port
283+
new_oauth_state()
219284

220285
# Start the server in a separate thread
221286
callback_server_thread = threading.Thread(target=server_thread, daemon=True)
@@ -244,7 +309,7 @@ def server_thread():
244309
global callback_server_running, callback_server_instance
245310

246311
try:
247-
callback_server_instance = HTTPServer(('localhost', callback_server_port), CallbackHandler)
312+
callback_server_instance = HTTPServer((CALLBACK_SERVER_HOST, callback_server_port), CallbackHandler)
248313
callback_server_running = True
249314
print(f"Callback server thread started on port {callback_server_port}")
250315
callback_server_instance.serve_forever()

‎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.123"
7+
version = "1.0.124"
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.123",
5+
"version": "1.0.124",
66
"remotes": [
77
{
88
"type": "streamable-http",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Regression tests for the OAuth `state` check (GHSA-75j5-qp3x-mx37, item 3).
2+
3+
`get_auth_url` emitted no `state` and the callback validated none, so any page
4+
that could reach the callback server could hand it an authorization code of the
5+
attacker's choosing (login CSRF, RFC 6749 §10.12). The callback now only stores
6+
a code whose `state` matches the one minted for the flow this process started,
7+
and the state is single-use.
8+
"""
9+
10+
import threading
11+
from http.server import HTTPServer
12+
from urllib.parse import parse_qs, urlparse
13+
14+
import httpx
15+
import pytest
16+
17+
from meta_ads_mcp.core import callback_server
18+
from meta_ads_mcp.core.callback_server import (
19+
CALLBACK_SERVER_HOST,
20+
CallbackHandler,
21+
consume_oauth_state,
22+
get_oauth_state,
23+
new_oauth_state,
24+
token_container,
25+
)
26+
27+
28+
@pytest.fixture
29+
def callback_url():
30+
"""Run a real CallbackHandler on an ephemeral loopback port."""
31+
server = HTTPServer((CALLBACK_SERVER_HOST, 0), CallbackHandler)
32+
thread = threading.Thread(target=server.serve_forever, daemon=True)
33+
thread.start()
34+
try:
35+
yield f"http://{CALLBACK_SERVER_HOST}:{server.server_port}"
36+
finally:
37+
server.shutdown()
38+
server.server_close()
39+
thread.join(timeout=5)
40+
41+
42+
@pytest.fixture(autouse=True)
43+
def clean_auth_state():
44+
"""Keep the module-level flow state from leaking between tests."""
45+
callback_server._oauth_state = None
46+
token_container.pop("auth_code", None)
47+
token_container.pop("state", None)
48+
yield
49+
callback_server._oauth_state = None
50+
token_container.pop("auth_code", None)
51+
token_container.pop("state", None)
52+
53+
54+
def test_code_without_state_is_rejected(callback_url):
55+
new_oauth_state()
56+
57+
response = httpx.get(f"{callback_url}/callback?code=attacker-code")
58+
59+
assert response.status_code == 200
60+
assert "Authorization Rejected" in response.text
61+
assert "auth_code" not in token_container
62+
63+
64+
def test_code_with_wrong_state_is_rejected(callback_url):
65+
new_oauth_state()
66+
67+
response = httpx.get(f"{callback_url}/callback?code=attacker-code&state=guessed")
68+
69+
assert "Authorization Rejected" in response.text
70+
assert "auth_code" not in token_container
71+
72+
73+
def test_code_with_matching_state_is_accepted(callback_url):
74+
state = new_oauth_state()
75+
76+
response = httpx.get(f"{callback_url}/callback?code=real-code&state={state}")
77+
78+
assert "Authorization Successful" in response.text
79+
assert token_container["auth_code"] == "real-code"
80+
81+
82+
def test_state_is_single_use(callback_url):
83+
state = new_oauth_state()
84+
httpx.get(f"{callback_url}/callback?code=real-code&state={state}")
85+
token_container.pop("auth_code", None)
86+
87+
replay = httpx.get(f"{callback_url}/callback?code=replayed-code&state={state}")
88+
89+
assert "Authorization Rejected" in replay.text
90+
assert "auth_code" not in token_container
91+
92+
93+
def test_code_is_rejected_when_no_flow_is_in_flight(callback_url):
94+
"""Nothing minted a state, so nothing may be stored."""
95+
response = httpx.get(f"{callback_url}/callback?code=attacker-code&state=anything")
96+
97+
assert "Authorization Rejected" in response.text
98+
assert "auth_code" not in token_container
99+
100+
101+
def test_consume_rejects_empty_and_mismatched_values():
102+
state = new_oauth_state()
103+
104+
assert consume_oauth_state(None) is False
105+
assert consume_oauth_state("") is False
106+
assert consume_oauth_state(state + "x") is False
107+
assert get_oauth_state() == state, "a failed check must not burn the state"
108+
assert consume_oauth_state(state) is True
109+
assert get_oauth_state() is None
110+
111+
112+
def test_auth_url_carries_the_state_the_callback_expects():
113+
from meta_ads_mcp.core.auth import auth_manager
114+
115+
state = new_oauth_state()
116+
query = parse_qs(urlparse(auth_manager.get_auth_url()).query)
117+
118+
assert query["state"] == [state]
119+
120+
121+
def test_auth_url_mints_a_state_when_no_flow_is_in_flight():
122+
from meta_ads_mcp.core.auth import auth_manager
123+
124+
query = parse_qs(urlparse(auth_manager.get_auth_url()).query)
125+
126+
assert query["state"][0]
127+
assert query["state"][0] == get_oauth_state()
128+
129+
130+
def test_callback_server_binds_loopback_only():
131+
assert CALLBACK_SERVER_HOST == "127.0.0.1"

‎tests/test_callback_server_xss.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from meta_ads_mcp.core.callback_server import (
1717
CallbackHandler,
1818
MAX_ERROR_DISPLAY_LENGTH,
19+
new_oauth_state,
1920
)
2021

2122
XSS_PAYLOAD = "<script>alert(document.domain)</script>"
@@ -71,7 +72,8 @@ def test_error_page_sets_locked_down_security_headers(callback_url):
7172

7273

7374
def test_success_page_allows_only_its_own_nonced_script(callback_url):
74-
response = httpx.get(f"{callback_url}/callback?code=test-auth-code&state=xyz")
75+
state = new_oauth_state()
76+
response = httpx.get(f"{callback_url}/callback?code=test-auth-code&state={state}")
7577

7678
csp = response.headers["content-security-policy"]
7779
assert csp.startswith("default-src 'none'; script-src 'nonce-")
@@ -84,7 +86,8 @@ def test_success_page_allows_only_its_own_nonced_script(callback_url):
8486
def test_token_endpoint_no_longer_exposes_the_auth_code(callback_url):
8587
"""/token was unreferenced and handed the stored auth code to any
8688
same-origin page reachable via the XSS above."""
87-
httpx.get(f"{callback_url}/callback?code=test-auth-code&state=xyz")
89+
state = new_oauth_state()
90+
httpx.get(f"{callback_url}/callback?code=test-auth-code&state={state}")
8891

8992
response = httpx.get(f"{callback_url}/token")
9093

0 commit comments

Comments
 (0)