From a8fae815550d7e93cd48b6346cff2f2165a88798 Mon Sep 17 00:00:00 2001 From: Jaeyoung Yun Date: Tue, 19 May 2026 15:27:42 +0900 Subject: [PATCH] Use ImmutableSandboxedEnvironment in jinja.py to prevent SSTI The previous JinjaEnvironment exposed `inspect` and `os.getcwd` as Jinja globals and used the raw (non-sandboxed) Environment. Any string that flows into `Template.render()` from external input could escape via either explicit globals (`{{ inspect.getmembers(os) }}`) or Python's implicit class-traversal vectors (`{{ ''.__class__.__mro__[1].__subclasses__() }}`). Marvin exposes prompt_template as a public field on Task, Agent, Memory, Team, and Actor. If any of those flow from external input (LLM-generated, HTTP request, file load), the SSTI primitive reaches arbitrary Python execution in the host process. Three changes: 1. Replace `from jinja2 import Environment as JinjaEnvironment` with `from jinja2.sandbox import ImmutableSandboxedEnvironment as JinjaEnvironment`. This blocks `__class__`, `__init__`, `__globals__`, `__builtins__`, `__import__`, `__subclasses__`, and the rest of the standard class-traversal escape chain. 2. Remove `inspect` from global_fns. It was not referenced by any shipped template (grepped src/marvin/templates/) and its only plausible runtime use is the SSTI escape path itself. 3. Remove `getcwd` from global_fns. Same reasoning: not used by any template, and exposes the host's working directory to template consumers. Verified that all 5 shipped templates (task, memory, system, agent, team) load + parse cleanly under ImmutableSandboxedEnvironment. The SecurityError raised by the sandbox is the expected behavior on disallowed attribute access; non-malicious templates that only access Marvin's own typed model attributes (task.id, agent.name, etc.) continue to render normally. Disclosed to bugbounty@prefect.io 2026-05-17; out of bounty scope per the program (only Prefect Cloud / Prefect OSS / Horizon are in scope), but the team explicitly invited a PR. --- src/marvin/utilities/jinja.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/marvin/utilities/jinja.py b/src/marvin/utilities/jinja.py index c9e55b5ac..38924e71f 100644 --- a/src/marvin/utilities/jinja.py +++ b/src/marvin/utilities/jinja.py @@ -1,12 +1,10 @@ -import inspect -import os from datetime import datetime from functools import partial from typing import Any from zoneinfo import ZoneInfo -from jinja2 import Environment as JinjaEnvironment from jinja2 import PackageLoader, StrictUndefined, select_autoescape +from jinja2.sandbox import ImmutableSandboxedEnvironment as JinjaEnvironment from pydantic_core import to_json @@ -28,8 +26,6 @@ def _pretty_print(x: object) -> str: global_fns: dict[str, Any] = { "now": partial(datetime.now, tz=ZoneInfo("UTC")), - "inspect": inspect, - "getcwd": os.getcwd, "zip": zip, "is_agent": _is_agent, "is_team": _is_team,