Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MEDIA_TAG_CLEANUP_RE,
BasePlatformAdapter,
SendResult,
is_network_accessible,
validate_media_delivery_path,
)
from agent.redact import redact_sensitive_text

Expand Down Expand Up @@ -581,9 +583,6 @@ async def cors_middleware(request, handler):
".webp": "image/webp",
".bmp": "image/bmp",
}
_MEDIA_TAG_RE = re.compile(
r"[`\"']?MEDIA:\s*(`[^`\n]+`|\"[^\"\n]+\"|'[^'\n]+'|\S+)[`\"']?"
)
_MEDIA_DATA_URL_MAX_BYTES = 5 * 1024 * 1024 # skip images larger than 5MB


Expand All @@ -594,29 +593,46 @@ def _resolve_media_to_data_urls(text: str) -> str:
``MEDIA:`` tags referencing images on the server are useless to them.
Inline small local images as markdown data URLs; non-image or unreadable
paths are left untouched.

Uses the same anchored ``MEDIA_TAG_CLEANUP_RE`` matcher and
``validate_media_delivery_path`` safety check every other platform
adapter's media delivery already goes through (gateway/platforms/base.py)
— an absolute-path anchor plus a known-extension requirement, and a
resolved-path check against the credential/system-path denylist. The
prior pattern here matched any bare token after ``MEDIA:`` (including a
relative/traversal path like ``../../etc/passwd.png``) and read the file
directly with no denylist, so any image-suffixed, readable file the
process could see was base64-exfiltrated to the API caller if its path
merely appeared in the model's own final reply text.
"""
if not text or "MEDIA:" not in text:
return text
import base64

def _to_data_url(path_str: str) -> Optional[str]:
p = Path(path_str.strip().strip("`\"'")).expanduser()
# validate_media_delivery_path() strips wrapping quotes/backticks
# and trailing punctuation internally, same as MEDIA_TAG_CLEANUP_RE's
# other callers (extract_media / _strip_media_tag_directives) rely on.
safe_path = validate_media_delivery_path(path_str)
if not safe_path:
return None
p = Path(safe_path)
suffix = p.suffix.lower()
if suffix not in _MEDIA_IMG_EXT:
return None
try:
if not p.is_file() or p.stat().st_size > _MEDIA_DATA_URL_MAX_BYTES:
if p.stat().st_size > _MEDIA_DATA_URL_MAX_BYTES:
return None
b64 = base64.b64encode(p.read_bytes()).decode()
except OSError:
return None
return f"![image](data:{_MEDIA_MIME[suffix]};base64,{b64})"

def _repl(m: "re.Match[str]") -> str:
return _to_data_url(m.group(1)) or m.group(0)
return _to_data_url(m.group("path")) or m.group(0)

try:
return _MEDIA_TAG_RE.sub(_repl, text)
return MEDIA_TAG_CLEANUP_RE.sub(_repl, text)
except Exception:
return text

Expand Down
34 changes: 34 additions & 0 deletions tests/gateway/test_api_server_media_data_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,40 @@ def test_multiple_tags(self):
out = _resolve_media_to_data_urls(f"MEDIA:{p1}\nand MEDIA:{p2}")
self.assertEqual(out.count("data:image/png;base64,"), 2)

def test_relative_traversal_path_not_inlined(self):
"""A relative/traversal path must never be inlined — the anchored
MEDIA_TAG_CLEANUP_RE matcher requires an absolute-path prefix
(~/, /, or a Windows drive letter), so a bare relative token after
MEDIA: is left as literal text rather than resolved against cwd."""
text = "MEDIA:../../../../etc/passwd.png"
self.assertEqual(_resolve_media_to_data_urls(text), text)

def test_credential_path_not_inlined_even_with_image_extension(self):
"""An absolute path under the credential/system-path denylist
(validate_media_delivery_path) must not be inlined even though it
has an allowed image extension and the tag matcher's shape."""
text = "MEDIA:~/.ssh/id_rsa.png"
self.assertEqual(_resolve_media_to_data_urls(text), text)

def test_symlink_escaping_to_denylisted_target_not_inlined(self):
"""A symlink whose resolved target lands under a denylisted system
prefix (/etc) must not be inlined — validate_media_delivery_path
resolves symlinks before the containment/denylist check runs, so
the traversal can't be laundered through an innocuous-looking
image-suffixed symlink name."""
import os
import tempfile
from pathlib import Path

d = Path(tempfile.mkdtemp(prefix="hermes_media_test_symlink"))
link = d / "shot.png"
try:
os.symlink("/etc/hosts", link)
except OSError:
self.skipTest("symlink creation not supported in this environment")
text = f"MEDIA:{link}"
self.assertEqual(_resolve_media_to_data_urls(text), text)


if __name__ == "__main__":
unittest.main()
Loading