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"]