Skip to content
Merged
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
60 changes: 58 additions & 2 deletions environments/benchmarks/terminalbench_2/terminalbench2_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
import time
import uuid
from collections import defaultdict
from pathlib import Path
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any, Dict, List, Optional, Tuple, Union

# Ensure repo root is on sys.path for imports
Expand Down Expand Up @@ -148,14 +148,70 @@ class TerminalBench2EvalConfig(HermesAgentEnvConfig):
# Tar extraction helper
# =============================================================================

def _normalize_tar_member_parts(member_name: str) -> list:
"""Return safe path components for a tar member or raise ValueError."""
normalized_name = member_name.replace("\\", "/")
posix_path = PurePosixPath(normalized_name)
windows_path = PureWindowsPath(member_name)

if (
not normalized_name
or posix_path.is_absolute()
or windows_path.is_absolute()
or windows_path.drive
):
raise ValueError(f"Unsafe archive member path: {member_name}")

parts = [part for part in posix_path.parts if part not in ("", ".")]
if not parts or any(part == ".." for part in parts):
raise ValueError(f"Unsafe archive member path: {member_name}")
return parts


def _safe_extract_tar(tar: tarfile.TarFile, target_dir: Path) -> None:
"""Extract a tar archive without allowing traversal or link entries."""
target_dir.mkdir(parents=True, exist_ok=True)
target_root = target_dir.resolve()

for member in tar.getmembers():
parts = _normalize_tar_member_parts(member.name)
target = target_dir.joinpath(*parts)
target_real = target.resolve(strict=False)

try:
target_real.relative_to(target_root)
except ValueError as exc:
raise ValueError(f"Unsafe archive member path: {member.name}") from exc

if member.isdir():
target_real.mkdir(parents=True, exist_ok=True)
continue

if not member.isfile():
raise ValueError(f"Unsupported archive member type: {member.name}")

target_real.parent.mkdir(parents=True, exist_ok=True)
extracted = tar.extractfile(member)
if extracted is None:
raise ValueError(f"Cannot read archive member: {member.name}")

with extracted, open(target_real, "wb") as dst:
shutil.copyfileobj(extracted, dst)

try:
os.chmod(target_real, member.mode & 0o777)
except OSError:
pass


def _extract_base64_tar(b64_data: str, target_dir: Path):
"""Extract a base64-encoded tar.gz archive into target_dir."""
if not b64_data:
return
raw = base64.b64decode(b64_data)
buf = io.BytesIO(raw)
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
tar.extractall(path=str(target_dir))
_safe_extract_tar(tar, target_dir)


# =============================================================================
Expand Down
3 changes: 2 additions & 1 deletion gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"""

import asyncio
import hmac
import json
import logging
import os
Expand Down Expand Up @@ -370,7 +371,7 @@ def _check_auth(self, request: "web.Request") -> Optional["web.Response"]:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
if token == self._api_key:
if hmac.compare_digest(token, self._api_key):
return None # Auth OK

return web.json_response(
Expand Down
14 changes: 14 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,14 @@ async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) ->

Returns:
Absolute path to the cached image file as a string.

Raises:
ValueError: If the URL targets a private/internal network (SSRF protection).
"""
from tools.url_safety import is_safe_url
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {_safe_url_for_log(url)}")

import asyncio
import httpx
import logging as _logging
Expand Down Expand Up @@ -232,7 +239,14 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) ->

Returns:
Absolute path to the cached audio file as a string.

