VYPR
Medium severity6.5NVD Advisory· Published May 27, 2026

CVE-2026-47118

CVE-2026-47118

Description

Agent Zero before version 1.15 contains a path traversal vulnerability that allows unauthenticated attackers to read arbitrary files by supplying crafted paths to the image file serving endpoint, which relies solely on an extension allowlist while the path containment check is explicitly disabled. Attackers can request any file with an image extension readable by the process, including files outside the agent workspace, user home directories, and mounted volumes, and can also leverage symlink-based escapes due to the lack of path canonicalization in the path resolution logic.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Unauthenticated path traversal in Agent Zero before 1.15 allows arbitrary file read via the image_get endpoint by bypassing containment checks.

Vulnerability

The /api/image_get endpoint in Agent Zero before version 1.15 lacks a path containment check. The intended containment using is_in_base_dir is commented out with the note "no real need to check, we have the extension filter in place" [2]. The only remaining validation is an extension allowlist that permits .jpg, .jpeg, .png, .gif, .bmp, .webp, .svg, .ico, and .svgz [1]. This allows an attacker to read any file with one of these extensions that the process can access, including files outside the agent workspace, user home directories, and mounted volumes. Additionally, because the path is not canonicalized, symlink-based escapes are possible [3].

Exploitation

An unauthenticated attacker sends a crafted GET request to /api/image_get?path= where ` is a file with an allowed extension (e.g., /etc/passwd fails because .passwd is not allowed, but /root/.ssh/id_rsa.pub or a symlink to an allowed extension file can be read). The server returns the file content verbatim. If the attacker uses a path ending in .svg, the server serves the SVG with Content-Type: image/svg+xml. When a browser renders this SVG, any embedded ` executes in the agent-zero origin, enabling stored/reflected XSS that can steal cookies or call API endpoints as the victim [2]. A full PoC including exfiltration was demonstrated without Docker [2].

Impact

An attacker can read any file on the system that the agent-zero process can open, limited only by the extension filter. This leads to information disclosure of sensitive files such as configuration files, SSH keys, or secrets. Moreover, SVG-based XSS allows arbitrary JavaScript execution in the agent-zero origin, potentially compromising the entire web UI and enabling actions on behalf of the authenticated user [2]. The vulnerability requires no authentication and no user interaction beyond visiting a malicious link.

Mitigation

The vulnerability is fixed in Agent Zero version 1.15. The fix (commit 1f2d512) re-enables path containment checks and restricts the endpoint to a workspace directory [1]. Users should update to version 1.15 or later. No official workaround has been published; users who cannot upgrade should avoid exposing the /api/image_get endpoint to untrusted networks.

AI Insight generated on May 27, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
1f2d51222652

