From ae0d6ee21466bb0b8799c80619a6977f3fb285f2 Mon Sep 17 00:00:00 2001 From: Snakinya <70094531+Snakinya@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:43:57 +0800 Subject: [PATCH] fix: SSRF bypass via raw httpx.get in API tool schema fetch (#37746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 归青 Co-authored-by: QuantumGhost --- api/core/tools/utils/parser.py | 4 +-- .../tools/api_tools_manage_service.py | 4 +-- .../core/tools/utils/test_parser.py | 4 +-- .../tools/test_api_tools_manage_service.py | 31 +++++++++++++++++++ 4 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 api/tests/unit_tests/services/tools/test_api_tools_manage_service.py diff --git a/api/core/tools/utils/parser.py b/api/core/tools/utils/parser.py index c96ad9f2bca..ba2ec214761 100644 --- a/api/core/tools/utils/parser.py +++ b/api/core/tools/utils/parser.py @@ -5,10 +5,10 @@ from json import loads as json_loads from json.decoder import JSONDecodeError from typing import Any, TypedDict -import httpx from flask import has_request_context, request from yaml import YAMLError, safe_load +from core.helper import ssrf_proxy from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_bundle import ApiToolBundle from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolParameter @@ -376,7 +376,7 @@ class ApiBasedToolSchemaParser: raise ToolNotSupportedError("Only openapi is supported now.") # get openapi yaml - response = httpx.get( + response = ssrf_proxy.get( api_url, headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "}, timeout=5 ) diff --git a/api/services/tools/api_tools_manage_service.py b/api/services/tools/api_tools_manage_service.py index 032e6cba4d1..49cc1e078f0 100644 --- a/api/services/tools/api_tools_manage_service.py +++ b/api/services/tools/api_tools_manage_service.py @@ -2,12 +2,12 @@ import json import logging from typing import Any, Literal, TypedDict, cast -from httpx import get from pydantic import TypeAdapter from sqlalchemy import select from sqlalchemy.orm import sessionmaker from core.entities.provider_entities import ProviderConfig, ProviderConfigType +from core.helper import ssrf_proxy from core.tools.__base.tool_runtime import ToolRuntime from core.tools.custom_tool.provider import ApiToolProviderController from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity @@ -231,7 +231,7 @@ class ApiToolManageService: } try: - response = get(url, headers=headers, timeout=10) + response = ssrf_proxy.get(url, headers=headers, timeout=10) if response.status_code != 200: raise ValueError(f"Got status code {response.status_code}") schema = response.text diff --git a/api/tests/unit_tests/core/tools/utils/test_parser.py b/api/tests/unit_tests/core/tools/utils/test_parser.py index 99a90f3b675..e9822109481 100644 --- a/api/tests/unit_tests/core/tools/utils/test_parser.py +++ b/api/tests/unit_tests/core/tools/utils/test_parser.py @@ -327,7 +327,7 @@ def test_parse_openai_plugin_json_branches(app): def test_parse_openai_plugin_json_http_branches(app): with app.test_request_context(): response = type("Resp", (), {"status_code": 500, "text": "", "close": Mock()})() - with patch("core.tools.utils.parser.httpx.get", return_value=response): + with patch("core.tools.utils.parser.ssrf_proxy.get", return_value=response): with pytest.raises(ToolProviderNotFoundError, match="cannot get openapi yaml"): ApiBasedToolSchemaParser.parse_openai_plugin_json_to_tool_bundle( '{"api": {"url": "https://x", "type": "openapi"}}' @@ -335,7 +335,7 @@ def test_parse_openai_plugin_json_http_branches(app): response.close.assert_called_once() success_response = type("Resp", (), {"status_code": 200, "text": "openapi: 3.0.0", "close": Mock()})() - with patch("core.tools.utils.parser.httpx.get", return_value=success_response): + with patch("core.tools.utils.parser.ssrf_proxy.get", return_value=success_response): with patch( "core.tools.utils.parser.ApiBasedToolSchemaParser.parse_openapi_yaml_to_tool_bundle", return_value=["bundle"], diff --git a/api/tests/unit_tests/services/tools/test_api_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_api_tools_manage_service.py new file mode 100644 index 00000000000..83665beb03f --- /dev/null +++ b/api/tests/unit_tests/services/tools/test_api_tools_manage_service.py @@ -0,0 +1,31 @@ +from unittest.mock import Mock + +from services.tools.api_tools_manage_service import ApiToolManageService + + +def test_get_api_tool_provider_remote_schema_uses_ssrf_proxy_get(monkeypatch) -> None: + schema = """ + { + "openapi": "3.0.0", + "info": {"title": "Demo API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": {} + } + """ + url = "https://example.com/openapi.json" + expected_headers = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)" + " Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0", + "Accept": "*/*", + } + mock_get = Mock(return_value=Mock(status_code=200, text=schema)) + mock_parser = Mock() + + monkeypatch.setattr("services.tools.api_tools_manage_service.ssrf_proxy.get", mock_get) + monkeypatch.setattr(ApiToolManageService, "parser_api_schema", mock_parser) + + result = ApiToolManageService.get_api_tool_provider_remote_schema("user-1", "tenant-1", url) + + assert result == {"schema": schema} + mock_get.assert_called_once_with(url, headers=expected_headers, timeout=10) + mock_parser.assert_called_once_with(schema)