Skip to content

Commit 8f8eda6

Browse files
authored
fix(security): confine save_ad_image_locally to an allowed base directory (GHSA-2h5x-4qc8-3x27) (#166)
ad_id and output_dir are caller-supplied tool arguments that were joined into a destination path unchecked. os.path.join returns an absolute second argument unchanged and does not neutralize '..', and os.makedirs built whatever tree was needed, so output_dir=/etc/cron.d or ad_id=../../../../tmp/evil wrote the downloaded image outside the intended directory with the server process's privileges. resolve_ad_image_save_path now decides the destination before anything touches disk: ad_id must be digits, the image hash must be filename-safe, and output_dir is resolved with realpath and must land on or under the base directory (the server's working directory, or META_ADS_IMAGE_OUTPUT_DIR). Absolute paths, traversal and symlinks out of the base are rejected. The tool only registers when META_ADS_ENABLE_SAVE_AD_IMAGE_LOCALLY is set; the hosted deployment does not set it. Bumps to 1.0.126 and documents the advisory in SECURITY.md.
1 parent d136b4c commit 8f8eda6

6 files changed

Lines changed: 220 additions & 12 deletions

File tree

‎SECURITY.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,3 +338,45 @@ IPv6 addresses are still unwrapped first, so `::ffff:100.64.1.1` is rejected too
338338
review outbound request logs for fetches into that range.
339339
- Credited to [@Gal3m](https://github.com/Gal3m) and
340340
[@mohammad228](https://github.com/mohammad228).
341+
342+
### GHSA-2h5x-4qc8-3x27 — `save_ad_image_locally` wrote to a caller-chosen path
343+
344+
- **Severity:** Medium
345+
- **Affected versions:** `<= 1.0.125`, and only when the tool is enabled.
346+
- **Fixed in:** `1.0.126`
347+
- **Affected configurations:** Installs that set
348+
`META_ADS_ENABLE_SAVE_AD_IMAGE_LOCALLY` — the tool is not registered without
349+
it. The hosted MCP at `*.mcp.pipeboard.co` does not set it, so the tool has
350+
never been exposed there.
351+
352+
**What went wrong.** `save_ad_image_locally` built its destination from two
353+
caller-supplied tool arguments with no validation:
354+
355+
```python
356+
filename = f"{ad_id}_{image_hashes[0]}.jpg"
357+
filepath = os.path.join(output_dir, filename)
358+
os.makedirs(output_dir) # built whatever tree was needed
359+
```
360+
361+
`os.path.join` returns an absolute second argument unchanged and does not
362+
neutralize `..`, so `output_dir="/etc/cron.d"`, `ad_id="/tmp/PWNED"` or
363+
`ad_id="../../../../tmp/evil"` all wrote the downloaded image outside the
364+
intended `ad_images` directory, with the server process's privileges, creating
365+
directories along the way. The bytes are an ad image, which a caller who
366+
controls the creative can influence.
367+
368+
**Fix.** A new `resolve_ad_image_save_path()` decides the destination before
369+
anything touches the disk: `ad_id` must be a Meta object id (digits), the image
370+
hash must be filename-safe, and `output_dir` is resolved with `os.path.realpath`
371+
and must land on or under an allowed base directory — so absolute paths, `..`
372+
and symlinks pointing out of the base are all rejected. The base is the working
373+
directory the server was started in, which is where the documented default
374+
(`ad_images`) has always resolved; operators who want images elsewhere set
375+
`META_ADS_IMAGE_OUTPUT_DIR`. Directories are only created after the path passes.
376+
377+
**Action for operators.**
378+
- Upgrade to `1.0.126` or later.
379+
- If you enabled the tool on an earlier version, check for unexpected files
380+
matching `*_<hash>.jpg` outside your image directory.
381+
- Credited to [@Gal3m](https://github.com/Gal3m) and
382+
[@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.125"
9+
__version__ = "1.0.126"
1010

1111
__all__ = [
1212
'get_ad_accounts',

‎meta_ads_mcp/core/ads.py‎

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from PIL import Image as PILImage
99
from mcp.server.fastmcp import Image
1010
import os
11+
import re
1112
import time
1213

1314
logger = logging.getLogger(__name__)
@@ -441,6 +442,58 @@ def _translate_video_customization_rules_for_existing_post(
441442
ENABLE_SAVE_AD_IMAGE_LOCALLY = bool(os.environ.get("META_ADS_ENABLE_SAVE_AD_IMAGE_LOCALLY", ""))
442443

443444

445+
class UnsafeSavePathError(Exception):
446+
"""Raised when a requested save path escapes the allowed base directory."""
447+
448+
449+
# `ad_id` and `output_dir` are caller-supplied tool arguments that used to be
450+
# joined into a destination path unchecked. os.path.join() returns an absolute
451+
# second argument unchanged and does not neutralize "..", so a caller could write
452+
# the downloaded image anywhere the process can reach — and os.makedirs() would
453+
# build the directory tree to get there. See GHSA-2h5x-4qc8-3x27.
454+
_AD_ID_PATTERN = re.compile(r"^[0-9]+$")
455+
_IMAGE_HASH_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
456+
457+
458+
def _save_image_base_dir() -> str:
459+
"""Directory that saved ad images may never escape.
460+
461+
Defaults to the working directory the server was started in, which is where
462+
the documented default (`ad_images`) has always resolved. Operators who want
463+
images elsewhere set META_ADS_IMAGE_OUTPUT_DIR.
464+
"""
465+
return os.path.realpath(os.environ.get("META_ADS_IMAGE_OUTPUT_DIR") or os.getcwd())
466+
467+
468+
def resolve_ad_image_save_path(ad_id: str, image_hash: str, output_dir: str) -> str:
469+
"""Build the destination path for a saved ad image, confined to the base dir.
470+
471+
Raises UnsafeSavePathError if `ad_id` is not a Meta object id, if the image
472+
hash is not filename-safe, or if `output_dir` resolves outside the base
473+
directory (absolute path, "..", or a symlink pointing out of it).
474+
"""
475+
if not _AD_ID_PATTERN.match(ad_id or ""):
476+
raise UnsafeSavePathError(
477+
f"ad_id '{ad_id}' is not a Meta ad id; expected digits only"
478+
)
479+
if not _IMAGE_HASH_PATTERN.match(image_hash or ""):
480+
raise UnsafeSavePathError(f"image hash '{image_hash}' is not a safe filename component")
481+
482+
base = _save_image_base_dir()
483+
# An absolute output_dir makes join() discard the base, so realpath is what
484+
# decides: the result must be the base itself or sit underneath it.
485+
target_dir = os.path.realpath(os.path.join(base, output_dir or ""))
486+
if target_dir != base and not target_dir.startswith(base + os.sep):
487+
raise UnsafeSavePathError(
488+
f"output_dir '{output_dir}' resolves outside the allowed directory "
489+
f"({base}). Pass a path inside it, or set META_ADS_IMAGE_OUTPUT_DIR "
490+
"to move the allowed directory."
491+
)
492+
493+
filename = os.path.basename(f"{ad_id}_{image_hash}.jpg")
494+
return os.path.join(target_dir, filename)
495+
496+
444497
@mcp_server.tool()
445498
@meta_api_tool
446499
async def get_ads(account_id: str, access_token: Optional[str] = None, limit: int = 10,
@@ -1203,16 +1256,15 @@ async def save_ad_image_locally(ad_id: str, access_token: Optional[str] = None,
12031256
if not image_bytes:
12041257
return json.dumps({"error": "Failed to download image"}, indent=2)
12051258

1259+
# Resolve and confine the destination before anything is created on disk.
12061260
try:
1207-
# Ensure output directory exists
1208-
if not os.path.exists(output_dir):
1209-
os.makedirs(output_dir)
1210-
1211-
# Create a filename (e.g., using ad_id and image hash)
1212-
file_extension = ".jpg" # Default extension, could try to infer from headers later
1213-
filename = f"{ad_id}_{image_hashes[0]}{file_extension}"
1214-
filepath = os.path.join(output_dir, filename)
1215-
1261+
filepath = resolve_ad_image_save_path(ad_id, image_hashes[0], output_dir)
1262+
except UnsafeSavePathError as e:
1263+
return json.dumps({"error": str(e)}, indent=2)
1264+
1265+
try:
1266+
os.makedirs(os.path.dirname(filepath), exist_ok=True)
1267+
12161268
# Save the image bytes to the file
12171269
with open(filepath, "wb") as f:
12181270
f.write(image_bytes)

‎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.125"
7+
version = "1.0.126"
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.125",
5+
"version": "1.0.126",
66
"remotes": [
77
{
88
"type": "streamable-http",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Regression tests for GHSA-2h5x-4qc8-3x27.
2+
3+
`save_ad_image_locally` built its destination from two caller-supplied tool
4+
arguments with no validation: `os.path.join(output_dir, f"{ad_id}_{hash}.jpg")`.
5+
join() returns an absolute second argument unchanged and does not neutralize
6+
"..", and `os.makedirs(output_dir)` would build whatever tree was needed, so a
7+
caller could drop the downloaded image anywhere the process could write.
8+
9+
`resolve_ad_image_save_path` now decides the path, and it is called before
10+
anything touches the disk.
11+
"""
12+
13+
import os
14+
15+
import pytest
16+
17+
from meta_ads_mcp.core.ads import UnsafeSavePathError, resolve_ad_image_save_path
18+
19+
AD_ID = "120250589548040262"
20+
IMAGE_HASH = "60dff700e54f59a38ec6e626c7b2f3f5"
21+
22+
23+
@pytest.fixture
24+
def base_dir(tmp_path, monkeypatch):
25+
"""Point the allowed base at a scratch directory."""
26+
base = tmp_path / "workdir"
27+
base.mkdir()
28+
monkeypatch.setenv("META_ADS_IMAGE_OUTPUT_DIR", str(base))
29+
return base
30+
31+
32+
def test_default_output_dir_lands_under_the_base(base_dir):
33+
path = resolve_ad_image_save_path(AD_ID, IMAGE_HASH, "ad_images")
34+
35+
assert path == str(base_dir / "ad_images" / f"{AD_ID}_{IMAGE_HASH}.jpg")
36+
37+
38+
def test_nested_subdirectories_are_allowed(base_dir):
39+
path = resolve_ad_image_save_path(AD_ID, IMAGE_HASH, "creatives/2026/q3")
40+
41+
assert path.startswith(str(base_dir) + os.sep)
42+
43+
44+
def test_empty_output_dir_writes_into_the_base(base_dir):
45+
path = resolve_ad_image_save_path(AD_ID, IMAGE_HASH, "")
46+
47+
assert path == str(base_dir / f"{AD_ID}_{IMAGE_HASH}.jpg")
48+
49+
50+
@pytest.mark.parametrize(
51+
"output_dir",
52+
[
53+
"/etc/cron.d", # absolute path discards the base in join()
54+
"/tmp",
55+
"../escaped", # traversal
56+
"../../../../tmp/evil",
57+
"ad_images/../../outside", # traversal after a legitimate-looking prefix
58+
],
59+
)
60+
def test_output_dir_escapes_are_rejected(base_dir, output_dir):
61+
with pytest.raises(UnsafeSavePathError):
62+
resolve_ad_image_save_path(AD_ID, IMAGE_HASH, output_dir)
63+
64+
65+
@pytest.mark.parametrize(
66+
"ad_id",
67+
[
68+
"/tmp/PWNED", # absolute id discards the directory
69+
"../../../../tmp/evil", # traversal through the filename
70+
"123/../../etc/passwd",
71+
"not-an-id",
72+
"",
73+
"12 3",
74+
],
75+
)
76+
def test_non_numeric_ad_ids_are_rejected(base_dir, ad_id):
77+
with pytest.raises(UnsafeSavePathError):
78+
resolve_ad_image_save_path(ad_id, IMAGE_HASH, "ad_images")
79+
80+
81+
@pytest.mark.parametrize("image_hash", ["../../evil", "a/b", "", "hash with spaces"])
82+
def test_unsafe_image_hashes_are_rejected(base_dir, image_hash):
83+
with pytest.raises(UnsafeSavePathError):
84+
resolve_ad_image_save_path(AD_ID, image_hash, "ad_images")
85+
86+
87+
def test_symlink_out_of_the_base_is_rejected(base_dir, tmp_path):
88+
"""realpath resolution is what catches this, not string matching."""
89+
outside = tmp_path / "outside"
90+
outside.mkdir()
91+
(base_dir / "escape").symlink_to(outside)
92+
93+
with pytest.raises(UnsafeSavePathError):
94+
resolve_ad_image_save_path(AD_ID, IMAGE_HASH, "escape")
95+
96+
97+
def test_rejection_creates_nothing_on_disk(base_dir):
98+
"""The path is resolved before any makedirs, so a rejected call is inert."""
99+
before = sorted(os.listdir(base_dir))
100+
101+
with pytest.raises(UnsafeSavePathError):
102+
resolve_ad_image_save_path(AD_ID, IMAGE_HASH, "../../../../tmp/evil-tree")
103+
104+
assert sorted(os.listdir(base_dir)) == before
105+
assert not os.path.exists("/tmp/evil-tree")
106+
107+
108+
def test_base_defaults_to_the_working_directory(tmp_path, monkeypatch):
109+
monkeypatch.delenv("META_ADS_IMAGE_OUTPUT_DIR", raising=False)
110+
monkeypatch.chdir(tmp_path)
111+
112+
path = resolve_ad_image_save_path(AD_ID, IMAGE_HASH, "ad_images")
113+
114+
assert path == os.path.join(os.path.realpath(tmp_path), "ad_images", f"{AD_ID}_{IMAGE_HASH}.jpg")

0 commit comments

Comments
 (0)