Fix dev apps launch escaping (#4399)

This commit is contained in:
Jeremiah Lowin 2026-06-27 12:18:36 -04:00 committed by GitHub
commit a1dd8b12e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 90 additions and 6 deletions

View file

@ -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",

View file

@ -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 = "</title><script>alert(1)</script><img src=x onerror=alert(2)>"
response = client.get("/launch", params={"tool": payload, "args": "{}"})
assert response.status_code == 200
assert payload not in response.text
assert (
"&lt;/title&gt;&lt;script&gt;alert(1)&lt;/script&gt;"
"&lt;img src=x onerror=alert(2)&gt;"
) 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": "</script><script>alert(1)</script>&"}
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=<script>",
"__json_args__": '{"value": "</script>"}',
},
)
assert response.status_code == 200
url = response.json()
query = parse_qs(urlsplit(url).query)
assert query["tool"] == ["tool&name=<script>"]
assert json.loads(query["args"][0]) == {"value": "</script>"}
@pytest.mark.parametrize(
"host, expected_host",
[