From a1dd8b12e1ddd00c99c2cfdc532416ccc42f35e9 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:18:36 -0400 Subject: [PATCH] Fix dev apps launch escaping (#4399) --- fastmcp_slim/fastmcp/cli/apps_dev.py | 24 +++++++--- tests/cli/test_run.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/fastmcp_slim/fastmcp/cli/apps_dev.py b/fastmcp_slim/fastmcp/cli/apps_dev.py index cefdd2296..b4074b14b 100644 --- a/fastmcp_slim/fastmcp/cli/apps_dev.py +++ b/fastmcp_slim/fastmcp/cli/apps_dev.py @@ -26,6 +26,7 @@ from __future__ import annotations import asyncio import contextlib +import html import io import json import logging @@ -39,7 +40,7 @@ import time import webbrowser from pathlib import Path from typing import Any -from urllib.parse import quote +from urllib.parse import urlencode import httpcore import httpx @@ -54,6 +55,18 @@ from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) +def _json_for_script(value: Any) -> str: + """Serialize JSON for embedding inside an HTML script element.""" + return ( + json.dumps(value) + .replace("&", "\\u0026") + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029") + ) + + # --------------------------------------------------------------------------- # MCP message log (captures proxy traffic for the dev UI log panel) # --------------------------------------------------------------------------- @@ -1415,10 +1428,10 @@ def _make_dev_app( args_raw = request.query_params.get("args", "{}") tool_args = json.loads(args_raw) host_html = _HOST_HTML_TEMPLATE.format( - tool_name=tool, + tool_name=html.escape(tool, quote=True), import_map_tag=import_map_tag, - tool_name_json=json.dumps(tool), - tool_args_json=json.dumps(tool_args), + tool_name_json=_json_for_script(tool), + tool_args_json=_json_for_script(tool_args), mcp_sdk_version=_MCP_SDK_VERSION, ) return ( @@ -1478,8 +1491,7 @@ def _make_dev_app( except (json.JSONDecodeError, TypeError): pass tool_args[k] = v - args_json = quote(json.dumps(tool_args)) - url = f"/launch?tool={tool}&args={args_json}" + url = "/launch?" + urlencode({"tool": tool, "args": json.dumps(tool_args)}) return Response( content=json.dumps(url), media_type="application/json", diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index 44d180790..bc8487a0e 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -5,6 +5,7 @@ import subprocess import sys from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import parse_qs, urlsplit import pytest from pydantic import ValidationError @@ -1034,6 +1035,77 @@ class TestInspectorModuleMode: class TestRunDevApps: """Test running dev apps with the run command.""" + def test_launch_escapes_tool_name_in_html_contexts(self): + """Test /launch escapes the tool query parameter in HTML sinks.""" + starlette_app = _make_dev_app( + mcp_url="http://127.0.0.1:8000/mcp", + app_bridge_js="// js", + import_map_tag="", + message_log=_MessageLog(), + log_panel=False, + ) + client = TestClient(starlette_app, raise_server_exceptions=False) + + payload = "" + response = client.get("/launch", params={"tool": payload, "args": "{}"}) + + assert response.status_code == 200 + assert payload not in response.text + assert ( + "</title><script>alert(1)</script>" + "<img src=x onerror=alert(2)>" + ) in response.text + assert "\\u003c/script\\u003e" in response.text + + def test_launch_serializes_args_safely_inside_script(self): + """Test /launch escapes argument values embedded in the script element.""" + starlette_app = _make_dev_app( + mcp_url="http://127.0.0.1:8000/mcp", + app_bridge_js="// js", + import_map_tag="", + message_log=_MessageLog(), + log_panel=False, + ) + client = TestClient(starlette_app, raise_server_exceptions=False) + + payload = {"name": "&"} + response = client.get( + "/launch", + params={"tool": "safe_tool", "args": json.dumps(payload)}, + ) + + assert response.status_code == 200 + assert json.dumps(payload) not in response.text + assert ( + '"\\u003c/script\\u003e\\u003cscript\\u003ealert(1)' + '\\u003c/script\\u003e\\u0026"' + ) in response.text + + def test_api_launch_encodes_generated_launch_url(self): + """Test /api/launch encodes query parameters in the returned URL.""" + starlette_app = _make_dev_app( + mcp_url="http://127.0.0.1:8000/mcp", + app_bridge_js="// js", + import_map_tag="", + message_log=_MessageLog(), + log_panel=False, + ) + client = TestClient(starlette_app, raise_server_exceptions=False) + + response = client.post( + "/api/launch", + json={ + "tool": "tool&name="}', + }, + ) + + assert response.status_code == 200 + url = response.json() + query = parse_qs(urlsplit(url).query) + assert query["tool"] == ["tool&name="} + @pytest.mark.parametrize( "host, expected_host", [