· 12 min read · Nadeera Silva
FastMCP Vulnerability CVE-2026-120338: Check and Fix Prefab UI
A CVSS 10.0 FastMCP vulnerability in prefab-ui lets Generative UI code read and write host files. Check your versions, upgrade and look for planted .pth files.

A critical FastMCP vulnerability landed this weekend, and it lives in a dependency rather than in FastMCP itself. CVE-2026-120338 (CVSS 10.0) is a sandbox escape in prefab-ui 0.14.0 to 0.20.2, the package behind FastMCP's Generative UI. If your MCP server calls mcp.add_provider(GenerativeUI()), any client that can call the generate_prefab_ui tool can read and write files on the host as the server's OS user, and can plant a .pth file that runs code the next time Python starts. The fix is prefab-ui 0.21.0. In this post I explain who is exposed, how I reproduced it and confirmed the fix on a throwaway environment, and share a tested script that checks your versions and looks for planted startup files.
What CVE-2026-120338 is
Prefab is Prefect's Python UI framework for MCP Apps, and FastMCP ships it as an optional extra. Its Generative UI feature lets a model write Prefab Python at runtime. The server then runs that code in a sandbox: Pyodide (Python compiled to WebAssembly) inside a Deno subprocess.
The problem, as JFrog's researcher Yuval Moravchick describes it, is that the sandbox was not much of a sandbox. From 0.14.0 through 0.20.2, prefab-ui started Deno with unrestricted --allow-read, --allow-write and --allow-net, and the runner left the Deno API on the JavaScript global object. Pyodide's js module proxies that global, so the "untrusted" Python could call Deno.readTextFileSync and Deno.writeTextFileSync on the real filesystem. The runner's own code comment in 0.20.2 says "No write, env, or FFI access", which I confirmed by reading the installed file. The launch flags said otherwise.
| Item | Detail |
|---|---|
| CVE | CVE-2026-120338, JFrog ID JFSA-2026-001699278 |
| Advisories | GitHub GHSA-3g72-rrqc-f76c; OSV lists GHSA-595q-jv4r-q7j9 as an alias |
| Score | 10.0 with no authentication, 9.9 when the caller must be an authenticated tool user |
| Affected | prefab-ui 0.14.0 to 0.20.2 (0.14.0 shipped 27 March 2026) |
| Fixed | prefab-ui 0.21.0, released 26 October 2026 (UTC); 0.21.1 is current |
| Fix | PR #538, commit 030f074 |
| Weakness | CWE-269, CWE-693, CWE-94 |
Who is exposed by this FastMCP vulnerability
Not every FastMCP server. Three things have to be true:
- You opted in to Generative UI. It is not on by default. You either call
mcp.add_provider(GenerativeUI()), or you run Prefab'sSandboxon code you did not write yourself. - Deno is on the server's PATH. The prefab-ui 0.21.1 code I read finds Deno with
shutil.which("deno")and raises an error if it is missing. The FastMCP docs say Deno "installs automatically on first use", so check the host rather than assuming it is absent. - Someone untrusted can call the tool.
FastMCP(...)defaults toauth=Noneunless you configure a provider. On a server with no auth, anyone who can reach it can callgenerate_prefab_ui. With auth, anyone allowed to call tools can, which is why the authenticated score is still 9.9.
The last point matters for AI agent setups. The code argument is normally written by the model, so a prompt injection that steers the model into writing hostile Prefab code is another route to the same tool, even when every client is trusted. I treat any tool that runs model-written code as reachable by whoever can influence the model's input.

