From d3fc0cca0f91da69864b78082ac4d260349554db Mon Sep 17 00:00:00 2001 From: memosr Date: Thu, 9 Apr 2026 19:54:41 +0300 Subject: [PATCH] fix(security): escape OAuth error parameter in callback HTML to prevent reflected XSS --- tests/tools/test_mcp_oauth.py | 23 +++++++++++++++++++++++ tools/mcp_oauth.py | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py index 2eda549f3f..faa4ebc3a0 100644 --- a/tests/tools/test_mcp_oauth.py +++ b/tests/tools/test_mcp_oauth.py @@ -5,6 +5,7 @@ import stat import sys from io import BytesIO from unittest.mock import patch, MagicMock +from urllib.parse import quote import pytest @@ -498,6 +499,28 @@ class TestCallbackHandlerIsolation: assert result["error"] == "access_denied" +class TestCallbackHandlerErrorEscaping: + """Regression: a hostile ``error`` parameter must be HTML-escaped before + being reflected into the callback response body (reflected XSS).""" + + def test_hostile_error_is_escaped_in_response_body(self): + HandlerClass, result = _make_callback_handler() + + handler = HandlerClass.__new__(HandlerClass) + handler.path = "/callback?error=" + quote("") + handler.wfile = BytesIO() + handler.send_response = MagicMock() + handler.send_header = MagicMock() + handler.end_headers = MagicMock() + handler.do_GET() + + body = handler.wfile.getvalue().decode("utf-8") + assert "" + + # --------------------------------------------------------------------------- # TOCTOU port reservation (#22161) # --------------------------------------------------------------------------- diff --git a/tools/mcp_oauth.py b/tools/mcp_oauth.py index 37141eb017..6f0b5ed34c 100644 --- a/tools/mcp_oauth.py +++ b/tools/mcp_oauth.py @@ -11,6 +11,7 @@ redirect_host, client_name, client_metadata_url, cimd, user_agent, timeout.""" import asyncio import contextlib import contextvars +import html import importlib.util as _importlib_util import json import logging @@ -473,7 +474,7 @@ def _make_callback_handler() -> tuple[type, dict]: parsed = _parse_redirect_query(urlparse(self.path).query) result.update(auth_code=parsed["code"], state=parsed["state"], error=parsed["error"], iss=parsed["iss"]) body = ("

Authorization Successful

You can close this tab and return to Hermes.

" if parsed["code"] - else f"

Authorization Failed

Error: {parsed['error'] or 'unknown'}

") + else f"

Authorization Failed

Error: {html.escape(parsed['error'] or 'unknown')}

") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers()