Skip to content

Commit 55f3278

Browse files
committed
fix(security): stop writing access tokens to the debug log (GHSA-r3r9-3mrh-x966)
setup_logging called logging.basicConfig(level=DEBUG, filename=...), which installs a handler on the ROOT logger, so third-party records were captured too — including httpx's INFO line, which prints the full request URL. The Graph API takes access_token as a query parameter, so every API call wrote the operator's token in full into a 0644 file that is never rotated. The package's own request logging masked it; httpx's did not. The handler now belongs to this package's logger with propagate=False, httpx and httpcore are held at WARNING so the line is not emitted into host-configured logging either, the default level is INFO (META_ADS_LOG_LEVEL=DEBUG restores it), and the log file is 0600 inside a 0700 directory, narrowed on startup if an earlier version left them wider. Credential prefixes (token[:10]) in log lines and in the get_login_link response are replaced by redact_secret(), which reports the length only. The pipeboard_auth.py query-string half of the report no longer applies: that module was removed in 1.0.121 and no credential is placed in a URL by this package today. Bumps to 1.0.128 and documents the advisory in SECURITY.md, including deleting existing logs and rotating the token.
1 parent d5eacee commit 55f3278

10 files changed

Lines changed: 310 additions & 37 deletions

‎SECURITY.md‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,3 +416,55 @@ it cannot express "owner only" there, where access is governed by ACLs.
416416
API access logs.
417417
- Credited to [@Gal3m](https://github.com/Gal3m) and
418418
[@mohammad228](https://github.com/mohammad228).
419+
420+
### GHSA-r3r9-3mrh-x966 — access token written to the debug log via third-party request logging
421+
422+
- **Severity:** Medium
423+
- **Affected versions:** `<= 1.0.127`
424+
- **Fixed in:** `1.0.128`
425+
- **Affected configurations:** Any install that makes Graph API calls. The log
426+
file lives at `~/.config/meta-ads-mcp/meta_ads_debug.log`
427+
(`~/Library/Application Support/meta-ads-mcp/` on macOS) and is never rotated,
428+
so records accumulate indefinitely. It matters most where logs are shipped
429+
somewhere else — container stdout collection, a log aggregator, a SIEM — or on
430+
a shared host.
431+
432+
**What the report described, and what was still live.** The advisory cites
433+
`pipeboard_auth.py`, which put `PIPEBOARD_API_TOKEN` in a URL query string and
434+
logged the full URL. That module was removed in `1.0.121`, and no credential is
435+
placed in a query string by this package today, so that part no longer applies.
436+
437+
The logging half did still apply, through a different route. `setup_logging()`
438+
called `logging.basicConfig(level=DEBUG, filename=...)`, which installs a handler
439+
on the **root** logger — so every third-party record was captured too, including
440+
httpx's INFO line:
441+
442+
```
443+
httpx - INFO - HTTP Request: GET https://graph.facebook.com/v24.0/me/adaccounts?access_token=<the operator's token> "HTTP/1.1 200 OK"
444+
```
445+
446+
The package masks the token in its own request logging, but the Graph API takes
447+
`access_token` as a query parameter, so httpx logged it in full on every call.
448+
The file was created at `0644`.
449+
450+
**Fix.**
451+
1. The file handler is attached to this package's logger with `propagate = False`
452+
instead of the root logger, so third-party records are no longer captured.
453+
2. `httpx` and `httpcore` are held at `WARNING`, so the URL line is not emitted
454+
into logging the host application configures either.
455+
3. The default level is `INFO` rather than `DEBUG`; set `META_ADS_LOG_LEVEL=DEBUG`
456+
to restore the old verbosity.
457+
4. The log file is created `0600` inside a `0700` directory, and both are
458+
narrowed on startup if an earlier version left them wider.
459+
5. Credential prefixes (`token[:10]`) in log lines and in the `get_login_link`
460+
response are replaced by `redact_secret()`, which reports the length only. Ten
461+
characters is still credential material in a file that is never rotated, and
462+
enough to correlate one caller's requests on a shared deployment.
463+
464+
**Action for operators.**
465+
- Upgrade to `1.0.128` or later.
466+
- Existing `meta_ads_debug.log` files contain access tokens in full. Delete them,
467+
and if the logs were shipped anywhere or the host is shared, rotate the token
468+
at `https://developers.facebook.com/tools/debug/accesstoken/`.
469+
- Credited to [@Gal3m](https://github.com/Gal3m) and
470+
[@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.127"
9+
__version__ = "1.0.128"
1010

1111
__all__ = [
1212
'get_ad_accounts',

‎meta_ads_mcp/core/auth.py‎

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import webbrowser
99
import asyncio
1010
import json
11-
from .utils import logger
11+
from .utils import logger, redact_secret, restrict_permissions
1212
import requests
1313

1414
# Import from the new callback server module
@@ -130,20 +130,9 @@ def deserialize(cls, data: Dict[str, Any]) -> 'TokenInfo':
130130
TOKEN_CACHE_DIR_MODE = 0o700
131131

132132

133-
def _restrict_permissions(path: pathlib.Path, mode: int) -> None:
134-
"""Narrow `path` to `mode` if it is wider, best effort.
135-
136-
Also repairs caches written by earlier versions, so an operator does not
137-
have to re-authenticate to get a private file. POSIX only: on Windows chmod
138-
cannot express "owner only", and access is governed by ACLs instead.
139-
"""
140-
if platform.system() == "Windows":
141-
return
142-
try:
143-
if (path.stat().st_mode & 0o777) != mode:
144-
os.chmod(path, mode)
145-
except OSError as e:
146-
logger.warning(f"Could not restrict permissions on {path}: {e}")
133+
# Shared with the log file in utils; repairs caches written by earlier versions
134+
# so an operator does not have to re-authenticate to get a private file.
135+
_restrict_permissions = restrict_permissions
147136

148137

149138
class AuthManager:
@@ -329,7 +318,7 @@ def get_access_token(self) -> Optional[str]:
329318
def invalidate_token(self) -> None:
330319
"""Invalidate the current token, usually because it has expired or is invalid"""
331320
if self.token_info:
332-
logger.info(f"Invalidating token: {self.token_info.access_token[:10]}...")
321+
logger.info(f"Invalidating token: {redact_secret(self.token_info.access_token)}")
333322
self.token_info = None
334323

335324
# Signal that authentication is needed
@@ -506,7 +495,7 @@ async def get_current_access_token() -> Optional[str]:
506495
auth_manager.invalidate_token()
507496
return None
508497

509-
logger.debug(f"Access token found in auth_manager (starts with: {token[:10]}...)")
498+
logger.debug(f"Access token found in auth_manager ({redact_secret(token)})")
510499
return token
511500
else:
512501
logger.warning("No valid access token available in auth_manager")

‎meta_ads_mcp/core/authentication.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from . import auth
3737
from .auth import start_callback_server, shutdown_callback_server, auth_manager
3838
from .server import mcp_server
39-
from .utils import logger, META_APP_SECRET
39+
from .utils import logger, redact_secret, META_APP_SECRET
4040

4141
# Only register the login link tool if not explicitly disabled
4242
ENABLE_LOGIN_LINK = not bool(os.environ.get("META_ADS_DISABLE_LOGIN_LINK", ""))
@@ -92,7 +92,7 @@ async def get_login_link(access_token: Optional[str] = None) -> str:
9292
return json.dumps({
9393
"message": "✅ Already Authenticated",
9494
"status": "You're successfully authenticated with Meta Ads!",
95-
"token_info": f"Token preview: {cached_token[:10]}...",
95+
"token_info": f"Token: {redact_secret(cached_token)}",
9696
"created_at": auth_manager.token_info.created_at if hasattr(auth_manager, "token_info") else None,
9797
"expires_in": auth_manager.token_info.expires_in if hasattr(auth_manager, "token_info") else None,
9898
"authentication_method": "meta_oauth",

‎meta_ads_mcp/core/callback_server.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from urllib.parse import urlparse, parse_qs
1313
from typing import Dict, Any, Optional
1414

15-
from .utils import logger
15+
from .utils import logger, redact_secret
1616

1717
# Global token container for communication between threads
1818
token_container = {"token": None, "expires_in": None, "user_id": None}
@@ -153,7 +153,7 @@ def _handle_oauth_callback(self):
153153
"""
154154
elif code:
155155
# Success case - we have the authorization code
156-
logger.info(f"Received authorization code: {code[:10]}...")
156+
logger.info(f"Received authorization code: {redact_secret(code)}")
157157

158158
# Store the authorization code temporarily
159159
# The auth module will exchange this for an access token

‎meta_ads_mcp/core/http_auth_integration.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import asyncio
99
import contextvars
1010
from typing import Optional
11-
from .utils import logger
11+
from .utils import logger, redact_secret
1212
import json
1313

1414
# Use context variables instead of thread-local storage for better async support
@@ -296,12 +296,12 @@ async def dispatch(self, request: Request, call_next):
296296
)
297297

298298
if auth_token:
299-
logger.debug(f"HTTP Auth Middleware: Extracted auth token: {auth_token[:10]}...")
299+
logger.debug(f"HTTP Auth Middleware: Extracted auth token: {redact_secret(auth_token)}")
300300
logger.debug("Injecting auth token into request context")
301301
FastMCPAuthIntegration.set_auth_token(auth_token)
302302

303303
if pipeboard_token:
304-
logger.debug(f"HTTP Auth Middleware: Extracted Pipeboard token: {pipeboard_token[:10]}...")
304+
logger.debug(f"HTTP Auth Middleware: Extracted Pipeboard token: {redact_secret(pipeboard_token)}")
305305
logger.debug("Injecting Pipeboard token into request context")
306306
FastMCPAuthIntegration.set_pipeboard_token(pipeboard_token)
307307

‎meta_ads_mcp/core/utils.py‎

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,61 @@
3636
print("WARNING: META_APP_SECRET environment variable is not set.", file=sys.stderr)
3737
print("NOTE: This is only needed to exchange a short-lived token for a long-lived one.", file=sys.stderr)
3838

39+
# The log file sits next to the token cache and records request activity, so it
40+
# gets the same owner-only treatment. See GHSA-prmg-4fr3-mm6x for the cache.
41+
LOG_DIR_MODE = 0o700
42+
LOG_FILE_MODE = 0o600
43+
44+
# Log level default. DEBUG used to be hard-coded, which (together with the root
45+
# handler below) wrote a great deal of request detail to a file that is never
46+
# rotated. Operators who need it set META_ADS_LOG_LEVEL=DEBUG.
47+
DEFAULT_LOG_LEVEL = "INFO"
48+
49+
# httpx logs "HTTP Request: GET <url>" at INFO, and for the Graph API that URL
50+
# carries `access_token=<the operator's token>` in the query string. The old
51+
# logging.basicConfig() call installed a handler on the ROOT logger, so those
52+
# records landed in meta_ads_debug.log in full. The handler is now attached to
53+
# this package's logger only, and httpx is held at WARNING so the line is not
54+
# emitted into whatever logging the host application configures either.
55+
# See GHSA-r3r9-3mrh-x966.
56+
_URL_LOGGING_LIBRARIES = ("httpx", "httpcore")
57+
58+
59+
def restrict_permissions(path: pathlib.Path, mode: int) -> None:
60+
"""Narrow `path` to `mode` if it is wider, best effort.
61+
62+
POSIX only: on Windows chmod cannot express "owner only", and access is
63+
governed by ACLs instead.
64+
"""
65+
if platform.system() == "Windows":
66+
return
67+
try:
68+
if (path.stat().st_mode & 0o777) != mode:
69+
os.chmod(path, mode)
70+
except OSError:
71+
# Logging is not configured yet when this runs at import time, and a
72+
# permissions failure must never stop the server from starting.
73+
pass
74+
75+
76+
def redact_secret(value: Optional[str]) -> str:
77+
"""Render a credential for a log line or a tool response.
78+
79+
Returns the length only. Prefixes (`token[:10]`) were used before, which is
80+
still credential material in a file that is never rotated, and long enough
81+
to correlate one caller's requests across a shared deployment.
82+
"""
83+
if not value:
84+
return "<none>"
85+
return f"<redacted:{len(value)} chars>"
86+
87+
88+
def _resolve_log_level() -> int:
89+
requested = os.environ.get("META_ADS_LOG_LEVEL", DEFAULT_LOG_LEVEL).upper()
90+
level = logging.getLevelName(requested)
91+
return level if isinstance(level, int) else logging.INFO
92+
93+
3994
# Configure logging to file
4095
def setup_logging():
4196
"""Set up logging to file for troubleshooting."""
@@ -49,21 +104,36 @@ def setup_logging():
49104

50105
# Create directory if it doesn't exist
51106
log_dir = base_path / "meta-ads-mcp"
52-
log_dir.mkdir(parents=True, exist_ok=True)
107+
log_dir.mkdir(parents=True, exist_ok=True, mode=LOG_DIR_MODE)
108+
restrict_permissions(log_dir, LOG_DIR_MODE)
53109

54110
log_file = log_dir / "meta_ads_debug.log"
55-
56-
# Configure file logger
57-
logging.basicConfig(
58-
level=logging.DEBUG,
59-
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
60-
filename=str(log_file),
61-
filemode='a' # Append mode
62-
)
111+
# Create the file ourselves so it exists with 0600 before the handler opens
112+
# it; narrow it afterwards in case an earlier version left it at 0644.
113+
if not log_file.exists():
114+
try:
115+
os.close(os.open(log_file, os.O_CREAT | os.O_WRONLY | os.O_APPEND, LOG_FILE_MODE))
116+
except OSError:
117+
pass
118+
restrict_permissions(log_file, LOG_FILE_MODE)
63119

64120
# Create a logger
65121
logger = logging.getLogger("meta-ads-mcp")
66-
logger.setLevel(logging.DEBUG)
122+
logger.setLevel(_resolve_log_level())
123+
124+
# Attach the file handler to this logger rather than the root logger, so
125+
# third-party records (notably httpx's full request URLs) are not captured.
126+
if not any(getattr(handler, "_meta_ads_mcp_handler", False) for handler in logger.handlers):
127+
file_handler = logging.FileHandler(str(log_file))
128+
file_handler._meta_ads_mcp_handler = True
129+
file_handler.setFormatter(
130+
logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
131+
)
132+
logger.addHandler(file_handler)
133+
logger.propagate = False
134+
135+
for library in _URL_LOGGING_LIBRARIES:
136+
logging.getLogger(library).setLevel(logging.WARNING)
67137

68138
# Log startup information
69139
logger.info(f"Logging initialized. Log file: {log_file}")

‎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.127"
7+
version = "1.0.128"
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.127",
5+
"version": "1.0.128",
66
"remotes": [
77
{
88
"type": "streamable-http",

0 commit comments

Comments
 (0)