fix(api): resolve image_get containment bypass (#1609)
Fixes agent0ai/agent-zero#1609. Issue: "Unauthenticated Path-Containment Bypass in Agent Zero `/api/image_get`" https://github.com/agent0ai/agent-zero/issues/1609 Resolve the path-containment bypass in /api/image_get by resolving requested images against the Agent Zero base directory before serving them, including symlink-aware validation and the development RFC fallback path. Harden SVG and SVGZ responses with nosniff and a sandboxed CSP so uploaded SVGs cannot execute scripts in the Agent Zero origin. Add focused regressions for outside paths, symlink escapes, SVG headers, and development-mode remote validation.
Alessandro committed
May 12, 2026 at 04:14 UTC
1f2d5122265282d6b98bc36ee8f9d0f8ab76db9e
4 files changed
+220
-33
api/image_get.py
+66
-31
@@ -1,5 +1,6 @@
1
import base64
2
import os
3
+from pathlib import Path
4
from urllib.parse import quote
5
from helpers.api import ApiHandler, Request, Response, send_file
6
from helpers import files, runtime
@@ -7,6 +8,24 @@ import io
8
from mimetypes import guess_type
9
10
11
+IMAGE_EXTENSIONS = (
12
+ ".jpg",
13
+ ".jpeg",
14
+ ".png",
15
+ ".gif",
16
+ ".bmp",
17
+ ".webp",
18
+ ".svg",
19
+ ".ico",
20
+ ".svgz",
21
+)
22
+SVG_EXTENSIONS = (".svg", ".svgz")
23
+SVG_CONTENT_SECURITY_POLICY = (
24
+ "sandbox; default-src 'none'; script-src 'none'; "
25
+ "img-src 'self' data:; style-src 'unsafe-inline'"
26
+)
27
+
28
+
29
class ImageGet(ApiHandler):
30
31
@classmethod
@@ -16,48 +35,35 @@ class ImageGet(ApiHandler):
35
async def process(self, input: dict, request: Request) -> dict | Response:
36
# input data
37
path = input.get("path", request.args.get("path", ""))
19
- metadata = (
20
- input.get("metadata", request.args.get("metadata", "false")).lower()
21
- == "true"
22
- )
38
39
if not path:
40
raise ValueError("No path provided")
41
27
- # no real need to check, we have the extension filter in place
28
- # check if path is within base directory
29
- # if runtime.is_development():
30
- # in_base = files.is_in_base_dir(files.fix_dev_path(path))
31
- # else:
32
- # in_base = files.is_in_base_dir(path)
33
- # if not in_base and not files.is_in_dir(path, "/root"):
34
- # raise ValueError("Path is outside of allowed directory")
35
-
42
# get file extension and info
43
file_ext = os.path.splitext(path)[1].lower()
44
filename = os.path.basename(path)
45
40
- # list of allowed image extensions
41
- image_extensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".ico", ".svgz"]
42
-
43
- # # If metadata is requested, return file information
44
- # if metadata:
45
- # return _get_file_metadata(path, filename, file_ext, image_extensions)
46
-
47
- if file_ext in image_extensions:
46
+ if file_ext in IMAGE_EXTENSIONS:
47
+ try:
48
+ local_path = _resolve_allowed_image_path(path)
49
+ except ValueError as exc:
50
+ return Response(str(exc), status=403, mimetype="text/plain")
51
52
# in development environment, try to serve the image from local file system if exists, otherwise from docker
53
if runtime.is_development():
51
- # Convert /a0/... Docker paths to local absolute paths
52
- local_path = files.fix_dev_path(path)
54
if files.exists(local_path):
55
response = send_file(local_path)
56
else:
57
# Try fetching from Docker via RFC as fallback
58
try:
58
- if await runtime.call_development_function(files.exists, path):
59
+ remote_path = await runtime.call_development_function(
60
+ _resolve_allowed_image_path, path
61
+ )
62
+ if await runtime.call_development_function(
63
+ files.exists, remote_path
64
+ ):
65
b64_content = await runtime.call_development_function(
60
- files.read_file_base64, path
66
+ files.read_file_base64, remote_path
67
)
68
file_content = base64.b64decode(b64_content)
69
mime_type, _ = guess_type(filename)
@@ -74,21 +80,50 @@ class ImageGet(ApiHandler):
80
except Exception:
81
response = _send_fallback_icon("image")
82
else:
77
- if files.exists(path):
78
- response = send_file(path)
83
+ if files.exists(local_path):
84
+ response = send_file(local_path)
85
else:
86
response = _send_fallback_icon("image")
87
82
- # Add cache headers for better device sync performance
83
- response.headers["Cache-Control"] = "public, max-age=3600"
84
- response.headers["X-File-Type"] = "image"
85
- response.headers["X-File-Name"] = quote(filename)
88
+ _set_image_headers(response, filename, file_ext)
89
return response
90
else:
91
# Handle non-image files with fallback icons
92
return _send_file_type_icon(file_ext, filename)
93
94
95
+def _resolve_allowed_image_path(path: str) -> str:
96
+ """Resolve a requested image path and keep it inside Agent Zero's base dir."""
97
+
98
+ if runtime.is_development():
99
+ candidate = Path(files.fix_dev_path(path))
100
+ else:
101
+ candidate = Path(files.get_abs_path(path))
102
+
103
+ if not candidate.is_absolute():
104
+ candidate = Path(files.get_base_dir()) / candidate
105
+
106
+ base_dir = Path(files.get_base_dir()).resolve()
107
+ resolved = candidate.resolve(strict=False)
108
+
109
+ try:
110
+ resolved.relative_to(base_dir)
111
+ except ValueError as exc:
112
+ raise ValueError("Path is outside of allowed directory") from exc
113
+
114
+ return str(resolved)
115
+
116
+
117
+def _set_image_headers(response: Response, filename: str, file_ext: str) -> None:
118
+ # Add cache headers for better device sync performance.
119
+ response.headers["Cache-Control"] = "public, max-age=3600"
120
+ response.headers["X-File-Type"] = "image"
121
+ response.headers["X-File-Name"] = quote(filename)
122
+ response.headers["X-Content-Type-Options"] = "nosniff"
123
+ if file_ext in SVG_EXTENSIONS:
124
+ response.headers["Content-Security-Policy"] = SVG_CONTENT_SECURITY_POLICY
125
+
126
+
127
def _send_file_type_icon(file_ext, filename=None):
128
"""Return appropriate icon for file type"""
129
helpers/api.py
-1
@@ -16,7 +16,6 @@ from flask import (
16
url_for,
17
)
18
from werkzeug.wrappers.response import Response as BaseResponse
19
-from agent import AgentContext
19
from helpers.print_style import PrintStyle
20
from helpers.errors import format_error
21
from helpers import files, cache
helpers/runtime.py
+3
-1
@@ -3,7 +3,7 @@ import inspect
3
import secrets
4
from pathlib import Path
5
from typing import TypeVar, Callable, Awaitable, Union, overload, cast
6
-from helpers import dotenv, rfc, settings, files
6
+from helpers import dotenv, rfc, files
7
import asyncio
8
import threading
9
import queue
@@ -134,6 +134,8 @@ def _get_rfc_password() -> str:
134
135
136
def _get_rfc_url() -> str:
137
+ # Delay import to avoid a circular import with helpers.settings.
138
+ from helpers import settings
139
set = settings.get_settings()
140
url = set["rfc_url"]
141
if not "://" in url:
tests/test_image_get_security.py
new
+151
@@ -0,0 +1,151 @@
1
+import asyncio
2
+import base64
3
+import sys
4
+import threading
5
+from pathlib import Path
6
+
7
+from flask import Flask, request
8
+
9
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
10
+if str(PROJECT_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(PROJECT_ROOT))
12
+
13
+from api import image_get
14
+
15
+
16
+def _patch_base_dir(monkeypatch, base_dir: Path, *, development: bool = False) -> None:
17
+ base_dir.mkdir(parents=True, exist_ok=True)
18
+
19
+ def fake_get_abs_path(*parts: str) -> str:
20
+ if len(parts) == 1 and Path(str(parts[0])).is_absolute():
21
+ return str(Path(str(parts[0])))
22
+ return str(base_dir.joinpath(*(str(part) for part in parts)))
23
+
24
+ monkeypatch.setattr(image_get.files, "get_base_dir", lambda: str(base_dir))
25
+ monkeypatch.setattr(image_get.files, "get_abs_path", fake_get_abs_path)
26
+ monkeypatch.setattr(image_get.runtime, "is_development", lambda: development)
27
+
28
+
29
+async def _request_image(path: str):
30
+ app = Flask("test_image_get_security")
31
+ handler = image_get.ImageGet(app, threading.Lock())
32
+ with app.test_request_context("/api/image_get"):
33
+ return await handler.process({"path": path}, request)
34
+
35
+
36
+def test_image_get_serves_images_inside_base_dir(tmp_path, monkeypatch):
37
+ base_dir = tmp_path / "a0"
38
+ _patch_base_dir(monkeypatch, base_dir)
39
+ image_path = base_dir / "usr" / "uploads" / "safe.png"
40
+ image_path.parent.mkdir(parents=True)
41
+ image_path.write_bytes(b"\x89PNG\r\n\x1a\n")
42
+
43
+ response = asyncio.run(_request_image(str(image_path)))
44
+
45
+ assert response.status_code == 200
46
+ assert response.headers["X-File-Type"] == "image"
47
+ assert response.headers["X-Content-Type-Options"] == "nosniff"
48
+
49
+
50
+def test_image_get_blocks_image_paths_outside_base_dir(tmp_path, monkeypatch):
51
+ base_dir = tmp_path / "a0"
52
+ _patch_base_dir(monkeypatch, base_dir)
53
+ outside_image = tmp_path / "outside.png"
54
+ outside_image.write_bytes(b"outside")
55
+
56
+ response = asyncio.run(_request_image(str(outside_image)))
57
+
58
+ assert response.status_code == 403
59
+ assert response.get_data(as_text=True) == "Path is outside of allowed directory"
60
+
61
+
62
+def test_image_get_blocks_symlink_escape_from_base_dir(tmp_path, monkeypatch):
63
+ base_dir = tmp_path / "a0"
64
+ _patch_base_dir(monkeypatch, base_dir)
65
+ outside_image = tmp_path / "secret.png"
66
+ outside_image.write_bytes(b"secret")
67
+ link_path = base_dir / "usr" / "uploads" / "linked.png"
68
+ link_path.parent.mkdir(parents=True)
69
+ link_path.symlink_to(outside_image)
70
+
71
+ response = asyncio.run(_request_image(str(link_path)))
72
+
73
+ assert response.status_code == 403
74
+
75
+
76
+def test_image_get_hardens_svg_responses(tmp_path, monkeypatch):
77
+ base_dir = tmp_path / "a0"
78
+ _patch_base_dir(monkeypatch, base_dir)
79
+ svg_path = base_dir / "usr" / "uploads" / "payload.svg"
80
+ svg_path.parent.mkdir(parents=True)
81
+ svg_path.write_text(
82
+ '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
83
+ encoding="utf-8",
84
+ )
85
+
86
+ response = asyncio.run(_request_image(str(svg_path)))
87
+
88
+ assert response.status_code == 200
89
+ assert response.headers["Content-Security-Policy"].startswith("sandbox;")
90
+ assert "script-src 'none'" in response.headers["Content-Security-Policy"]
91
+ assert response.headers["X-Content-Type-Options"] == "nosniff"
92
+
93
+
94
+def test_image_get_development_fallback_validates_remote_path(tmp_path, monkeypatch):
95
+ base_dir = tmp_path / "a0"
96
+ _patch_base_dir(monkeypatch, base_dir, development=True)
97
+ calls = []
98
+
99
+ async def fake_call_development_function(func, *args, **kwargs):
100
+ calls.append(func.__name__)
101
+ if func is image_get._resolve_allowed_image_path:
102
+ return "/a0/usr/uploads/remote.png"
103
+ if func is image_get.files.exists:
104
+ return True
105
+ if func is image_get.files.read_file_base64:
106
+ return base64.b64encode(b"\x89PNG\r\n\x1a\n").decode("ascii")
107
+ raise AssertionError(f"Unexpected remote call: {func.__name__}")
108
+
109
+ monkeypatch.setattr(
110
+ image_get.runtime,
111
+ "call_development_function",
112
+ fake_call_development_function,
113
+ )
114
+
115
+ response = asyncio.run(_request_image("/a0/usr/uploads/remote.png"))
116
+
117
+ assert response.status_code == 200
118
+ assert response.headers["X-File-Type"] == "image"
119
+ assert calls == ["_resolve_allowed_image_path", "exists", "read_file_base64"]
120
+
121
+
122
+def test_image_get_development_fallback_does_not_read_rejected_remote_path(
123
+ tmp_path,
124
+ monkeypatch,
125
+):
126
+ base_dir = tmp_path / "a0"
127
+ _patch_base_dir(monkeypatch, base_dir, development=True)
128
+ calls = []
129
+
130
+ async def fake_call_development_function(func, *args, **kwargs):
131
+ calls.append(func.__name__)
132
+ if func is image_get._resolve_allowed_image_path:
133
+ raise ValueError("Path is outside of allowed directory")
134
+ raise AssertionError(f"Unexpected remote call after validation: {func.__name__}")
135
+
136
+ monkeypatch.setattr(
137
+ image_get.runtime,
138
+ "call_development_function",
139
+ fake_call_development_function,
140
+ )
141
+ monkeypatch.setattr(
142
+ image_get,
143
+ "_send_fallback_icon",
144
+ lambda _icon_name: image_get.Response("fallback", status=200),
145
+ )
146
+
147
+ response = asyncio.run(_request_image("/a0/usr/uploads/rejected.png"))
148
+
149
+ assert response.status_code == 200
150
+ assert response.get_data(as_text=True) == "fallback"
151
+ assert calls == ["_resolve_allowed_image_path"]