fix(api): resolve image_get containment bypass (#1609)

https://github.com/3clyp50/agent-zeroAlessandroMay 12, 2026via nvd-ref
4 files changed · +220 33
  • api/image_get.py+66 31 modified
    @@ -1,12 +1,31 @@
     import base64
     import os
    +from pathlib import Path
     from urllib.parse import quote
     from helpers.api import ApiHandler, Request, Response, send_file
     from helpers import files, runtime
     import io
     from mimetypes import guess_type
     
     
    +IMAGE_EXTENSIONS = (
    +    ".jpg",
    +    ".jpeg",
    +    ".png",
    +    ".gif",
    +    ".bmp",
    +    ".webp",
    +    ".svg",
    +    ".ico",
    +    ".svgz",
    +)
    +SVG_EXTENSIONS = (".svg", ".svgz")
    +SVG_CONTENT_SECURITY_POLICY = (
    +    "sandbox; default-src 'none'; script-src 'none'; "
    +    "img-src 'self' data:; style-src 'unsafe-inline'"
    +)
    +
    +
     class ImageGet(ApiHandler):
     
         @classmethod
    @@ -16,48 +35,35 @@ def get_methods(cls) -> list[str]:
         async def process(self, input: dict, request: Request) -> dict | Response:
             # input data
             path = input.get("path", request.args.get("path", ""))
    -        metadata = (
    -            input.get("metadata", request.args.get("metadata", "false")).lower()
    -            == "true"
    -        )
     
             if not path:
                 raise ValueError("No path provided")
     
    -        # no real need to check, we have the extension filter in place
    -        # check if path is within base directory
    -        # if runtime.is_development():
    -        #     in_base = files.is_in_base_dir(files.fix_dev_path(path))
    -        # else:
    -        #     in_base = files.is_in_base_dir(path)
    -        # if not in_base and not files.is_in_dir(path, "/root"):
    -        #     raise ValueError("Path is outside of allowed directory")
    -
             # get file extension and info
             file_ext = os.path.splitext(path)[1].lower()
             filename = os.path.basename(path)
     
    -        # list of allowed image extensions
    -        image_extensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".ico", ".svgz"]
    -
    -        # # If metadata is requested, return file information
    -        # if metadata:
    -        #     return _get_file_metadata(path, filename, file_ext, image_extensions)
    -       
    -        if file_ext in image_extensions:
    +        if file_ext in IMAGE_EXTENSIONS:
    +            try:
    +                local_path = _resolve_allowed_image_path(path)
    +            except ValueError as exc:
    +                return Response(str(exc), status=403, mimetype="text/plain")
     
                 # in development environment, try to serve the image from local file system if exists, otherwise from docker
                 if runtime.is_development():
    -                # Convert /a0/... Docker paths to local absolute paths
    -                local_path = files.fix_dev_path(path)
                     if files.exists(local_path):
                         response = send_file(local_path)
                     else:
                         # Try fetching from Docker via RFC as fallback
                         try:
    -                        if await runtime.call_development_function(files.exists, path):
    +                        remote_path = await runtime.call_development_function(
    +                            _resolve_allowed_image_path, path
    +                        )
    +                        if await runtime.call_development_function(
    +                            files.exists, remote_path
    +                        ):
                                 b64_content = await runtime.call_development_function(
    -                                files.read_file_base64, path
    +                                files.read_file_base64, remote_path
                                 )
                                 file_content = base64.b64decode(b64_content)
                                 mime_type, _ = guess_type(filename)
    @@ -74,21 +80,50 @@ async def process(self, input: dict, request: Request) -> dict | Response:
                         except Exception:
                             response = _send_fallback_icon("image")
                 else:
    -                if files.exists(path):
    -                    response = send_file(path)
    +                if files.exists(local_path):
    +                    response = send_file(local_path)
                     else:
                         response = _send_fallback_icon("image")
     
    -            # Add cache headers for better device sync performance
    -            response.headers["Cache-Control"] = "public, max-age=3600"
    -            response.headers["X-File-Type"] = "image"
    -            response.headers["X-File-Name"] = quote(filename)
    +            _set_image_headers(response, filename, file_ext)
                 return response
             else:
                 # Handle non-image files with fallback icons
                 return _send_file_type_icon(file_ext, filename)
     
     
    +def _resolve_allowed_image_path(path: str) -> str:
    +    """Resolve a requested image path and keep it inside Agent Zero's base dir."""
    +
    +    if runtime.is_development():
    +        candidate = Path(files.fix_dev_path(path))
    +    else:
    +        candidate = Path(files.get_abs_path(path))
    +
    +    if not candidate.is_absolute():
    +        candidate = Path(files.get_base_dir()) / candidate
    +
    +    base_dir = Path(files.get_base_dir()).resolve()
    +    resolved = candidate.resolve(strict=False)
    +
    +    try:
    +        resolved.relative_to(base_dir)
    +    except ValueError as exc:
    +        raise ValueError("Path is outside of allowed directory") from exc
    +
    +    return str(resolved)
    +
    +
    +def _set_image_headers(response: Response, filename: str, file_ext: str) -> None:
    +    # Add cache headers for better device sync performance.
    +    response.headers["Cache-Control"] = "public, max-age=3600"
    +    response.headers["X-File-Type"] = "image"
    +    response.headers["X-File-Name"] = quote(filename)
    +    response.headers["X-Content-Type-Options"] = "nosniff"
    +    if file_ext in SVG_EXTENSIONS:
    +        response.headers["Content-Security-Policy"] = SVG_CONTENT_SECURITY_POLICY
    +
    +
     def _send_file_type_icon(file_ext, filename=None):
         """Return appropriate icon for file type"""
     
    
  • helpers/api.py+0 1 modified
    @@ -16,7 +16,6 @@
         url_for,
     )
     from werkzeug.wrappers.response import Response as BaseResponse
    -from agent import AgentContext
     from helpers.print_style import PrintStyle
     from helpers.errors import format_error
     from helpers import files, cache
    
  • helpers/runtime.py+3 1 modified
    @@ -3,7 +3,7 @@
     import secrets
     from pathlib import Path
     from typing import TypeVar, Callable, Awaitable, Union, overload, cast
    -from helpers import dotenv, rfc, settings, files
    +from helpers import dotenv, rfc, files
     import asyncio
     import threading
     import queue
    @@ -134,6 +134,8 @@ def _get_rfc_password() -> str:
     
     
     def _get_rfc_url() -> str:
    +    # Delay import to avoid a circular import with helpers.settings.
    +    from helpers import settings
         set = settings.get_settings()
         url = set["rfc_url"]
         if not "://" in url:
    
  • tests/test_image_get_security.py+151 0 added
    @@ -0,0 +1,151 @@
    +import asyncio
    +import base64
    +import sys
    +import threading
    +from pathlib import Path
    +
    +from flask import Flask, request
    +
    +PROJECT_ROOT = Path(__file__).resolve().parents[1]
    +if str(PROJECT_ROOT) not in sys.path:
    +    sys.path.insert(0, str(PROJECT_ROOT))
    +
    +from api import image_get
    +
    +
    +def _patch_base_dir(monkeypatch, base_dir: Path, *, development: bool = False) -> None:
    +    base_dir.mkdir(parents=True, exist_ok=True)
    +
    +    def fake_get_abs_path(*parts: str) -> str:
    +        if len(parts) == 1 and Path(str(parts[0])).is_absolute():
    +            return str(Path(str(parts[0])))
    +        return str(base_dir.joinpath(*(str(part) for part in parts)))
    +
    +    monkeypatch.setattr(image_get.files, "get_base_dir", lambda: str(base_dir))
    +    monkeypatch.setattr(image_get.files, "get_abs_path", fake_get_abs_path)
    +    monkeypatch.setattr(image_get.runtime, "is_development", lambda: development)
    +
    +
    +async def _request_image(path: str):
    +    app = Flask("test_image_get_security")
    +    handler = image_get.ImageGet(app, threading.Lock())
    +    with app.test_request_context("/api/image_get"):
    +        return await handler.process({"path": path}, request)
    +
    +
    +def test_image_get_serves_images_inside_base_dir(tmp_path, monkeypatch):
    +    base_dir = tmp_path / "a0"
    +    _patch_base_dir(monkeypatch, base_dir)
    +    image_path = base_dir / "usr" / "uploads" / "safe.png"
    +    image_path.parent.mkdir(parents=True)
    +    image_path.write_bytes(b"\x89PNG\r\n\x1a\n")
    +
    +    response = asyncio.run(_request_image(str(image_path)))
    +
    +    assert response.status_code == 200
    +    assert response.headers["X-File-Type"] == "image"
    +    assert response.headers["X-Content-Type-Options"] == "nosniff"
    +
    +
    +def test_image_get_blocks_image_paths_outside_base_dir(tmp_path, monkeypatch):
    +    base_dir = tmp_path / "a0"
    +    _patch_base_dir(monkeypatch, base_dir)
    +    outside_image = tmp_path / "outside.png"
    +    outside_image.write_bytes(b"outside")
    +
    +    response = asyncio.run(_request_image(str(outside_image)))
    +
    +    assert response.status_code == 403
    +    assert response.get_data(as_text=True) == "Path is outside of allowed directory"
    +
    +
    +def test_image_get_blocks_symlink_escape_from_base_dir(tmp_path, monkeypatch):
    +    base_dir = tmp_path / "a0"
    +    _patch_base_dir(monkeypatch, base_dir)
    +    outside_image = tmp_path / "secret.png"
    +    outside_image.write_bytes(b"secret")
    +    link_path = base_dir / "usr" / "uploads" / "linked.png"
    +    link_path.parent.mkdir(parents=True)
    +    link_path.symlink_to(outside_image)
    +
    +    response = asyncio.run(_request_image(str(link_path)))
    +
    +    assert response.status_code == 403
    +
    +
    +def test_image_get_hardens_svg_responses(tmp_path, monkeypatch):
    +    base_dir = tmp_path / "a0"
    +    _patch_base_dir(monkeypatch, base_dir)
    +    svg_path = base_dir / "usr" / "uploads" / "payload.svg"
    +    svg_path.parent.mkdir(parents=True)
    +    svg_path.write_text(
    +        '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
    +        encoding="utf-8",
    +    )
    +
    +    response = asyncio.run(_request_image(str(svg_path)))
    +
    +    assert response.status_code == 200
    +    assert response.headers["Content-Security-Policy"].startswith("sandbox;")
    +    assert "script-src 'none'" in response.headers["Content-Security-Policy"]
    +    assert response.headers["X-Content-Type-Options"] == "nosniff"
    +
    +
    +def test_image_get_development_fallback_validates_remote_path(tmp_path, monkeypatch):
    +    base_dir = tmp_path / "a0"
    +    _patch_base_dir(monkeypatch, base_dir, development=True)
    +    calls = []
    +
    +    async def fake_call_development_function(func, *args, **kwargs):
    +        calls.append(func.__name__)
    +        if func is image_get._resolve_allowed_image_path:
    +            return "/a0/usr/uploads/remote.png"
    +        if func is image_get.files.exists:
    +            return True
    +        if func is image_get.files.read_file_base64:
    +            return base64.b64encode(b"\x89PNG\r\n\x1a\n").decode("ascii")
    +        raise AssertionError(f"Unexpected remote call: {func.__name__}")
    +
    +    monkeypatch.setattr(
    +        image_get.runtime,
    +        "call_development_function",
    +        fake_call_development_function,
    +    )
    +
    +    response = asyncio.run(_request_image("/a0/usr/uploads/remote.png"))
    +
    +    assert response.status_code == 200
    +    assert response.headers["X-File-Type"] == "image"
    +    assert calls == ["_resolve_allowed_image_path", "exists", "read_file_base64"]
    +
    +
    +def test_image_get_development_fallback_does_not_read_rejected_remote_path(
    +    tmp_path,
    +    monkeypatch,
    +):
    +    base_dir = tmp_path / "a0"
    +    _patch_base_dir(monkeypatch, base_dir, development=True)
    +    calls = []
    +
    +    async def fake_call_development_function(func, *args, **kwargs):
    +        calls.append(func.__name__)
    +        if func is image_get._resolve_allowed_image_path:
    +            raise ValueError("Path is outside of allowed directory")
    +        raise AssertionError(f"Unexpected remote call after validation: {func.__name__}")
    +
    +    monkeypatch.setattr(
    +        image_get.runtime,
    +        "call_development_function",
    +        fake_call_development_function,
    +    )
    +    monkeypatch.setattr(
    +        image_get,
    +        "_send_fallback_icon",
    +        lambda _icon_name: image_get.Response("fallback", status=200),
    +    )
    +
    +    response = asyncio.run(_request_image("/a0/usr/uploads/rejected.png"))
    +
    +    assert response.status_code == 200
    +    assert response.get_data(as_text=True) == "fallback"
    +    assert calls == ["_resolve_allowed_image_path"]
    

