mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 14:04:18 +02:00
Fix dev apps launch escaping (#4399)
This commit is contained in:
parent
6b2a2d507a
commit
a1dd8b12e1
2 changed files with 90 additions and 6 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
"</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": "</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",
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue