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

CVE-2026-47119

CVE-2026-47119

Description

Agent Zero before version 1.15 contains a stored cross-site scripting vulnerability that allows attackers to execute arbitrary JavaScript in the application origin by serving SVG files through the image_get API endpoint without Content-Security-Policy, X-Content-Type-Options, or Content-Disposition headers. Attackers can place a crafted SVG file containing script tags in any path readable by the agent-zero process and lure an authenticated user to the image_get endpoint, causing the browser to execute the malicious script, steal the csrf_token cookie, and perform unauthorized API calls on behalf of the victim.

AI Insight

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

Agent Zero before 1.15 allows stored XSS via SVG files served through the image_get API without proper headers, enabling cookie theft and API abuse.

Vulnerability

Agent Zero before version 1.15 contains a stored cross-site scripting (XSS) vulnerability in the /api/image_get endpoint. The handler accepts a path parameter and serves files with image extensions (including .svg) without Content-Security-Policy, X-Content-Type-Options, or Content-Disposition headers [1][3]. The developer commented out the intended directory containment check, relying solely on an extension whitelist [2]. This allows any file with an allowed extension readable by the agent-zero process to be served, including attacker-controlled SVG files placed anywhere on the filesystem [2].

Exploitation

An attacker must first write a crafted SVG file containing JavaScript payload to a path accessible by the agent-zero process (e.g., via a writable share or other means). Then, the attacker lures an authenticated user of Agent Zero to navigate to /api/image_get?path=<path_to_svg>. The browser renders the SVG, executing the embedded script in the application's origin. The script can read document.cookie to steal the csrf_token and perform any API call on behalf of the victim [2][3]. No authentication is needed to access the endpoint, but the XSS payload only activates when an authenticated user loads the SVG.

Impact

Successful exploitation results in stored XSS in the Agent Zero origin, allowing the attacker to steal CSRF tokens, make unauthorized API calls, and fully compromise the victim's session. This can lead to unauthorized actions including reading, modifying, or deleting data accessible through the API [2][3].

Mitigation

The vulnerability is fixed in Agent Zero version 1.15. The commit [1] adds directory containment checks for SVG files and sets a strict Content-Security-Policy sandbox; default-src 'none'; script-src 'none'; img-src 'self' data:; style-src 'unsafe-inline' for SVG responses. No workaround is available; users should update to 1.15 or later [1][3]. The issue is tracked as GHSA-q4mh-57gh-36r2 [2].

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

"Missing path-containment validation and missing Content-Security-Policy headers on SVG responses in the image_get API endpoint."

Attack vector

An attacker places a crafted SVG file containing `<script>` tags at any path readable by the agent-zero process (e.g., a user's `~/Pictures/` directory or a Docker host-mounted volume). The attacker then lures an authenticated user to visit `/api/image_get?path=.../attacker.svg`. Because the endpoint returns SVG files with `Content-Type: image/svg+xml` but without `Content-Security-Policy` or `X-Content-Type-Options` headers, the browser renders the SVG as a top-level document and executes the embedded script in the agent-zero origin [ref_id=2]. The script can steal the `csrf_token` cookie and perform unauthorized API calls on behalf of the victim.

Affected code

The vulnerable endpoint is `GET /api/image_get` in `api/image_get.py`. The path-containment check (`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 handler only verified the file extension against an image allowlist but never verified that the resolved path stayed within the Agent Zero base directory [patch_id=2691355].

What the fix does

The patch introduces `_resolve_allowed_image_path()` which resolves the requested path against the Agent Zero base directory and uses `Path.resolve().relative_to(base_dir)` to ensure the final path stays within the base directory, including symlink-aware validation [patch_id=2691355]. It also adds `_set_image_headers()` which sets `X-Content-Type-Options: nosniff` on all image responses and applies a sandboxed Content-Security-Policy (`sandbox; default-src 'none'; script-src 'none'`) specifically for SVG/SVGZ files, preventing script execution in the agent-zero origin [patch_id=2691355]. The development-mode fallback path was also hardened to validate remote paths through the same resolver before reading file contents.

Preconditions

  • inputAttacker must place a crafted SVG file at a path readable by the agent-zero process
  • authVictim must be authenticated to the Agent Zero web UI
  • networkVictim must navigate to the crafted /api/image_get URL (e.g., via social engineering)

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.