Raises:
ValueError: If the URL targets a private/internal network (SSRF protection).
"""
from tools.url_safety import is_safe_url
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {_safe_url_for_log(url)}")

import asyncio
import httpx
import logging as _logging
Expand Down
5 changes: 5 additions & 0 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
cache_document_from_bytes,
SUPPORTED_DOCUMENT_TYPES,
)
from tools.url_safety import is_safe_url


def _clean_discord_id(entry: str) -> str:
Expand Down Expand Up @@ -1285,6 +1286,10 @@ async def send_image(
if not self._client:
return SendResult(success=False, error="Not connected")

if not is_safe_url(image_url):
logger.warning("[%s] Blocked unsafe image URL during Discord send_image", self.name)
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)

try:
import aiohttp

Expand Down
4 changes: 4 additions & 0 deletions gateway/platforms/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -2112,6 +2112,10 @@ async def _download_remote_document(
default_ext: str,
preferred_name: str,
) -> tuple[str, str]:
from tools.url_safety import is_safe_url
if not is_safe_url(file_url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {file_url[:80]}")

import httpx

async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
Expand Down
5 changes: 5 additions & 0 deletions gateway/platforms/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,11 @@ async def send_image(
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Download an image URL and upload it to Matrix."""
from tools.url_safety import is_safe_url
if not is_safe_url(image_url):
logger.warning("Matrix: blocked unsafe image URL (SSRF protection)")
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)

try:
# Try aiohttp first (always available), fall back to httpx
try:
Expand Down
5 changes: 5 additions & 0 deletions gateway/platforms/mattermost.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,11 @@ async def _send_url_as_file(
kind: str = "file",
) -> SendResult:
"""Download a URL and upload it as a file attachment."""
from tools.url_safety import is_safe_url
if not is_safe_url(url):
logger.warning("Mattermost: blocked unsafe URL (SSRF protection)")
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)

import asyncio
import aiohttp

Expand Down
5 changes: 5 additions & 0 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,11 @@ async def send_image(
if not self._app:
return SendResult(success=False, error="Not connected")

from tools.url_safety import is_safe_url
if not is_safe_url(image_url):
logger.warning("[Slack] Blocked unsafe image URL (SSRF protection)")
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)

try:
import httpx

