Skip to content

Commit 2c7ced2

Browse files
nictukumdrokon-dev
authored andcommitted
fix(security): write the token cache owner-only (GHSA-prmg-4fr3-mm6x) (pipeboard-co#167)
The cache holding the long-lived Meta access token was created by a bare open(path, 'w') inside a directory made by mkdir() with no mode, so under the usual 0022 umask it landed at 0644 inside 0755 — readable by every other local user on a shared host. The file is now created with os.open(..., O_CREAT, 0o600) and the directory with mode 0o700. O_CREAT's mode only applies at creation and mkdir's is masked by the umask, so both are narrowed explicitly afterwards; the same narrowing runs on load, which repairs a cache written by an earlier version without requiring re-authentication. The chmod step is skipped on Windows, where access is governed by ACLs. Bumps to 1.0.127 and documents the advisory in SECURITY.md.
1 parent 952f627 commit 2c7ced2

3 files changed

Lines changed: 203 additions & 3 deletions

File tree

‎SECURITY.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,3 +380,39 @@ directory the server was started in, which is where the documented default
380380
matching `*_<hash>.jpg` outside your image directory.
381381
- Credited to [@Gal3m](https://github.com/Gal3m) and
382382
[@mohammad228](https://github.com/mohammad228).
383+
384+
### GHSA-prmg-4fr3-mm6x — cached Meta access token was group/world-readable on disk
385+
386+
- **Severity:** Medium
387+
- **Affected versions:** `<= 1.0.126`
388+
- **Fixed in:** `1.0.127`
389+
- **Affected configurations:** Local installs that authenticate through the
390+
OAuth flow and cache a token (`~/.config/meta-ads-mcp/token_cache.json`, or
391+
`~/Library/Application Support/meta-ads-mcp/` on macOS). Matters most on
392+
shared hosts — CI runners, bastion boxes, multi-UID containers. The hosted MCP
393+
at `*.mcp.pipeboard.co` does not cache tokens to disk.
394+
395+
**What went wrong.** The cache file was created with a bare `open(path, "w")`
396+
and its directory with `mkdir()` with no mode, so under the usual `0022` umask
397+
the long-lived Meta access token (~60 days) landed in a `0644` file inside a
398+
`0755` directory. Any other local user could read it and act as the operator
399+
against the Graph API.
400+
401+
**Fix.** The file is created with `os.open(..., O_CREAT, 0o600)` and the
402+
directory with mode `0o700`. Because `O_CREAT`'s mode applies only at creation
403+
and `mkdir`'s mode is masked by the umask (and is a no-op for a directory that
404+
already exists), both are also narrowed explicitly afterwards — which repairs a
405+
cache written by an earlier version. The same narrowing runs when a cache is
406+
loaded, so an existing install stops being readable as soon as the server
407+
starts, with no need to re-authenticate. On Windows the chmod step is skipped:
408+
it cannot express "owner only" there, where access is governed by ACLs.
409+
410+
**Action for operators.**
411+
- Upgrade to `1.0.127` or later; the permissions are repaired on the next load
412+
or save.
413+
- If you ran an earlier version on a shared host, treat the cached token as
414+
exposed: rotate it at
415+
`https://developers.facebook.com/tools/debug/accesstoken/` and review Graph
416+
API access logs.
417+
- Credited to [@Gal3m](https://github.com/Gal3m) and
418+
[@mohammad228](https://github.com/mohammad228).

‎meta_ads_mcp/core/auth.py‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,30 @@ def deserialize(cls, data: Dict[str, Any]) -> 'TokenInfo':
122122
return token
123123

124124

125+
# The token cache holds a long-lived Meta access token (~60 days), so it must be
126+
# readable only by its owner. Before this, the file was created by a bare open()
127+
# and the directory by mkdir() with no mode, landing at 0644 inside 0755 under
128+
# the usual 0022 umask — readable by every local user. See GHSA-prmg-4fr3-mm6x.
129+
TOKEN_CACHE_FILE_MODE = 0o600
130+
TOKEN_CACHE_DIR_MODE = 0o700
131+
132+
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}")
147+
148+
125149
class AuthManager:
126150
"""Manages authentication with Meta APIs"""
127151
def __init__(self, app_id: str, redirect_uri: str = AUTH_REDIRECT_URI):
@@ -141,7 +165,10 @@ def _get_token_cache_path(self) -> pathlib.Path:
141165

142166
# Create directory if it doesn't exist
143167
cache_dir = base_path / "meta-ads-mcp"
144-
cache_dir.mkdir(parents=True, exist_ok=True)
168+
cache_dir.mkdir(parents=True, exist_ok=True, mode=TOKEN_CACHE_DIR_MODE)
169+
# mkdir's mode is masked by the umask and is a no-op for a directory that
170+
# already exists, so narrow it explicitly either way.
171+
_restrict_permissions(cache_dir, TOKEN_CACHE_DIR_MODE)
145172

146173
return cache_dir / "token_cache.json"
147174

@@ -152,6 +179,8 @@ def _load_cached_token(self) -> bool:
152179
if not cache_path.exists():
153180
return False
154181

182+
_restrict_permissions(cache_path, TOKEN_CACHE_FILE_MODE)
183+
155184
try:
156185
with open(cache_path, "r") as f:
157186
data = json.load(f)
@@ -213,8 +242,21 @@ def _save_token_to_cache(self) -> None:
213242
cache_path = self._get_token_cache_path()
214243

215244
try:
216-
with open(cache_path, "w") as f:
217-
json.dump(self.token_info.serialize(), f)
245+
# O_CREAT's mode only applies when the file is created, so an
246+
# existing cache from an earlier version is narrowed afterwards.
247+
fd = os.open(
248+
cache_path,
249+
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
250+
TOKEN_CACHE_FILE_MODE,
251+
)
252+
try:
253+
cache_file = os.fdopen(fd, "w")
254+
except Exception:
255+
os.close(fd)
256+
raise
257+
with cache_file:
258+
json.dump(self.token_info.serialize(), cache_file)
259+
_restrict_permissions(cache_path, TOKEN_CACHE_FILE_MODE)
218260
logger.info(f"Token cached at: {cache_path}")
219261
except Exception as e:
220262
logger.error(f"Error saving token to cache: {e}")
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Regression tests for GHSA-prmg-4fr3-mm6x.
2+
3+
The token cache holds a long-lived Meta access token (~60 days). It was written
4+
with a bare `open(path, "w")` into a directory created by `mkdir()` with no
5+
mode, so under the usual 0022 umask it landed at 0644 inside 0755 — readable by
6+
every other local user on a shared host.
7+
"""
8+
9+
import json
10+
import os
11+
import platform
12+
import stat
13+
import time
14+
15+
import pytest
16+
17+
from meta_ads_mcp.core.auth import (
18+
TOKEN_CACHE_DIR_MODE,
19+
TOKEN_CACHE_FILE_MODE,
20+
AuthManager,
21+
TokenInfo,
22+
_restrict_permissions,
23+
)
24+
25+
pytestmark = pytest.mark.skipif(
26+
platform.system() == "Windows",
27+
reason="POSIX permission bits do not apply on Windows",
28+
)
29+
30+
31+
def _mode(path) -> int:
32+
return stat.S_IMODE(os.stat(path).st_mode)
33+
34+
35+
@pytest.fixture
36+
def home(tmp_path, monkeypatch):
37+
"""Point the platform cache lookup at a scratch HOME."""
38+
monkeypatch.setenv("HOME", str(tmp_path))
39+
monkeypatch.setenv("APPDATA", str(tmp_path))
40+
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
41+
(tmp_path / "Library" / "Application Support").mkdir(parents=True, exist_ok=True)
42+
(tmp_path / ".config").mkdir(parents=True, exist_ok=True)
43+
return tmp_path
44+
45+
46+
@pytest.fixture
47+
def manager(home):
48+
auth_manager = AuthManager(app_id="test-app-id")
49+
auth_manager.token_info = TokenInfo(
50+
access_token="EAAG" + "x" * 60,
51+
expires_in=60 * 24 * 3600,
52+
user_id="123",
53+
)
54+
return auth_manager
55+
56+
57+
def test_token_cache_file_is_owner_only(manager):
58+
manager._save_token_to_cache()
59+
cache_path = manager._get_token_cache_path()
60+
61+
assert _mode(cache_path) == TOKEN_CACHE_FILE_MODE
62+
assert not _mode(cache_path) & 0o077, "group/other can read the Meta token"
63+
64+
65+
def test_token_cache_directory_is_owner_only(manager):
66+
cache_dir = manager._get_token_cache_path().parent
67+
68+
assert _mode(cache_dir) == TOKEN_CACHE_DIR_MODE
69+
assert not _mode(cache_dir) & 0o077
70+
71+
72+
def test_permissions_hold_under_a_permissive_umask(manager):
73+
"""0644 came from the umask, so the umask is what the fix must survive."""
74+
previous = os.umask(0o000)
75+
try:
76+
manager._save_token_to_cache()
77+
finally:
78+
os.umask(previous)
79+
80+
assert _mode(manager._get_token_cache_path()) == TOKEN_CACHE_FILE_MODE
81+
82+
83+
def test_existing_world_readable_cache_is_narrowed_on_save(manager):
84+
cache_path = manager._get_token_cache_path()
85+
cache_path.write_text("{}")
86+
os.chmod(cache_path, 0o644)
87+
88+
manager._save_token_to_cache()
89+
90+
assert _mode(cache_path) == TOKEN_CACHE_FILE_MODE
91+
92+
93+
def test_existing_world_readable_cache_is_narrowed_on_load(manager, home):
94+
"""A cache written by an older version is repaired without re-authenticating."""
95+
cache_path = manager._get_token_cache_path()
96+
cache_path.write_text(
97+
json.dumps(
98+
{
99+
"access_token": "EAAG" + "y" * 60,
100+
"expires_in": 60 * 24 * 3600,
101+
"created_at": int(time.time()),
102+
"user_id": "123",
103+
}
104+
)
105+
)
106+
os.chmod(cache_path, 0o644)
107+
108+
assert AuthManager(app_id="test-app-id")._load_cached_token() is True
109+
assert _mode(cache_path) == TOKEN_CACHE_FILE_MODE
110+
111+
112+
def test_saved_token_is_still_readable_by_its_owner(manager):
113+
manager._save_token_to_cache()
114+
115+
data = json.loads(manager._get_token_cache_path().read_text())
116+
117+
assert data["access_token"] == manager.token_info.access_token
118+
119+
120+
def test_restrict_permissions_survives_a_missing_file(tmp_path):
121+
"""Best effort: a vanished path must not raise into the caller."""
122+
_restrict_permissions(tmp_path / "gone.json", TOKEN_CACHE_FILE_MODE)

0 commit comments

Comments
 (0)