Why an old lockfile keeps you vulnerable
FastMCP 4.1.5, 4.2.0a2 and 3.4.11 all declare prefab-ui>=0.18.0 for the apps extra. That floor is below the fix. Upgrading FastMCP does not pull in a safe prefab-ui if your lockfile, requirements.txt or Docker image already has 0.18 to 0.20.2 pinned. You need to move prefab-ui explicitly:
pip install --upgrade "prefab-ui>=0.21.0"
# uv projects
uv add "prefab-ui>=0.21.0"
uv lock --upgrade-package prefab-uiThen restart every process that uses the sandbox. The Prefab sandbox keeps a warm Deno process between calls, so a running server keeps the old runner until it restarts.
How I tested the fix
I created two throwaway virtual environments on a scratch machine, one with prefab-ui 0.20.2 and one with fastmcp 4.1.5 plus prefab-ui 0.21.1, and wrote a canary file to /tmp. I then ran the same short piece of Prefab code through each sandbox, trying to read the canary through the js bridge. I am not publishing the probe, but the advisories already describe the call.
- On 0.20.2 the sandbox returned the canary's contents inside the rendered component.
- On 0.21.1 the same read failed with
NotCapable: Requires read access to "/tmp/prefab_canary.txt", run again with the --allow-read flag.
That matches the fix description. In 0.21.0 the runner loads Pyodide and the Prefab sources, then calls Deno.permissions.revoke for read, write and net before it accepts any generated code. Deno now starts with --no-prompt, so nothing can ask for those permissions back, and bootstrap network access is limited to cdn.jsdelivr.net:443.
Check your FastMCP server for CVE-2026-120338
This script uses only the standard library. Run it with the Python interpreter of the environment you want to check, not your system Python. It prints the prefab-ui and FastMCP versions, whether Deno is on PATH, any GenerativeUI lines in your source, and every .pth file in site-packages. It also flags any .pth, sitecustomize.py or usercustomize.py file that no installed package lists in its RECORD.
#!/usr/bin/env python3
"""Check a Python environment for CVE-2026-120338 (prefab-ui Generative UI sandbox escape).
Run it with the interpreter of the environment you want to check, for example:
/srv/mcp/.venv/bin/python check_prefab_cve.py --src /srv/mcp/app
Exit code: 0 = nothing found, 1 = affected prefab-ui version, 2 = review needed (unowned startup file).
"""
from __future__ import annotations
import argparse
import csv
import shutil
import site
import sys
from datetime import datetime, timezone
from importlib import metadata
from pathlib import Path
AFFECTED_MIN, FIXED = (0, 14, 0), (0, 21, 0)
def parse(v: str) -> tuple[int, ...]:
out: list[int] = []
for part in v.split(".")[:3]:
digits = "".join(ch for ch in part if ch.isdigit())
out.append(int(digits) if digits else 0)
return tuple(out + [0] * (3 - len(out)))
def version(name: str) -> str | None:
try:
return metadata.version(name)
except metadata.PackageNotFoundError:
return None
def site_dirs() -> list[Path]:
dirs = [Path(p) for p in site.getsitepackages()]
user = Path(site.getusersitepackages())
if site.ENABLE_USER_SITE and user.is_dir():
dirs.append(user)
return [d for d in dict.fromkeys(dirs) if d.is_dir()]
def owned_files(sp: Path) -> set[str]:
"""Every file path (relative to site-packages) listed in a RECORD file."""
owned: set[str] = set()
for record in sp.glob("*.dist-info/RECORD"):
with record.open(newline="", encoding="utf-8", errors="replace") as fh:
for row in csv.reader(fh):
if row:
owned.add(row[0].replace("\\", "/"))
return owned
def scan_pth(sp: Path) -> list[tuple[Path, bool, list[str], str]]:
owned = owned_files(sp)
results = []
for pth in sorted(sp.glob("*.pth")):
lines = pth.read_text(encoding="utf-8", errors="replace").splitlines()
exec_lines = [ln.strip() for ln in lines if ln.lstrip().startswith(("import ", "import\t"))]
mtime = datetime.fromtimestamp(pth.stat().st_mtime, timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
results.append((pth, pth.name in owned, exec_lines, mtime))
return results
def find_generative_ui(src: Path) -> list[str]:
hits = []
for py in src.rglob("*.py"):
if any(part in {".venv", "venv", "site-packages", "node_modules", ".git"} for part in py.parts):
continue
text = py.read_text(encoding="utf-8", errors="replace")
for n, line in enumerate(text.splitlines(), 1):
if "GenerativeUI" in line and not line.lstrip().startswith("#"):
hits.append(f"{py}:{n}: {line.strip()}")
return hits
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--src", type=Path, help="project source folder to search for GenerativeUI")
args = ap.parse_args()
status, review = 0, False
print(f"Python: {sys.executable}")
prefab = version("prefab-ui")
for name in ("prefab-ui", "fastmcp", "fastmcp-slim"):
print(f"{name:13} {version(name) or 'not installed'}")
print(f"deno on PATH: {shutil.which('deno') or 'no'}")
if prefab and AFFECTED_MIN <= parse(prefab) < FIXED:
print(f"VULNERABLE: prefab-ui {prefab} is in the affected range 0.14.0 to 0.20.2. Upgrade to 0.21.0 or later.")
status = 1
elif prefab:
print(f"OK: prefab-ui {prefab} is outside the affected range.")
if args.src:
hits = find_generative_ui(args.src)
print(f"GenerativeUI references in {args.src}: {len(hits)}")
for h in hits:
print(" " + h)
for sp in site_dirs():
print(f"\n.pth files in {sp}")
for pth, is_owned, exec_lines, mtime in scan_pth(sp):
flag = "owned " if is_owned else "UNOWNED"
print(f" [{flag}] {pth.name} (modified {mtime}, {len(exec_lines)} import line(s))")
if not is_owned:
for ln in exec_lines:
print(f" runs at startup: {ln[:160]}")
review = True
owned = owned_files(sp)
for hook in ("sitecustomize.py", "usercustomize.py"):
if (sp / hook).exists() and hook not in owned:
print(f" [UNOWNED] {hook} (also runs at every interpreter start)")
review = True
if review:
status = status or 2
print("\nREVIEW: at least one startup file is not listed in any package RECORD. Check who created it.")
return status
if __name__ == "__main__":
sys.exit(main())Real output from a test environment with fastmcp 4.1.5, prefab-ui 0.20.2 and a harmless .pth file I planted (paths shortened). The exit code was 1:
Python: ./.venv-test/bin/python
prefab-ui 0.20.2
fastmcp 4.1.5
fastmcp-slim 4.1.5
deno on PATH: ~/.deno/bin/deno
VULNERABLE: prefab-ui 0.20.2 is in the affected range 0.14.0 to 0.20.2. Upgrade to 0.21.0 or later.
GenerativeUI references in lab/app: 2
lab/app/server.py:2: from fastmcp.apps.generative import GenerativeUI
lab/app/server.py:5: mcp.add_provider(GenerativeUI())
.pth files in ./.venv-test/lib/python3.13/site-packages
[owned ] a1_coverage.pth (modified 2026-11-02 09:37 UTC, 1 import line(s))
[owned ] distutils-precedence.pth (modified 2026-11-02 09:37 UTC, 1 import line(s))
[UNOWNED] zz_planted_test.pth (modified 2026-11-02 09:41 UTC, 1 import line(s))
runs at startup: import sys
REVIEW: at least one startup file is not listed in any package RECORD. Check who created it.Why RECORD? Every wheel install writes a list of the files it created. Legitimate .pth files from setuptools, coverage or editable installs appear in one of those lists. A file the sandbox wrote does not. "Unowned" is a reason to look, not proof of compromise, since some tools write .pth files by hand. An import line in an unowned file is the part to read closely, because Python runs those lines at every interpreter start. I tested the script with mypy --strict and against clean, vulnerable and planted environments. Exit code 1 means upgrade, 2 means review.
If the server was exposed
If an affected server was reachable by clients you do not fully control, assume the attacker could do anything the server's OS user can do. The sandbox also had unrestricted network access, so data could have left. My order of work:
- Take Generative UI offline or block the server, then upgrade prefab-ui and restart.
- Run the script above against every virtualenv on the host, and check
~/.bashrc,~/.profile,~/.ssh/authorized_keysand the user crontab for that account. - Rotate every secret the server user could read:
.envfiles, API keys for LLM providers, database URLs, cloud credentials and MCP OAuth client secrets. - If you find anything you cannot explain, rebuild the environment from a clean image instead of cleaning it in place.
The clean-up steps overlap with what I wrote after the LiteLLM 1.107.0 compromise. That guide covers key rotation for AI apps in more detail.
Harden the server so the next sandbox bug matters less
The upgrade closes this hole. What reduced the damage was the server's own configuration: no authentication, a tool that runs model-written code, and a process user that could write to its own site-packages. Here is the pattern I now use on client MCP servers. Generative UI is off unless an environment variable turns it on, and the server refuses to start with it on if prefab-ui is unpatched or no auth is configured.
"""FastMCP server that only exposes Generative UI when it is safe to do so."""
from __future__ import annotations
import os
from importlib.metadata import version
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier
def prefab_is_patched() -> bool:
major, minor, *_ = (int(p) for p in version("prefab-ui").split(".")[:2])
return (major, minor) >= (0, 21)
def build_server() -> FastMCP:
jwks = os.environ.get("MCP_JWKS_URI")
auth = (
JWTVerifier(
jwks_uri=jwks,
issuer=os.environ["MCP_ISSUER"],
audience=os.environ["MCP_AUDIENCE"],
)
if jwks
else None
)
mcp = FastMCP("reports", auth=auth)
if os.environ.get("ENABLE_GENERATIVE_UI") == "1":
if not prefab_is_patched():
raise RuntimeError("prefab-ui < 0.21.0 is affected by CVE-2026-120338; upgrade first")
if auth is None:
raise RuntimeError("refusing to expose generate_prefab_ui without authentication")
from fastmcp.apps.generative import GenerativeUI
mcp.add_provider(GenerativeUI())
return mcp
mcp = build_server()
if __name__ == "__main__":
mcp.run(transport="http", host="127.0.0.1", port=8000)I tested it with FastMCP's in-memory client. By default it lists no tools. With ENABLE_GENERATIVE_UI=1 and no auth it refuses to start. With a JWTVerifier configured it registers generate_prefab_ui and search_prefab_components. With prefab-ui 0.20.2 installed it refuses to start. It also passes mypy --strict.
Beyond the code:
- Run the server as a user that cannot write to its own virtualenv. Install dependencies at image build time and mount site-packages read-only, so a sandbox escape cannot plant a startup file.
- Bind to
127.0.0.1or a private network unless the server truly needs to be public, and put it behind your identity provider. My MCP stateless spec migration guide covers auth and transport choices for remote servers. - Treat any tool that executes code as high risk, and give it its own process or container with no secrets in its environment.
- Pin minimum versions for security-relevant transitive dependencies in your own
pyproject.toml, instead of relying on the floor a framework declares.
Quick checklist
prefab-uiis 0.21.0 or later in every environment, including Docker images and CI caches.- Every process using the Prefab sandbox has been restarted since the upgrade.
check_prefab_cve.pyexits 0 on each virtualenv, or each unowned startup file has an explanation.- Generative UI is only enabled on servers that need it, and those servers require authentication.
- Secrets readable by the server user have been rotated if the server was exposed to untrusted clients.
- The server user cannot write to its own site-packages.
Frequently asked questions
What is CVE-2026-120338?
It is a critical sandbox escape in the prefab-ui Python package, scored CVSS 10.0. The Generative UI sandbox started Deno with full read, write and network permissions, so caller-supplied Python could read and write host files as the server user and plant a .pth file for persistent code execution.
Which prefab-ui versions are affected by CVE-2026-120338?
Versions 0.14.0 through 0.20.2 are affected. The fix is prefab-ui 0.21.0, released on 26 October 2026, and 0.21.1 is the current release.
Is my FastMCP server vulnerable?
Only if it registers the GenerativeUI provider (or runs the Prefab sandbox on untrusted code), has Deno on its PATH, and uses prefab-ui 0.14.0 to 0.20.2. Servers without authentication are the worst case because any client that can reach them can call the generate_prefab_ui tool.
Does upgrading FastMCP fix CVE-2026-120338?
Not by itself. FastMCP's apps extra only requires prefab-ui 0.18.0 or later, so an existing lockfile can keep a vulnerable version. Upgrade prefab-ui to 0.21.0 or later explicitly and restart the server.
Why doesn't pip-audit report CVE-2026-120338?
On 2 November 2026 the GitHub advisory listed no affected package and the OSV record only had a git range, so pip-audit 2.10.1 found nothing for prefab-ui 0.20.2. Check the installed version directly until the advisory data is updated.
What should I do if my server was exposed?
Upgrade and restart, scan every virtualenv for unexpected .pth, sitecustomize.py and usercustomize.py files, check shell profiles, SSH keys and crontabs for the server user, and rotate every secret that user could read. If anything is unexplained, rebuild from a clean image.
Sources
- Prefab UI is vulnerable to Remote Code Execution via a Pyodide js Bridge to Deno (JFSA-2026-001699278) (JFrog Security Research, 2026-11-01)
- CVE-2026-120338 (OSV, 2026-11-01)
- GHSA-3g72-rrqc-f76c: Prefab UI's Generative UI sandbox runs caller-supplied Python (GitHub Advisory Database, 2026-11-01)
- CVE-2026-120338 detail (NIST NVD, 2026-11-01)
- Refresh dependencies and fix renderer and sandbox regressions (PR #538) (PrefectHQ/prefab on GitHub, 2026-10-25)
- Prefab v0.21.0 release (PrefectHQ/prefab on GitHub, 2026-10-26)
- prefab-ui release history (PyPI)
- Generative UI (FastMCP docs)
- FastMCP v4.1.5 release (PrefectHQ/fastmcp on GitHub, 2026-11-01)
- fastmcp-slim 4.1.5 (PyPI, 2026-11-01)
- site: Site-specific configuration hook (.pth files) (Python documentation)
- Deno permissions (Deno docs)
Keep reading
- LiteLLM 1.107.0 Compromised: What to Check and RotateLiteLLM 1.107.0 shipped a credential stealer on PyPI and Docker. Here is how to check exposure, clean up safely, rotate keys and pin your AI dependencies.
- MCP Stateless Update: Migrate Your Server to 2026-07-28MCP's 2026-07-28 spec drops sessions and the initialize handshake. What breaks in your MCP server, and how to migrate a TypeScript server step by step.
- Agent Skills Guide: Write a SKILL.md That Works EverywhereWhat agent skills are, how to write a SKILL.md that loads in Claude Code, Copilot, Cursor and Codex, and how to install shared skills safely.
Comments
Share a question or note about this article.
Loading comments…