Expand Down
7 changes: 6 additions & 1 deletion gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -1632,7 +1632,12 @@ async def send_image(
"""
if not self._bot:
return SendResult(success=False, error="Not connected")


from tools.url_safety import is_safe_url
if not is_safe_url(image_url):
logger.warning("[%s] Blocked unsafe image URL (SSRF protection)", self.name)
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)

try:
# Telegram can send photos directly from URLs (up to ~5MB)
_photo_thread = metadata.get("thread_id") if metadata else None
Expand Down
4 changes: 4 additions & 0 deletions gateway/platforms/wecom.py
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,10 @@ async def _download_remote_bytes(
url: str,
max_bytes: int,
) -> Tuple[bytes, Dict[str, str]]:
from tools.url_safety import is_safe_url
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {url[:80]}")

if not HTTPX_AVAILABLE:
raise RuntimeError("httpx is required for WeCom media download")

Expand Down
164 changes: 164 additions & 0 deletions tests/environments/benchmarks/test_terminalbench2_env_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Security tests for Terminal-Bench 2 archive extraction."""

import base64
import importlib
import io
import sys
import tarfile
import types

import pytest


def _stub_module(name: str, **attrs):
module = types.ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
return module


def _load_terminalbench_module(monkeypatch):
class _EvalHandlingEnum:
STOP_TRAIN = "stop_train"

class _APIServerConfig:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs

class _AgentResult:
pass

class _HermesAgentLoop:
pass

class _HermesAgentBaseEnv:
pass

class _HermesAgentEnvConfig:
pass

class _ToolContext:
pass

stub_modules = {
"atroposlib": _stub_module("atroposlib"),
"atroposlib.envs": _stub_module("atroposlib.envs"),
"atroposlib.envs.base": _stub_module(
"atroposlib.envs.base",
EvalHandlingEnum=_EvalHandlingEnum,
),
"atroposlib.envs.server_handling": _stub_module("atroposlib.envs.server_handling"),
"atroposlib.envs.server_handling.server_manager": _stub_module(
"atroposlib.envs.server_handling.server_manager",
APIServerConfig=_APIServerConfig,
),
"environments.agent_loop": _stub_module(
"environments.agent_loop",
AgentResult=_AgentResult,
HermesAgentLoop=_HermesAgentLoop,
),
"environments.hermes_base_env": _stub_module(
"environments.hermes_base_env",
HermesAgentBaseEnv=_HermesAgentBaseEnv,
HermesAgentEnvConfig=_HermesAgentEnvConfig,
),
"environments.tool_context": _stub_module(
"environments.tool_context",
ToolContext=_ToolContext,
),
"tools.terminal_tool": _stub_module(
"tools.terminal_tool",
register_task_env_overrides=lambda *args, **kwargs: None,
clear_task_env_overrides=lambda *args, **kwargs: None,
cleanup_vm=lambda *args, **kwargs: None,
),
}

stub_modules["atroposlib"].envs = stub_modules["atroposlib.envs"]
stub_modules["atroposlib.envs"].base = stub_modules["atroposlib.envs.base"]
stub_modules["atroposlib.envs"].server_handling = stub_modules["atroposlib.envs.server_handling"]
stub_modules["atroposlib.envs.server_handling"].server_manager = stub_modules[
"atroposlib.envs.server_handling.server_manager"
]

for name, module in stub_modules.items():
monkeypatch.setitem(sys.modules, name, module)

module_name = "environments.benchmarks.terminalbench_2.terminalbench2_env"
sys.modules.pop(module_name, None)
return importlib.import_module(module_name)


def _build_tar_b64(entries):
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for entry in entries:
kind = entry["kind"]
info = tarfile.TarInfo(entry["name"])

if kind == "dir":
info.type = tarfile.DIRTYPE
tar.addfile(info)
continue

if kind == "file":
data = entry["data"].encode("utf-8")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
continue

if kind == "symlink":
info.type = tarfile.SYMTYPE
info.linkname = entry["target"]
tar.addfile(info)
continue

raise ValueError(f"Unknown tar entry kind: {kind}")

return base64.b64encode(buf.getvalue()).decode("ascii")


def test_extract_base64_tar_allows_safe_files(tmp_path, monkeypatch):
module = _load_terminalbench_module(monkeypatch)
archive = _build_tar_b64(
[
{"kind": "dir", "name": "nested"},
{"kind": "file", "name": "nested/hello.txt", "data": "hello"},
]
)

target = tmp_path / "extract"
module._extract_base64_tar(archive, target)

assert (target / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello"


def test_extract_base64_tar_rejects_path_traversal(tmp_path, monkeypatch):
module = _load_terminalbench_module(monkeypatch)
archive = _build_tar_b64(
[
{"kind": "file", "name": "../escape.txt", "data": "owned"},
]
)

target = tmp_path / "extract"
with pytest.raises(ValueError, match="Unsafe archive member path"):
module._extract_base64_tar(archive, target)

assert not (tmp_path / "escape.txt").exists()


def test_extract_base64_tar_rejects_symlinks(tmp_path, monkeypatch):
module = _load_terminalbench_module(monkeypatch)
archive = _build_tar_b64(
[
{"kind": "symlink", "name": "link", "target": "../../escape.txt"},
]
)

target = tmp_path / "extract"
with pytest.raises(ValueError, match="Unsupported archive member type"):
module._extract_base64_tar(archive, target)

assert not (target / "link").exists()
3 changes: 2 additions & 1 deletion tests/gateway/test_mattermost.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,8 @@ def setup_method(self):
self.adapter._session = MagicMock()

@pytest.mark.asyncio
async def test_send_image_downloads_and_uploads(self):
@patch("tools.url_safety.is_safe_url", return_value=True)
async def test_send_image_downloads_and_uploads(self, _mock_safe):
"""send_image should download the URL, upload via /api/v4/files, then post."""
# Mock the download (GET)
mock_dl_resp = AsyncMock()
Expand Down
Loading
Loading