Skip to content

Commit 0a7669e

Browse files
committed
Validate MCP tool server_url to block SSRF to private addresses
Fixes #6287
1 parent f139a67 commit 0a7669e

2 files changed

Lines changed: 101 additions & 0 deletions

File tree

src/ogx/providers/inline/responses/builtin/responses/streaming.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from opentelemetry import trace
1515

1616
from ogx.log import get_logger
17+
from ogx.providers.utils.common.url_validation import validate_url_not_private
1718
from ogx.providers.utils.inference.openai_compat import convert_tooldef_to_openai_tool
1819
from ogx.providers.utils.inference.prompt_adapter import interleaved_content_as_str
1920
from ogx.providers.utils.tools.mcp import list_mcp_tools
@@ -1732,6 +1733,11 @@ async def _process_mcp_tool(
17321733
f"Failed to list MCP tools for server '{mcp_tool.server_label}': server_url is not set"
17331734
)
17341735

1736+
# Security: reject MCP server URLs that resolve to private/loopback addresses to
1737+
# prevent the server from being used as an SSRF proxy against internal services
1738+
# or cloud metadata endpoints (see #6287).
1739+
validate_url_not_private(mcp_tool.server_url)
1740+
17351741
attributes = {
17361742
"server_label": mcp_tool.server_label,
17371743
"server_url": mcp_tool.server_url,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Copyright (c) The OGX Contributors.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
"""Regression tests for the MCP tool ``server_url`` SSRF guard.
8+
9+
An unauthenticated caller could point an MCP tool's ``server_url`` at an internal
10+
address (loopback, RFC1918, link-local, or cloud metadata) and have the server
11+
connect to it and forward attacker-supplied headers/tokens. The server must reject
12+
such URLs before opening a connection.
13+
14+
See: https://github.com/ogx-ai/ogx/issues/6287
15+
"""
16+
17+
from unittest.mock import AsyncMock, MagicMock, patch
18+
19+
import pytest
20+
21+
from ogx.providers.inline.responses.builtin.responses import streaming
22+
from ogx.providers.inline.responses.builtin.responses.streaming import StreamingResponseOrchestrator
23+
from ogx.providers.inline.responses.builtin.responses.types import ChatCompletionContext, ToolContext
24+
from ogx_api import OpenAIResponseInputToolMCP
25+
26+
27+
def _make_mcp_server(**kwargs) -> OpenAIResponseInputToolMCP:
28+
defaults = {"server_label": "test-server", "server_url": "http://localhost:9999/mcp"}
29+
defaults.update(kwargs)
30+
return OpenAIResponseInputToolMCP(**defaults)
31+
32+
33+
def _build_orchestrator() -> StreamingResponseOrchestrator:
34+
mock_ctx = MagicMock(spec=ChatCompletionContext)
35+
mock_ctx.tool_context = MagicMock(spec=ToolContext)
36+
mock_ctx.tool_context.previous_tools = {}
37+
mock_ctx.model = "test-model"
38+
mock_ctx.messages = []
39+
mock_ctx.temperature = None
40+
mock_ctx.top_p = None
41+
mock_ctx.frequency_penalty = None
42+
mock_ctx.response_format = MagicMock()
43+
mock_ctx.tool_choice = None
44+
mock_ctx.response_tools = []
45+
mock_ctx.approval_response = MagicMock(return_value=None)
46+
47+
return StreamingResponseOrchestrator(
48+
inference_api=AsyncMock(),
49+
ctx=mock_ctx,
50+
response_id="resp_test",
51+
created_at=0,
52+
text=MagicMock(),
53+
max_infer_iters=1,
54+
tool_executor=MagicMock(),
55+
instructions=None,
56+
moderation_endpoint=None,
57+
)
58+
59+
60+
class TestMcpServerUrlSsrfGuard:
61+
"""MCP tool ``server_url`` must be validated to prevent SSRF (issue #6287)."""
62+
63+
async def test_private_server_url_rejected_before_connection(self):
64+
orch = _build_orchestrator()
65+
mcp_tool = _make_mcp_server(server_url="http://169.254.169.254/latest/meta-data/")
66+
67+
with patch.object(streaming, "list_mcp_tools", new_callable=AsyncMock) as mock_list:
68+
with pytest.raises(ValueError, match="private"):
69+
async for _ in orch._process_mcp_tool(mcp_tool, ["seed"]):
70+
pass
71+
72+
# The connection must never be opened for a blocked URL.
73+
mock_list.assert_not_called()
74+
75+
async def test_loopback_server_url_rejected(self):
76+
orch = _build_orchestrator()
77+
mcp_tool = _make_mcp_server(server_url="http://127.0.0.1:19877/mcp")
78+
79+
with patch.object(streaming, "list_mcp_tools", new_callable=AsyncMock) as mock_list:
80+
with pytest.raises(ValueError, match="private"):
81+
async for _ in orch._process_mcp_tool(mcp_tool, ["seed"]):
82+
pass
83+
84+
mock_list.assert_not_called()
85+
86+
async def test_public_server_url_still_allowed(self):
87+
orch = _build_orchestrator()
88+
mcp_tool = _make_mcp_server(server_url="http://8.8.8.8/mcp")
89+
90+
with patch.object(streaming, "list_mcp_tools", new_callable=AsyncMock) as mock_list:
91+
async for _ in orch._process_mcp_tool(mcp_tool, ["seed"]):
92+
pass
93+
94+
mock_list.assert_awaited_once()
95+
assert mock_list.await_args.kwargs["endpoint"] == "http://8.8.8.8/mcp"

0 commit comments

Comments
 (0)