From 6c2d2bc7705de65675bab927a4c1245c2ce220f0 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 8 May 2026 11:10:06 +0100 Subject: [PATCH 1/4] Python: Fix HttpPlugin SSRF vulnerabilities (issues 115048, 115285) - Deny-all by default: add allow_all_domains flag requiring explicit opt-in - Block redirects when allowed_domains is set to prevent domain bypass - Add URL scheme validation (http/https only) - Fix empty hostname bypass and redirect logic edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core_plugins/http_plugin.py | 68 ++++++-- .../unit/core_plugins/test_http_plugin.py | 160 ++++++++++++++++-- 2 files changed, 199 insertions(+), 29 deletions(-) diff --git a/python/semantic_kernel/core_plugins/http_plugin.py b/python/semantic_kernel/core_plugins/http_plugin.py index 40269a5c49c8..94e3178a6cf9 100644 --- a/python/semantic_kernel/core_plugins/http_plugin.py +++ b/python/semantic_kernel/core_plugins/http_plugin.py @@ -15,23 +15,36 @@ class HttpPlugin(KernelBaseModel): """A plugin that provides HTTP functionality. Usage: - kernel.add_plugin(HttpPlugin(), "http") - - # With allowed domains for security: + # With allowed domains (recommended): kernel.add_plugin(HttpPlugin(allowed_domains=["example.com", "api.example.com"]), "http") + # Explicitly allow all domains (opt-in, less secure): + kernel.add_plugin(HttpPlugin(allow_all_domains=True), "http") + Examples: {{http.getAsync $url}} {{http.postAsync $url}} {{http.putAsync $url}} {{http.deleteAsync $url}} + + Security: + - By default, all requests are blocked unless ``allowed_domains`` is provided + or ``allow_all_domains`` is set to True. + - When ``allowed_domains`` is set, HTTP redirects are disabled to prevent + redirect-based domain bypass (SSRF). + - Only ``http`` and ``https`` URL schemes are permitted. """ allowed_domains: set[str] | None = None - """List of allowed domains to send requests to. If None, all domains are allowed.""" + """Set of allowed domains to send requests to.""" + + allow_all_domains: bool = False + """When True, requests to any domain are allowed. Must be explicitly set.""" + + _ALLOWED_SCHEMES: frozenset[str] = frozenset({"http", "https"}) def _is_uri_allowed(self, url: str) -> bool: - """Check if the URL's host is in the allowed domains list. + """Check if the URL's host and scheme are permitted. Args: url: The URL to check. @@ -39,25 +52,36 @@ def _is_uri_allowed(self, url: str) -> bool: Returns: True if the URL is allowed, False otherwise. """ - if self.allowed_domains is None: - return True - parsed = urlparse(url) + + # Validate scheme + if parsed.scheme.lower() not in self._ALLOWED_SCHEMES: + return False + host = parsed.hostname - if host is None: + if not host: return False - # Case-insensitive comparison - return host.lower() in {domain.lower() for domain in self.allowed_domains} + # If allow_all_domains is set, skip domain check + if self.allow_all_domains: + return True + + # If allowed_domains is set, check against it + if self.allowed_domains is not None: + return host.lower() in {domain.lower() for domain in self.allowed_domains} + + # Default: deny all + return False def _validate_url(self, url: str) -> None: - """Validate the URL, checking if it's not empty and is in the allowed domains. + """Validate the URL, checking scheme, emptiness, and allowed domains. Args: url: The URL to validate. Raises: - FunctionExecutionException: If the URL is empty or not in the allowed domains. + FunctionExecutionException: If the URL is empty, uses a disallowed scheme, + or targets a domain that is not allowed. """ if not url: raise FunctionExecutionException("url cannot be `None` or empty") @@ -77,7 +101,11 @@ async def get(self, url: Annotated[str, "The URL to send the request to."]) -> s """ self._validate_url(url) - async with aiohttp.ClientSession() as session, session.get(url, raise_for_status=True) as response: + allow_redirects = self.allow_all_domains or self.allowed_domains is None + async with ( + aiohttp.ClientSession() as session, + session.get(url, raise_for_status=True, allow_redirects=allow_redirects) as response, + ): return await response.text() @kernel_function(description="Makes a POST request to a uri", name="postAsync") @@ -98,9 +126,10 @@ async def post( headers = {"Content-Type": "application/json"} data = json.dumps(body) if body is not None else None + allow_redirects = self.allow_all_domains or self.allowed_domains is None async with ( aiohttp.ClientSession() as session, - session.post(url, headers=headers, data=data, raise_for_status=True) as response, + session.post(url, headers=headers, data=data, raise_for_status=True, allow_redirects=allow_redirects) as response, ): return await response.text() @@ -123,9 +152,10 @@ async def put( headers = {"Content-Type": "application/json"} data = json.dumps(body) if body is not None else None + allow_redirects = self.allow_all_domains or self.allowed_domains is None async with ( aiohttp.ClientSession() as session, - session.put(url, headers=headers, data=data, raise_for_status=True) as response, + session.put(url, headers=headers, data=data, raise_for_status=True, allow_redirects=allow_redirects) as response, ): return await response.text() @@ -141,5 +171,9 @@ async def delete(self, url: Annotated[str, "The URI to send the request to."]) - """ self._validate_url(url) - async with aiohttp.ClientSession() as session, session.delete(url, raise_for_status=True) as response: + allow_redirects = self.allow_all_domains or self.allowed_domains is None + async with ( + aiohttp.ClientSession() as session, + session.delete(url, raise_for_status=True, allow_redirects=allow_redirects) as response, + ): return await response.text() diff --git a/python/tests/unit/core_plugins/test_http_plugin.py b/python/tests/unit/core_plugins/test_http_plugin.py index 4472211c76c7..971d2e7a8b31 100644 --- a/python/tests/unit/core_plugins/test_http_plugin.py +++ b/python/tests/unit/core_plugins/test_http_plugin.py @@ -11,7 +11,7 @@ async def test_it_can_be_instantiated(): - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) assert plugin is not None @@ -23,7 +23,7 @@ async def test_it_can_be_instantiated_with_allowed_domains(): async def test_it_can_be_imported(): kernel = Kernel() - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) kernel.add_plugin(plugin, "http") assert kernel.get_plugin(plugin_name="http") is not None assert kernel.get_plugin(plugin_name="http").name == "http" @@ -36,20 +36,20 @@ async def test_get(mock_get): mock_get.return_value.__aenter__.return_value.text.return_value = "Hello" mock_get.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) response = await plugin.get("https://example.org/get") assert response == "Hello" @pytest.mark.parametrize("method", ["get", "post", "put", "delete"]) async def test_fail_no_url(method): - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) with pytest.raises(FunctionExecutionException): await getattr(plugin, method)(url="") async def test_get_none_url(): - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) with pytest.raises(FunctionExecutionException): await plugin.get(None) @@ -59,7 +59,7 @@ async def test_post(mock_post): mock_post.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_post.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/post", body="{message: 'Hello, world!'}") response = await plugin.post(**arguments) assert response == "Hello World !" @@ -70,7 +70,7 @@ async def test_post_nobody(mock_post): mock_post.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_post.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/post") response = await plugin.post(**arguments) assert response == "Hello World !" @@ -81,7 +81,7 @@ async def test_put(mock_put): mock_put.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_put.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/put", body="{message: 'Hello, world!'}") response = await plugin.put(**arguments) assert response == "Hello World !" @@ -92,7 +92,7 @@ async def test_put_nobody(mock_put): mock_put.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_put.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/put") response = await plugin.put(**arguments) assert response == "Hello World !" @@ -103,7 +103,7 @@ async def test_delete(mock_delete): mock_delete.return_value.__aenter__.return_value.text.return_value = "Hello World !" mock_delete.return_value.__aenter__.return_value.status = 200 - plugin = HttpPlugin() + plugin = HttpPlugin(allow_all_domains=True) arguments = KernelArguments(url="https://example.org/delete") response = await plugin.delete(**arguments) assert response == "Hello World !" @@ -179,8 +179,8 @@ async def test_allowed_domains_case_insensitive(): async def test_allowed_domains_none_allows_all(): - """Test that when allowed_domains is None, all domains are allowed.""" - plugin = HttpPlugin() # allowed_domains defaults to None + """Test that when allow_all_domains is True, all domains are allowed.""" + plugin = HttpPlugin(allow_all_domains=True) assert plugin._is_uri_allowed("https://any-domain.com/path") is True assert plugin._is_uri_allowed("https://another-domain.org/path") is True @@ -214,3 +214,139 @@ async def test_allowed_domains_exact_subdomain_match(): assert plugin._is_uri_allowed("https://sub.example.com/path") is True assert plugin._is_uri_allowed("https://example.com/path") is False assert plugin._is_uri_allowed("https://other.example.com/path") is False + + +# Security regression tests + + +async def test_default_constructor_denies_all(): + """Test that default HttpPlugin() denies all requests (issue 115285).""" + plugin = HttpPlugin() + assert plugin._is_uri_allowed("https://example.com/path") is False + assert plugin._is_uri_allowed("https://any-domain.com/path") is False + + +@pytest.mark.parametrize("method", ["get", "post", "put", "delete"]) +async def test_default_constructor_blocks_requests(method): + """Test that default HttpPlugin() blocks all HTTP methods (issue 115285).""" + plugin = HttpPlugin() + with pytest.raises(FunctionExecutionException, match="Sending requests to the provided location is not allowed"): + if method in ["post", "put"]: + await getattr(plugin, method)(url="https://example.com/path", body={"key": "value"}) + else: + await getattr(plugin, method)(url="https://example.com/path") + + +@patch("aiohttp.ClientSession.get") +async def test_allow_all_domains_flag(mock_get): + """Test that allow_all_domains=True permits requests to any domain.""" + mock_get.return_value.__aenter__.return_value.text.return_value = "OK" + mock_get.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + response = await plugin.get("https://any-domain.com/path") + assert response == "OK" + + +@patch("aiohttp.ClientSession.get") +async def test_redirects_disabled_with_allowed_domains(mock_get): + """Test that redirects are disabled when allowed_domains is set (issue 115048).""" + mock_get.return_value.__aenter__.return_value.text.return_value = "OK" + mock_get.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.get("https://example.com/path") + + _, kwargs = mock_get.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.post") +async def test_redirects_disabled_for_post_with_allowed_domains(mock_post): + """Test that redirects are disabled for POST when allowed_domains is set.""" + mock_post.return_value.__aenter__.return_value.text.return_value = "OK" + mock_post.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.post("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_post.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.put") +async def test_redirects_disabled_for_put_with_allowed_domains(mock_put): + """Test that redirects are disabled for PUT when allowed_domains is set.""" + mock_put.return_value.__aenter__.return_value.text.return_value = "OK" + mock_put.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.put("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_put.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.delete") +async def test_redirects_disabled_for_delete_with_allowed_domains(mock_delete): + """Test that redirects are disabled for DELETE when allowed_domains is set.""" + mock_delete.return_value.__aenter__.return_value.text.return_value = "OK" + mock_delete.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allowed_domains={"example.com"}) + await plugin.delete("https://example.com/path") + + _, kwargs = mock_delete.call_args + assert kwargs["allow_redirects"] is False + + +@patch("aiohttp.ClientSession.get") +async def test_redirects_allowed_with_allow_all_domains(mock_get): + """Test that redirects are still allowed when allow_all_domains is True.""" + mock_get.return_value.__aenter__.return_value.text.return_value = "OK" + mock_get.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.get("https://example.com/path") + + _, kwargs = mock_get.call_args + assert kwargs["allow_redirects"] is True + + +@pytest.mark.parametrize("scheme", ["file", "ftp", "gopher", "data"]) +async def test_disallowed_schemes_blocked(scheme): + """Test that non-HTTP schemes are blocked.""" + plugin = HttpPlugin(allow_all_domains=True) + assert plugin._is_uri_allowed(f"{scheme}://example.com/path") is False + + +@pytest.mark.parametrize("scheme", ["file", "ftp", "gopher"]) +@pytest.mark.parametrize("method", ["get", "post", "put", "delete"]) +async def test_disallowed_schemes_blocked_all_methods(scheme, method): + """Test that non-HTTP schemes are blocked for all HTTP methods.""" + plugin = HttpPlugin(allow_all_domains=True) + with pytest.raises(FunctionExecutionException, match="Sending requests to the provided location is not allowed"): + if method in ["post", "put"]: + await getattr(plugin, method)(url=f"{scheme}://example.com/path", body={"key": "value"}) + else: + await getattr(plugin, method)(url=f"{scheme}://example.com/path") + + +async def test_http_scheme_allowed(): + """Test that both http and https schemes are allowed.""" + plugin = HttpPlugin(allow_all_domains=True) + assert plugin._is_uri_allowed("http://example.com/path") is True + assert plugin._is_uri_allowed("https://example.com/path") is True + + +async def test_empty_hostname_rejected(): + """Test that URLs with empty hostnames are rejected.""" + plugin = HttpPlugin(allow_all_domains=True) + assert plugin._is_uri_allowed("http://") is False + assert plugin._is_uri_allowed("https://") is False + + +async def test_allow_all_domains_with_allowed_domains_allows_redirects(): + """Test that redirects are allowed when both allow_all_domains and allowed_domains are set.""" + plugin = HttpPlugin(allowed_domains={"example.com"}, allow_all_domains=True) + assert plugin._is_uri_allowed("https://any-domain.com/path") is True From 6c581b0ce683186c467284c74d7e2c57f497c154 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 11 May 2026 11:42:12 +0100 Subject: [PATCH 2/4] fix(python): pin azure-search-documents < 12.0.0 and onnxruntime < 1.26.0 azure-search-documents 12.0.0 removed the internal _endpoint attribute from SearchIndexClient, breaking AzureAISearchCollection initialization. onnxruntime 1.26.0 does not ship a macOS ARM64 wheel, breaking CI on macos-latest runners. See: https://github.com/microsoft/onnxruntime/issues/28441 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/pyproject.toml | 6 ++++-- python/uv.lock | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index c3044451cb76..3383b6db5f1d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -73,7 +73,7 @@ aws = [ azure = [ "azure-ai-inference >= 1.0.0b6", "azure-core-tracing-opentelemetry >= 1.0.0b11", - "azure-search-documents >= 11.6.0b4", + "azure-search-documents >= 11.6.0b4, < 12.0.0", "azure-cosmos ~= 4.7" ] chroma = [ @@ -118,7 +118,9 @@ ollama = [ onnx = [ # onnxruntime>=1.24.0 dropped Python 3.10 support; pin to last compatible version for 3.10. "onnxruntime==1.22.1; python_version == '3.10'", - "onnxruntime>=1.24.3; python_version > '3.10'", + # onnxruntime 1.26.0 has no macOS ARM64 wheel; pin to last compatible version. + # https://github.com/microsoft/onnxruntime/issues/28441 + "onnxruntime>=1.24.3, <1.26.0; python_version > '3.10'", "onnxruntime-genai==0.9.0" ] oracledb = [ diff --git a/python/uv.lock b/python/uv.lock index 430794738a60..d1093258d222 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -6480,7 +6480,7 @@ requires-dist = [ { name = "azure-core-tracing-opentelemetry", marker = "extra == 'azure'", specifier = ">=1.0.0b11" }, { name = "azure-cosmos", marker = "extra == 'azure'", specifier = "~=4.7" }, { name = "azure-identity", specifier = ">=1.13" }, - { name = "azure-search-documents", marker = "extra == 'azure'", specifier = ">=11.6.0b4" }, + { name = "azure-search-documents", marker = "extra == 'azure'", specifier = ">=11.6.0b4,<12.0.0" }, { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.36.4,<1.41.0" }, { name = "chromadb", marker = "extra == 'chroma'", specifier = ">=0.5,<1.4" }, { name = "cloudevents", specifier = "~=1.0" }, @@ -6502,7 +6502,7 @@ requires-dist = [ { name = "numpy", marker = "python_full_version >= '3.12'", specifier = ">=1.26.0" }, { name = "ollama", marker = "extra == 'ollama'", specifier = "~=0.4" }, { name = "onnxruntime", marker = "python_full_version == '3.10.*' and extra == 'onnx'", specifier = "==1.22.1" }, - { name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'onnx'", specifier = ">=1.24.3" }, + { name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'onnx'", specifier = ">=1.24.3,<1.26.0" }, { name = "onnxruntime-genai", marker = "extra == 'onnx'", specifier = "==0.9.0" }, { name = "openai", specifier = ">=2.0.0" }, { name = "openapi-core", specifier = ">=0.18,<0.20" }, From 8aeb1f69a2d210110c7e3a301cd95820d1c58cad Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 11 May 2026 12:16:06 +0100 Subject: [PATCH 3/4] refactor(python): address PR review comments for HttpPlugin - Update Security docstring to clarify redirect behavior when allow_all_domains=True is set alongside allowed_domains. - Centralize allow_redirects logic into _allow_redirects property, removing vestigial 'allowed_domains is None' clause. - Rename test_allowed_domains_none_allows_all to test_allow_all_domains_allows_all to match actual behavior. - Add redirect-allowed tests for POST, PUT, and DELETE methods with allow_all_domains=True for symmetric coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core_plugins/http_plugin.py | 28 ++++++++----- .../unit/core_plugins/test_http_plugin.py | 41 ++++++++++++++++++- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/python/semantic_kernel/core_plugins/http_plugin.py b/python/semantic_kernel/core_plugins/http_plugin.py index 94e3178a6cf9..8082d8435500 100644 --- a/python/semantic_kernel/core_plugins/http_plugin.py +++ b/python/semantic_kernel/core_plugins/http_plugin.py @@ -30,8 +30,10 @@ class HttpPlugin(KernelBaseModel): Security: - By default, all requests are blocked unless ``allowed_domains`` is provided or ``allow_all_domains`` is set to True. - - When ``allowed_domains`` is set, HTTP redirects are disabled to prevent - redirect-based domain bypass (SSRF). + - When ``allowed_domains`` is set and ``allow_all_domains`` is False, HTTP + redirects are disabled to prevent redirect-based domain bypass (SSRF). + - When ``allow_all_domains`` is True, redirects are allowed regardless of + whether ``allowed_domains`` is also set. - Only ``http`` and ``https`` URL schemes are permitted. """ @@ -43,6 +45,16 @@ class HttpPlugin(KernelBaseModel): _ALLOWED_SCHEMES: frozenset[str] = frozenset({"http", "https"}) + @property + def _allow_redirects(self) -> bool: + """Whether HTTP redirects should be followed. + + Redirects are only allowed when ``allow_all_domains`` is True. + When domain restrictions are configured, redirects are disabled + to prevent redirect-based SSRF bypass. + """ + return self.allow_all_domains + def _is_uri_allowed(self, url: str) -> bool: """Check if the URL's host and scheme are permitted. @@ -101,10 +113,9 @@ async def get(self, url: Annotated[str, "The URL to send the request to."]) -> s """ self._validate_url(url) - allow_redirects = self.allow_all_domains or self.allowed_domains is None async with ( aiohttp.ClientSession() as session, - session.get(url, raise_for_status=True, allow_redirects=allow_redirects) as response, + session.get(url, raise_for_status=True, allow_redirects=self._allow_redirects) as response, ): return await response.text() @@ -126,10 +137,9 @@ async def post( headers = {"Content-Type": "application/json"} data = json.dumps(body) if body is not None else None - allow_redirects = self.allow_all_domains or self.allowed_domains is None async with ( aiohttp.ClientSession() as session, - session.post(url, headers=headers, data=data, raise_for_status=True, allow_redirects=allow_redirects) as response, + session.post(url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects) as response, ): return await response.text() @@ -152,10 +162,9 @@ async def put( headers = {"Content-Type": "application/json"} data = json.dumps(body) if body is not None else None - allow_redirects = self.allow_all_domains or self.allowed_domains is None async with ( aiohttp.ClientSession() as session, - session.put(url, headers=headers, data=data, raise_for_status=True, allow_redirects=allow_redirects) as response, + session.put(url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects) as response, ): return await response.text() @@ -171,9 +180,8 @@ async def delete(self, url: Annotated[str, "The URI to send the request to."]) - """ self._validate_url(url) - allow_redirects = self.allow_all_domains or self.allowed_domains is None async with ( aiohttp.ClientSession() as session, - session.delete(url, raise_for_status=True, allow_redirects=allow_redirects) as response, + session.delete(url, raise_for_status=True, allow_redirects=self._allow_redirects) as response, ): return await response.text() diff --git a/python/tests/unit/core_plugins/test_http_plugin.py b/python/tests/unit/core_plugins/test_http_plugin.py index 971d2e7a8b31..216967ffaee9 100644 --- a/python/tests/unit/core_plugins/test_http_plugin.py +++ b/python/tests/unit/core_plugins/test_http_plugin.py @@ -178,7 +178,7 @@ async def test_allowed_domains_case_insensitive(): assert plugin._is_uri_allowed("https://Example.Com/path") is True -async def test_allowed_domains_none_allows_all(): +async def test_allow_all_domains_allows_all(): """Test that when allow_all_domains is True, all domains are allowed.""" plugin = HttpPlugin(allow_all_domains=True) assert plugin._is_uri_allowed("https://any-domain.com/path") is True @@ -313,6 +313,45 @@ async def test_redirects_allowed_with_allow_all_domains(mock_get): assert kwargs["allow_redirects"] is True +@patch("aiohttp.ClientSession.post") +async def test_redirects_allowed_for_post_with_allow_all_domains(mock_post): + """Test that redirects are allowed for POST when allow_all_domains is True.""" + mock_post.return_value.__aenter__.return_value.text.return_value = "OK" + mock_post.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.post("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_post.call_args + assert kwargs["allow_redirects"] is True + + +@patch("aiohttp.ClientSession.put") +async def test_redirects_allowed_for_put_with_allow_all_domains(mock_put): + """Test that redirects are allowed for PUT when allow_all_domains is True.""" + mock_put.return_value.__aenter__.return_value.text.return_value = "OK" + mock_put.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.put("https://example.com/path", body={"key": "value"}) + + _, kwargs = mock_put.call_args + assert kwargs["allow_redirects"] is True + + +@patch("aiohttp.ClientSession.delete") +async def test_redirects_allowed_for_delete_with_allow_all_domains(mock_delete): + """Test that redirects are allowed for DELETE when allow_all_domains is True.""" + mock_delete.return_value.__aenter__.return_value.text.return_value = "OK" + mock_delete.return_value.__aenter__.return_value.status = 200 + + plugin = HttpPlugin(allow_all_domains=True) + await plugin.delete("https://example.com/path") + + _, kwargs = mock_delete.call_args + assert kwargs["allow_redirects"] is True + + @pytest.mark.parametrize("scheme", ["file", "ftp", "gopher", "data"]) async def test_disallowed_schemes_blocked(scheme): """Test that non-HTTP schemes are blocked.""" From f69bcbbb218429fe73b95a38b7da27c28473c2af Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 11 May 2026 12:38:46 +0100 Subject: [PATCH 4/4] style(python): fix line length violations in HttpPlugin Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/semantic_kernel/core_plugins/http_plugin.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/core_plugins/http_plugin.py b/python/semantic_kernel/core_plugins/http_plugin.py index 8082d8435500..8a554410fb44 100644 --- a/python/semantic_kernel/core_plugins/http_plugin.py +++ b/python/semantic_kernel/core_plugins/http_plugin.py @@ -139,7 +139,9 @@ async def post( data = json.dumps(body) if body is not None else None async with ( aiohttp.ClientSession() as session, - session.post(url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects) as response, + session.post( + url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects + ) as response, ): return await response.text() @@ -164,7 +166,9 @@ async def put( data = json.dumps(body) if body is not None else None async with ( aiohttp.ClientSession() as session, - session.put(url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects) as response, + session.put( + url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects + ) as response, ): return await response.text()