Vulnerability mechanics

Root cause

"Path-containment check is commented out, leaving only an extension allowlist that does not restrict where files are read from."

Attack vector

An unauthenticated attacker sends a `GET /api/image_get?path=<crafted-path>` request to the Agent Zero web UI. The handler only validates that the file extension is in a static image allowlist (`.jpg`, `.png`, `.svg`, etc.) and does not check whether the path is inside the agent's base directory [ref_id=2]. This allows the attacker to read any file with an image extension that the process can open, including files outside the workspace, user home directories, or mounted volumes. Additionally, because SVG files are served with `Content-Type: image/svg+xml` without a sandboxed CSP, an attacker can upload or reference an SVG containing `<script>` tags that execute in the Agent Zero origin, enabling stored/reflected XSS [ref_id=2].

Affected code

The vulnerable endpoint is `GET /api/image_get` in `api/image_get.py`. The `process()` method accepts a `path` parameter and checks only that the file extension is in an image allowlist (`.jpg`, `.png`, `.svg`, etc.). The intended path-containment check (`files.is_in_base_dir()`) was explicitly commented out with the note "no real need to check, we have the extension filter in place" [ref_id=2]. The new `_resolve_allowed_image_path()` function introduced by the patch resolves the candidate path against the base directory and uses `Path.resolve()` with `relative_to()` to enforce containment [patch_id=2691356].

What the fix does

The patch introduces `_resolve_allowed_image_path()` which resolves the requested path using `Path.resolve()` and then verifies the resolved path is relative to the base directory via `Path.relative_to()`, rejecting paths that escape with a 403 response [patch_id=2691356]. This replaces the previously commented-out containment check with proper symlink-aware canonicalization. The patch also adds `_set_image_headers()` which sets `X-Content-Type-Options: nosniff` on all image responses and applies a sandboxed Content-Security-Policy (`script-src 'none'`) on SVG/SVGZ responses to prevent script execution in the Agent Zero origin [patch_id=2691356]. The development-mode Docker fallback path is also hardened by validating the remote path through the same `_resolve_allowed_image_path` function before reading.

Preconditions

  • authNo authentication required; the endpoint is publicly accessible
  • networkAttacker must be able to reach the Agent Zero web UI over the network
  • inputThe target file must have an image extension from the allowlist (.jpg, .jpeg, .png, .gif, .bmp, .webp, .svg, .ico, .svgz)

Generated on May 27, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.