From 58f4a6685829db66243abca2f3fde1e7873a4371 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Mon, 18 May 2026 10:55:00 +0100
Subject: [PATCH 1/6] Python: Improve function call invocation parameter
consistency (#14014)
## Description
This PR improves the consistency of how the function_behavior parameter
is passed through internal invoke_function_call callsites.
### Changes
- Thread function_behavior parameter through all internal
invoke_function_call callsites for consistent parameter handling:
- Realtime connector (_open_ai_realtime.py)
- Responses agent (responses_agent_thread_actions.py)
- Assistant agent (assistant_thread_actions.py)
- Azure AI agent (agent_thread_actions.py)
- Bedrock agent (bedrock_agent.py)
- Add defensive logging in kernel.py when function_behavior is not
provided
- Add unit tests covering parameter handling scenarios
### Testing
- All existing tests pass (678 passed, 0 failed)
- 5 new tests added covering the updated parameter handling
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../agents/bedrock/bedrock_agent.py | 1 +
.../open_ai/responses_agent_thread_actions.py | 10 +-
.../ai/open_ai/services/_open_ai_realtime.py | 3 +-
python/semantic_kernel/kernel.py | 7 ++
.../open_ai/services/test_openai_realtime.py | 80 ++++++++++++++++
python/tests/unit/kernel/test_kernel.py | 95 +++++++++++++++++++
6 files changed, 193 insertions(+), 3 deletions(-)
diff --git a/python/semantic_kernel/agents/bedrock/bedrock_agent.py b/python/semantic_kernel/agents/bedrock/bedrock_agent.py
index 13d356af4e5b..e58476e28931 100644
--- a/python/semantic_kernel/agents/bedrock/bedrock_agent.py
+++ b/python/semantic_kernel/agents/bedrock/bedrock_agent.py
@@ -681,6 +681,7 @@ async def _handle_function_call_contents(
chat_history=chat_history,
arguments=self.arguments,
function_call_count=len(function_call_contents),
+ function_behavior=self.function_choice_behavior,
)
for function_call in function_call_contents
],
diff --git a/python/semantic_kernel/agents/open_ai/responses_agent_thread_actions.py b/python/semantic_kernel/agents/open_ai/responses_agent_thread_actions.py
index 1979a6cd38da..430c6118a331 100644
--- a/python/semantic_kernel/agents/open_ai/responses_agent_thread_actions.py
+++ b/python/semantic_kernel/agents/open_ai/responses_agent_thread_actions.py
@@ -1111,11 +1111,17 @@ def _collect_text_and_annotations(cls: type[_T], content_list: list[Any]) -> lis
@classmethod
async def _invoke_function_calls(
- cls: type[_T], kernel: "Kernel", fccs: list["FunctionCallContent"], chat_history: "ChatHistory"
+ cls: type[_T],
+ kernel: "Kernel",
+ fccs: list["FunctionCallContent"],
+ chat_history: "ChatHistory",
+ function_behavior: "FunctionChoiceBehavior | None" = None,
) -> list[Any]:
"""Invoke the function calls."""
tasks = [
- kernel.invoke_function_call(function_call=function_call, chat_history=chat_history)
+ kernel.invoke_function_call(
+ function_call=function_call, chat_history=chat_history, function_behavior=function_behavior
+ )
for function_call in fccs
]
return await asyncio.gather(*tasks)
diff --git a/python/semantic_kernel/connectors/ai/open_ai/services/_open_ai_realtime.py b/python/semantic_kernel/connectors/ai/open_ai/services/_open_ai_realtime.py
index 304b4e4efff1..cf1e1e618790 100644
--- a/python/semantic_kernel/connectors/ai/open_ai/services/_open_ai_realtime.py
+++ b/python/semantic_kernel/connectors/ai/open_ai/services/_open_ai_realtime.py
@@ -481,7 +481,8 @@ async def _parse_function_call_arguments_done(
# Step 4: Invoke the function call
chat_history = ChatHistory()
- await self._kernel.invoke_function_call(item, chat_history)
+ function_behavior = self._current_settings.function_choice_behavior if self._current_settings else None
+ await self._kernel.invoke_function_call(item, chat_history, function_behavior=function_behavior)
created_output: FunctionResultContent = chat_history.messages[-1].items[0] # type: ignore
# Step 5: Create the function result event
result = RealtimeFunctionResultEvent(
diff --git a/python/semantic_kernel/kernel.py b/python/semantic_kernel/kernel.py
index c511ce206b0b..5d74e42c4e26 100644
--- a/python/semantic_kernel/kernel.py
+++ b/python/semantic_kernel/kernel.py
@@ -347,6 +347,13 @@ async def invoke_function_call(
raise FunctionExecutionException(
f"Only functions: {allowed_functions} are allowed, {function_call.name} is not allowed."
)
+ elif function_behavior is None:
+ logger.debug(
+ "invoke_function_call called without function_behavior. "
+ "No allowlist validation will be performed for function '%s'. "
+ "Pass a FunctionChoiceBehavior with filters to enable validation.",
+ function_call.name,
+ )
function_to_call = self.get_function(function_call.plugin_name, function_call.function_name)
except Exception as exc:
logger.exception(f"The function `{function_call.name}` is not part of the provided functions: {exc}.")
diff --git a/python/tests/unit/connectors/ai/open_ai/services/test_openai_realtime.py b/python/tests/unit/connectors/ai/open_ai/services/test_openai_realtime.py
index 64d7a6a64b58..eb7be349fcb5 100644
--- a/python/tests/unit/connectors/ai/open_ai/services/test_openai_realtime.py
+++ b/python/tests/unit/connectors/ai/open_ai/services/test_openai_realtime.py
@@ -556,6 +556,86 @@ async def test_parse_function_call_arguments_done_fail(OpenAIWebsocket, kernel):
iter += 1
+async def test_parse_function_call_arguments_done_passes_function_behavior(OpenAIWebsocket, kernel):
+ """Verify that the realtime path passes function_choice_behavior to invoke_function_call."""
+ func_result = "result"
+ event = ResponseFunctionCallArgumentsDoneEvent(
+ call_id="call_id",
+ arguments='{"x": "' + func_result + '"}',
+ event_id="event_id",
+ output_index=0,
+ item_id="item_id",
+ name="plugin_name-function_name",
+ response_id="response_id",
+ type="response.function_call_arguments.done",
+ )
+ function_behavior = FunctionChoiceBehavior.Auto(filters={"included_plugins": ["plugin_name"]})
+ OpenAIWebsocket._current_settings = OpenAIRealtimeExecutionSettings(
+ instructions="instructions", ai_model_id="gpt-realtime"
+ )
+ OpenAIWebsocket._current_settings.function_choice_behavior = function_behavior
+ OpenAIWebsocket._call_id_to_function_map["call_id"] = "plugin_name-function_name"
+ func = kernel_function(name="function_name", description="function_description")(lambda x: x)
+ kernel.add_function(plugin_name="plugin_name", function_name="function_name", function=func)
+ OpenAIWebsocket._kernel = kernel
+
+ # Capture the kwargs passed to invoke_function_call
+ captured_kwargs = {}
+ original_invoke = Kernel.invoke_function_call
+
+ async def spy_invoke(self, *args, **kwargs):
+ captured_kwargs.update(kwargs)
+ return await original_invoke(self, *args, **kwargs)
+
+ with (
+ patch.object(Kernel, "invoke_function_call", spy_invoke),
+ patch.object(OpenAIWebsocket, "_send"),
+ ):
+ async for _ in OpenAIWebsocket._parse_function_call_arguments_done(event):
+ pass
+
+ assert "function_behavior" in captured_kwargs
+ assert captured_kwargs["function_behavior"] is function_behavior
+
+
+async def test_parse_function_call_arguments_done_filters_block_unallowed(OpenAIWebsocket, kernel):
+ """Verify that the realtime path blocks a function not in the allowlist."""
+ event = ResponseFunctionCallArgumentsDoneEvent(
+ call_id="call_id",
+ arguments='{"url": "http://169.254.169.254/"}',
+ event_id="event_id",
+ output_index=0,
+ item_id="item_id",
+ name="HttpPlugin-GetAsync",
+ response_id="response_id",
+ type="response.function_call_arguments.done",
+ )
+ function_behavior = FunctionChoiceBehavior.Auto(filters={"included_plugins": ["SafePlugin"]})
+ OpenAIWebsocket._current_settings = OpenAIRealtimeExecutionSettings(
+ instructions="instructions", ai_model_id="gpt-realtime"
+ )
+ OpenAIWebsocket._current_settings.function_choice_behavior = function_behavior
+ OpenAIWebsocket._call_id_to_function_map["call_id"] = "HttpPlugin-GetAsync"
+
+ # Register both plugins on kernel
+ safe_func = kernel_function(name="safe_function", description="safe")(lambda: "safe")
+ http_func = kernel_function(name="GetAsync", description="http get")(lambda url: url)
+ kernel.add_function(plugin_name="SafePlugin", function_name="safe_function", function=safe_func)
+ kernel.add_function(plugin_name="HttpPlugin", function_name="GetAsync", function=http_func)
+ OpenAIWebsocket._kernel = kernel
+
+ events_received = []
+ with patch.object(OpenAIWebsocket, "_send"):
+ async for evt in OpenAIWebsocket._parse_function_call_arguments_done(event):
+ events_received.append(evt)
+
+ # The function call event is yielded, then the result should contain the error
+ assert len(events_received) >= 2
+ result_event = events_received[-1]
+ assert isinstance(result_event, RealtimeFunctionResultEvent)
+ assert "not part of the provided" in str(result_event.function_result.result)
+
+
async def test_send_audio(OpenAIWebsocket):
audio_event = RealtimeAudioEvent(
audio=AudioContent(data=b"audio data", mime_type="audio/wav"),
diff --git a/python/tests/unit/kernel/test_kernel.py b/python/tests/unit/kernel/test_kernel.py
index f66095fb7133..ea3413f4842f 100644
--- a/python/tests/unit/kernel/test_kernel.py
+++ b/python/tests/unit/kernel/test_kernel.py
@@ -626,6 +626,101 @@ async def test_invoke_function_call_with_missing_or_unexpected_args(kernel: Kern
), "Expected fallback message not found in chat history."
+async def test_invoke_function_call_with_filters_blocks_unallowed_function(kernel: Kernel):
+ """Verify that when function_behavior has filters, an unallowed function is blocked."""
+ tool_call_mock = MagicMock(spec=FunctionCallContent)
+ tool_call_mock.name = "HttpPlugin-GetAsync"
+ tool_call_mock.function_name = "GetAsync"
+ tool_call_mock.plugin_name = "HttpPlugin"
+ tool_call_mock.arguments = {"url": "http://169.254.169.254/"}
+ tool_call_mock.ai_model_id = None
+ tool_call_mock.metadata = {}
+ tool_call_mock.index = 0
+ tool_call_mock.id = "test_id"
+
+ chat_history = ChatHistory()
+
+ safe_func_meta = KernelFunctionMetadata(name="safe_function", is_prompt=False, plugin_name="SafePlugin")
+ function_behavior = FunctionChoiceBehavior.Auto(filters={"included_plugins": ["SafePlugin"]})
+
+ with patch("semantic_kernel.kernel.Kernel.get_list_of_function_metadata", return_value=[safe_func_meta]):
+ await kernel.invoke_function_call(
+ function_call=tool_call_mock,
+ chat_history=chat_history,
+ function_behavior=function_behavior,
+ )
+
+ # The function should have been blocked — an error message should be in chat history
+ assert len(chat_history.messages) == 1
+ assert "not allowed" in str(chat_history.messages[0].items[0].result) or "not part of the provided" in str(
+ chat_history.messages[0].items[0].result
+ )
+
+
+async def test_invoke_function_call_with_filters_allows_matching_function(kernel: Kernel, get_tool_call_mock):
+ """Verify that when function_behavior has filters, an allowed function proceeds (not blocked)."""
+ tool_call_mock = get_tool_call_mock
+ chat_history_mock = MagicMock(spec=ChatHistory)
+
+ func_meta = KernelFunctionMetadata(
+ name="function", is_prompt=False, plugin_name="test", fully_qualified_name="test-function"
+ )
+
+ func_mock = AsyncMock(spec=KernelFunction)
+ func_mock.metadata = func_meta
+ func_mock.name = "function"
+ func_mock.parameters = []
+ func_result = FunctionResult(value="ok", function=func_meta)
+ func_mock.invoke = AsyncMock(return_value=func_result)
+
+ function_behavior = FunctionChoiceBehavior.Auto(filters={"included_plugins": ["test"]})
+
+ with (
+ patch("semantic_kernel.kernel.logger", autospec=True) as logger_mock,
+ patch("semantic_kernel.kernel.Kernel.get_list_of_function_metadata", return_value=[func_meta]),
+ patch("semantic_kernel.kernel.Kernel.get_function", return_value=func_mock),
+ ):
+ await kernel.invoke_function_call(
+ function_call=tool_call_mock,
+ chat_history=chat_history_mock,
+ function_behavior=function_behavior,
+ )
+
+ # The debug message for missing function_behavior should NOT have been logged
+ debug_calls = [call[0][0] for call in logger_mock.debug.call_args_list] if logger_mock.debug.called else []
+ assert not any("without function_behavior" in msg for msg in debug_calls)
+ # The exception logger should NOT have been called (function was allowed)
+ logger_mock.exception.assert_not_called()
+
+
+async def test_invoke_function_call_without_function_behavior_logs_debug(kernel: Kernel, get_tool_call_mock):
+ """Verify that calling invoke_function_call without function_behavior logs a debug message."""
+ tool_call_mock = get_tool_call_mock
+ chat_history_mock = MagicMock(spec=ChatHistory)
+
+ func_mock = AsyncMock(spec=KernelFunction)
+ func_meta = KernelFunctionMetadata(name="function", is_prompt=False)
+ func_mock.metadata = func_meta
+ func_mock.name = "function"
+ func_mock.parameters = []
+ func_result = FunctionResult(value="Function result", function=func_meta)
+ func_mock.invoke = AsyncMock(return_value=func_result)
+
+ with (
+ patch("semantic_kernel.kernel.logger", autospec=True) as logger_mock,
+ patch("semantic_kernel.kernel.Kernel.get_function", return_value=func_mock),
+ ):
+ await kernel.invoke_function_call(
+ function_call=tool_call_mock,
+ chat_history=chat_history_mock,
+ # function_behavior intentionally omitted
+ )
+
+ logger_mock.debug.assert_called()
+ debug_calls = [call[0][0] for call in logger_mock.debug.call_args_list]
+ assert any("without function_behavior" in msg for msg in debug_calls)
+
+
# endregion
# region Plugins
From 644eb0a962b048b1ac9fa639e60f878964e58f85 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Mon, 18 May 2026 13:49:00 +0100
Subject: [PATCH 2/6] Python: [Breaking] Update OpenAPI document parsing
options (#14009)
Update OpenAPI document parsing to gate file and HTTP ref resolution
separately.
### Breaking change
- `RESOLVE_FILES` is no longer enabled by default. Only internal JSON
pointer references are resolved by default.
- Users with multi-file OpenAPI specs must now pass
`enable_file_ref_resolution=True` via
`OpenAPIFunctionExecutionParameters`.
- `enable_external_ref_resolution` has been renamed to
`enable_http_ref_resolution`.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../openapi_function_execution_parameters.py | 17 ++
.../openapi_plugin/openapi_manager.py | 6 +-
.../openapi_plugin/openapi_parser.py | 27 ++-
.../openapi_plugin/test_openapi_manager.py | 6 +-
.../openapi_plugin/test_openapi_parser.py | 194 ++++++++++++++++++
python/uv.lock | 1 -
6 files changed, 245 insertions(+), 6 deletions(-)
diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py
index 442af52a49d2..2d1ac19df68b 100644
--- a/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py
+++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py
@@ -30,6 +30,23 @@ class OpenAPIFunctionExecutionParameters(KernelBaseModel):
timeout: float | None = Field(
None, description="Default timeout in seconds for HTTP requests. Uses httpx default (5 seconds) if None."
)
+ enable_file_ref_resolution: bool = Field(
+ False,
+ description=(
+ "Whether to resolve local file $ref references when parsing OpenAPI documents. "
+ "Disabled by default. When False, only internal JSON pointer references are resolved. "
+ "Set to True if your OpenAPI spec is split across multiple local files and you trust "
+ "the document source."
+ ),
+ )
+ enable_http_ref_resolution: bool = Field(
+ False,
+ description=(
+ "Whether to resolve external HTTP $ref references when parsing OpenAPI documents. "
+ "Disabled by default. Set to True only if you trust the OpenAPI document source "
+ "and need external HTTP $ref resolution."
+ ),
+ )
def model_post_init(self, __context: Any) -> None:
"""Post initialization method for the model."""
diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py
index 135221f17c59..b825a1635cae 100644
--- a/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py
+++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py
@@ -56,7 +56,11 @@ def create_functions_from_openapi(
# Parse the document from the given path
parser = OpenApiParser()
- parsed_doc = parser.parse(openapi_document_path)
+ parsed_doc = parser.parse(
+ openapi_document_path,
+ enable_file_ref_resolution=(execution_settings.enable_file_ref_resolution if execution_settings else False),
+ enable_http_ref_resolution=(execution_settings.enable_http_ref_resolution if execution_settings else False),
+ )
if parsed_doc is None:
raise FunctionExecutionException(f"Error parsing OpenAPI document: {openapi_document_path}")
diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py
index 0ed36f209d00..1f6aabe5eeff 100644
--- a/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py
+++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py
@@ -6,6 +6,7 @@
from typing import TYPE_CHECKING, Any, Final
from prance import ResolvingParser
+from prance.util.resolver import RESOLVE_FILES, RESOLVE_HTTP, RESOLVE_INTERNAL
from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import RestApiExpectedResponse
from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation
@@ -44,9 +45,29 @@ class OpenApiParser:
PAYLOAD_PROPERTIES_HIERARCHY_MAX_DEPTH: int = 10
SUPPORTED_MEDIA_TYPES: Final[list[str]] = ["application/json", "text/plain"]
- def parse(self, openapi_document: str) -> Any | dict[str, Any] | None:
- """Parse the OpenAPI document."""
- parser = ResolvingParser(openapi_document)
+ def parse(
+ self,
+ openapi_document: str,
+ enable_file_ref_resolution: bool = False,
+ enable_http_ref_resolution: bool = False,
+ ) -> Any | dict[str, Any] | None:
+ """Parse the OpenAPI document.
+
+ Args:
+ openapi_document: The path or URL to the OpenAPI document.
+ enable_file_ref_resolution: Whether to resolve local file $ref references.
+ Disabled by default. When False, only internal JSON pointer references
+ are resolved. Set to True if your OpenAPI spec is split across multiple
+ local files.
+ enable_http_ref_resolution: Whether to resolve external HTTP $ref references.
+ Disabled by default.
+ """
+ resolve_types = RESOLVE_INTERNAL
+ if enable_file_ref_resolution:
+ resolve_types |= RESOLVE_FILES
+ if enable_http_ref_resolution:
+ resolve_types |= RESOLVE_HTTP
+ parser = ResolvingParser(openapi_document, resolve_types=resolve_types)
return parser.specification
def _parse_parameters(self, parameters: list[dict[str, Any]]):
diff --git a/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py b/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py
index 05f3f948ff43..37bd6b324d77 100644
--- a/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py
+++ b/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py
@@ -223,7 +223,11 @@ async def test_create_functions_from_openapi_raises_exception(mock_parse):
with pytest.raises(FunctionExecutionException, match="Error parsing OpenAPI document: test_openapi_document_path"):
create_functions_from_openapi(plugin_name="test_plugin", openapi_document_path="test_openapi_document_path")
- mock_parse.assert_called_once_with("test_openapi_document_path")
+ mock_parse.assert_called_once_with(
+ "test_openapi_document_path",
+ enable_file_ref_resolution=False,
+ enable_http_ref_resolution=False,
+ )
async def test_run_operation_uses_timeout_from_run_options():
diff --git a/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py b/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py
index ecd5e044ba2e..b0c81074e5a8 100644
--- a/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py
+++ b/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py
@@ -189,3 +189,197 @@ def test_no_operationid_raises_error():
openapi_document_path=no_op_path,
execution_settings=None,
)
+
+
+def test_parse_blocks_external_http_refs_by_default():
+ """Verify that external HTTP $ref resolution is not enabled by default."""
+ from unittest.mock import MagicMock, patch
+
+ from prance.util.resolver import RESOLVE_HTTP, RESOLVE_INTERNAL
+
+ with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
+ mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
+ parser = OpenApiParser()
+ parser.parse("dummy_path.yaml")
+
+ mock_parser_cls.assert_called_once()
+ call_kwargs = mock_parser_cls.call_args
+ resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
+ assert resolve_types == RESOLVE_INTERNAL, (
+ f"Expected only RESOLVE_INTERNAL ({RESOLVE_INTERNAL}), got {resolve_types}"
+ )
+ assert not (resolve_types & RESOLVE_HTTP), "RESOLVE_HTTP should not be set by default"
+
+
+def test_parse_resolves_internal_refs_by_default(tmp_path):
+ """Verify that internal $ref references are still resolved by default."""
+ openapi_spec = tmp_path / "spec_with_internal_ref.yaml"
+ openapi_spec.write_text(
+ """
+openapi: 3.0.0
+info:
+ title: Internal Ref Test
+ version: 1.0.0
+servers:
+ - url: http://example.com
+paths:
+ /test:
+ get:
+ operationId: testOp
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TestSchema"
+components:
+ schemas:
+ TestSchema:
+ type: object
+ properties:
+ name:
+ type: string
+""",
+ encoding="utf-8",
+ )
+
+ parser = OpenApiParser()
+ result = parser.parse(str(openapi_spec))
+
+ # Internal $ref should be resolved
+ response_schema = result["paths"]["/test"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
+ assert "$ref" not in response_schema, "Internal $ref should be resolved"
+ assert response_schema["type"] == "object"
+ assert "name" in response_schema["properties"]
+
+
+def test_parse_blocks_file_refs_by_default():
+ """Verify that local file $ref resolution is not enabled by default."""
+ from unittest.mock import MagicMock, patch
+
+ from prance.util.resolver import RESOLVE_FILES, RESOLVE_INTERNAL
+
+ with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
+ mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
+ parser = OpenApiParser()
+ parser.parse("dummy_path.yaml")
+
+ call_kwargs = mock_parser_cls.call_args
+ resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
+ assert resolve_types == RESOLVE_INTERNAL, (
+ f"Expected only RESOLVE_INTERNAL ({RESOLVE_INTERNAL}), got {resolve_types}"
+ )
+ assert not (resolve_types & RESOLVE_FILES), "RESOLVE_FILES should not be set by default"
+
+
+def test_parse_enables_file_refs_when_requested():
+ """Verify that local file $ref resolution is enabled when explicitly requested."""
+ from unittest.mock import MagicMock, patch
+
+ from prance.util.resolver import RESOLVE_FILES, RESOLVE_INTERNAL
+
+ with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
+ mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
+ parser = OpenApiParser()
+ parser.parse("dummy_path.yaml", enable_file_ref_resolution=True)
+
+ call_kwargs = mock_parser_cls.call_args
+ resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
+ assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_FILES), (
+ f"Expected RESOLVE_INTERNAL | RESOLVE_FILES, got {resolve_types}"
+ )
+
+
+def test_parse_enables_http_refs_when_requested():
+ """Verify that HTTP $ref resolution is enabled when explicitly requested."""
+ from unittest.mock import MagicMock, patch
+
+ from prance.util.resolver import RESOLVE_HTTP, RESOLVE_INTERNAL
+
+ with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
+ mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
+ parser = OpenApiParser()
+ parser.parse("dummy_path.yaml", enable_http_ref_resolution=True)
+
+ call_kwargs = mock_parser_cls.call_args
+ resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
+ assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_HTTP), (
+ f"Expected RESOLVE_INTERNAL | RESOLVE_HTTP, got {resolve_types}"
+ )
+
+
+def test_parse_enables_both_file_and_http_refs_when_requested():
+ """Verify both file and HTTP $ref resolution work together."""
+ from unittest.mock import MagicMock, patch
+
+ from prance.util.resolver import RESOLVE_FILES, RESOLVE_HTTP, RESOLVE_INTERNAL
+
+ with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
+ mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
+ parser = OpenApiParser()
+ parser.parse("dummy_path.yaml", enable_file_ref_resolution=True, enable_http_ref_resolution=True)
+
+ call_kwargs = mock_parser_cls.call_args
+ resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
+ assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_FILES | RESOLVE_HTTP), (
+ f"Expected RESOLVE_INTERNAL | RESOLVE_FILES | RESOLVE_HTTP, got {resolve_types}"
+ )
+
+
+def test_create_functions_propagates_enable_http_ref_resolution():
+ """Verify enable_http_ref_resolution=True is propagated from settings to parser."""
+ from unittest.mock import patch
+
+ from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
+ OpenAPIFunctionExecutionParameters,
+ )
+
+ minimal_spec = {
+ "openapi": "3.0.0",
+ "info": {"title": "Test", "version": "1.0.0"},
+ "paths": {},
+ }
+
+ settings = OpenAPIFunctionExecutionParameters(enable_http_ref_resolution=True)
+
+ with patch.object(OpenApiParser, "parse", return_value=minimal_spec) as mock_parse:
+ create_functions_from_openapi(
+ plugin_name="testPlugin",
+ openapi_document_path="dummy.yaml",
+ execution_settings=settings,
+ )
+ mock_parse.assert_called_once_with(
+ "dummy.yaml",
+ enable_file_ref_resolution=False,
+ enable_http_ref_resolution=True,
+ )
+
+
+def test_create_functions_propagates_enable_file_ref_resolution():
+ """Verify enable_file_ref_resolution=True is propagated from settings to parser."""
+ from unittest.mock import patch
+
+ from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
+ OpenAPIFunctionExecutionParameters,
+ )
+
+ minimal_spec = {
+ "openapi": "3.0.0",
+ "info": {"title": "Test", "version": "1.0.0"},
+ "paths": {},
+ }
+
+ settings = OpenAPIFunctionExecutionParameters(enable_file_ref_resolution=True)
+
+ with patch.object(OpenApiParser, "parse", return_value=minimal_spec) as mock_parse:
+ create_functions_from_openapi(
+ plugin_name="testPlugin",
+ openapi_document_path="dummy.yaml",
+ execution_settings=settings,
+ )
+ mock_parse.assert_called_once_with(
+ "dummy.yaml",
+ enable_file_ref_resolution=True,
+ enable_http_ref_resolution=False,
+ )
diff --git a/python/uv.lock b/python/uv.lock
index 10b500f8c457..44c249e89981 100644
--- a/python/uv.lock
+++ b/python/uv.lock
@@ -1,5 +1,4 @@
version = 1
-revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '4' and sys_platform == 'darwin'",
From 3e180c16b3004f8b10720ea5f82c3ad136e59153 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Mon, 25 May 2026 20:24:11 +0100
Subject: [PATCH 3/6] .Net: Enable default-on server URL validation for OpenAPI
plugins (#14029)
### Motivation and Context
Strengthen the OpenAPI plugin's server URL handling by making validation
active by default.
### Description
- Introduce `ServerUrlValidator` with host classification and DNS
resolution
- Make `RestApiOperationServerUrlValidationOptions` enforce validation
by default instead of opt-in
- Add `AllowPrivateNetworkAccess` opt-out for scenarios that require it
- Remove experimental `AllowedSchemes` property (SKEXP0040)
- Update `RestApiOperationRunner` to apply validation unconditionally
- Add unit tests
### Contribution Checklist
- [x] The code builds clean without any errors or warnings
- [x] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
- [x] The code follows the [.NET coding
conventions](https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
- [x] All unit tests pass
- [x] New unit tests added
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
dotnet/Directory.Packages.props | 2 +-
.../CompatibilitySuppressions.xml | 42 ++
.../OpenApiFunctionExecutionParameters.cs | 23 +-
.../RestApiOperationRunner.cs | 75 +---
...tApiOperationServerUrlValidationOptions.cs | 61 ++-
.../Functions.OpenApi/ServerUrlValidator.cs | 407 ++++++++++++++++++
.../OpenApiKernelExtensionsTests.cs | 19 +-
.../OpenApiKernelPluginFactoryTests.cs | 19 +-
.../OpenApi/RestApiOperationRunnerTests.cs | 271 ++++++++++--
.../OpenApi/ServerUrlValidatorTests.cs | 232 ++++++++++
.../CrossLanguage/OpenApiTest.cs | 6 +-
11 files changed, 1023 insertions(+), 134 deletions(-)
create mode 100644 dotnet/src/Functions/Functions.OpenApi/ServerUrlValidator.cs
create mode 100644 dotnet/src/Functions/Functions.UnitTests/OpenApi/ServerUrlValidatorTests.cs
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 10568b08f85f..4469eea47ee9 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -106,7 +106,7 @@
-
+
diff --git a/dotnet/src/Functions/Functions.OpenApi/CompatibilitySuppressions.xml b/dotnet/src/Functions/Functions.OpenApi/CompatibilitySuppressions.xml
index cc1b3172c906..ff2b43c448eb 100644
--- a/dotnet/src/Functions/Functions.OpenApi/CompatibilitySuppressions.xml
+++ b/dotnet/src/Functions/Functions.OpenApi/CompatibilitySuppressions.xml
@@ -1,10 +1,52 @@
+
+ CP0002
+ M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.get_AllowedSchemes
+ lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ true
+
+
+ CP0002
+ M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.set_AllowedSchemes(System.Collections.Generic.IReadOnlyList{System.String})
+ lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ true
+
+
+ CP0002
+ M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.get_AllowedSchemes
+ lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ true
+
+
+ CP0002
+ M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.set_AllowedSchemes(System.Collections.Generic.IReadOnlyList{System.String})
+ lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ true
+
CP0002
F:Microsoft.SemanticKernel.Plugins.OpenApi.OpenApiKernelFunctionContext.KernelFunctionContextKey
lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+
+ CP0002
+ M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.get_AllowedSchemes
+ lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ true
+
+
+ CP0002
+ M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.set_AllowedSchemes(System.Collections.Generic.IReadOnlyList{System.String})
+ lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll
+ true
+
\ No newline at end of file
diff --git a/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs b/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs
index 31f95a8a536e..3a9166b6d1b6 100644
--- a/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs
+++ b/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs
@@ -99,11 +99,26 @@ public class OpenApiFunctionExecutionParameters
public RestApiParameterFilter? ParameterFilter { get; set; }
///
- /// Options for validating server URLs before making HTTP requests.
- /// When set, the plugin will validate each resolved URL against the configured allowed base URLs and schemes
- /// before sending the HTTP request. This helps prevent Server-Side Request Forgery (SSRF) attacks.
- /// If null (default), no URL validation is performed.
+ /// Options for validating server URLs before making HTTP requests, to help prevent
+ /// Server-Side Request Forgery (SSRF) attacks against private/internal infrastructure.
///
+ ///
+ ///
+ /// Validation is on by default: when this property is null, the plugin behaves
+ /// as if a default-constructed was
+ /// supplied. The implicit policy permits only HTTPS URLs that resolve to public IP
+ /// addresses, blocking loopback, link-local, RFC1918, IPv6 ULA, CGNAT and other
+ /// non-public ranges (including the cloud-metadata address 169.254.169.254).
+ ///
+ ///
+ /// To allow plaintext HTTP or private/loopback hosts (for example for localhost
+ /// development or on-prem APIs), set
+ /// with the
+ /// specific allowed origins, or set
+ ///
+ /// to true.
+ ///
+ ///
[Experimental("SKEXP0040")]
public RestApiOperationServerUrlValidationOptions? ServerUrlValidationOptions { get; set; }
diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs
index adcbbbab86a8..390e94be7101 100644
--- a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs
+++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs
@@ -120,11 +120,6 @@ internal sealed class RestApiOperationRunner
///
private readonly RestApiOperationServerUrlValidationOptions? _serverUrlValidationOptions;
- ///
- /// Default allowed schemes when none are explicitly configured.
- ///
- private static readonly IReadOnlyList s_defaultAllowedSchemes = ["https"];
-
///
/// Creates an instance of the class.
///
@@ -191,7 +186,7 @@ public RestApiOperationRunner(
/// Options for REST API operation run.
/// The cancellation token.
/// The task execution result.
- public Task RunAsync(
+ public async Task RunAsync(
RestApiOperation operation,
KernelArguments arguments,
RestApiOperationRunOptions? options = null,
@@ -199,81 +194,17 @@ public Task RunAsync(
{
var url = this._urlFactory?.Invoke(operation, arguments, options) ?? this.BuildsOperationUrl(operation, arguments, options?.ServerUrlOverride, options?.ApiHostUrl);
- this.ValidateUrl(url);
+ await ServerUrlValidator.ValidateAsync(url, this._serverUrlValidationOptions, cancellationToken).ConfigureAwait(false);
var headers = this._headersFactory?.Invoke(operation, arguments, options) ?? operation.BuildHeaders(arguments);
var (Payload, Content) = this._payloadFactory?.Invoke(operation, arguments, this._enableDynamicPayload, this._enablePayloadNamespacing, options) ?? this.BuildOperationPayload(operation, arguments);
- return this.SendAsync(operation, url, headers, Payload, Content, options, cancellationToken);
+ return await this.SendAsync(operation, url, headers, Payload, Content, options, cancellationToken).ConfigureAwait(false);
}
#region private
- ///
- /// Validates the resolved URL against the configured server URL validation options.
- ///
- /// The resolved URL to validate.
- /// Thrown when the URL violates the validation rules.
- private void ValidateUrl(Uri url)
- {
- if (this._serverUrlValidationOptions is null)
- {
- return;
- }
-
- // Validate the URI scheme.
- var allowedSchemes = this._serverUrlValidationOptions.AllowedSchemes ?? s_defaultAllowedSchemes;
- if (allowedSchemes.Count > 0)
- {
- bool schemeAllowed = false;
- foreach (var scheme in allowedSchemes)
- {
- if (string.Equals(url.Scheme, scheme, StringComparison.OrdinalIgnoreCase))
- {
- schemeAllowed = true;
- break;
- }
- }
-
- if (!schemeAllowed)
- {
- throw new InvalidOperationException(
- $"The request URI scheme '{url.Scheme}' is not allowed. Allowed schemes: {string.Join(", ", allowedSchemes)}.");
- }
- }
-
- // Validate the URL against the allowed base URLs.
- if (this._serverUrlValidationOptions.AllowedBaseUrls is { Count: > 0 } allowedBaseUrls)
- {
- bool baseUrlAllowed = false;
-
- foreach (var baseUrl in allowedBaseUrls)
- {
- // Use only scheme + authority + path for comparison, ignoring any query or fragment.
- var baseUrlPath = baseUrl.GetLeftPart(UriPartial.Path);
- var urlPath = url.GetLeftPart(UriPartial.Path);
- var baseUrlWithSlash = baseUrlPath;
- if (!baseUrlWithSlash.EndsWith("/", StringComparison.Ordinal))
- {
- baseUrlWithSlash += "/";
- }
- if (string.Equals(urlPath, baseUrlPath, StringComparison.OrdinalIgnoreCase) ||
- urlPath.StartsWith(baseUrlWithSlash, StringComparison.OrdinalIgnoreCase))
- {
- baseUrlAllowed = true;
- break;
- }
- }
-
- if (!baseUrlAllowed)
- {
- throw new InvalidOperationException(
- $"The request URI '{url}' is not allowed. It does not match any of the allowed base URLs.");
- }
- }
- }
-
///
/// Sends an HTTP request.
///
diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationServerUrlValidationOptions.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationServerUrlValidationOptions.cs
index 0f9335998e7d..4d3e6d640af1 100644
--- a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationServerUrlValidationOptions.cs
+++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationServerUrlValidationOptions.cs
@@ -8,25 +8,64 @@ namespace Microsoft.SemanticKernel.Plugins.OpenApi;
///
/// Options for validating server URLs before making HTTP requests in the OpenAPI plugin.
-/// When configured, these options help prevent Server-Side Request Forgery (SSRF) attacks
-/// by restricting which URLs the plugin is allowed to call.
+/// These options control the secure-by-default protection against Server-Side Request Forgery
+/// (SSRF) attacks that the OpenAPI plugin applies to URLs derived from the OpenAPI document
+/// (the servers[].url field and any server-variable substitutions).
///
+///
+///
+/// When is left
+/// , a default-constructed instance of this class is applied. The default
+/// policy is:
+///
+///
+/// - Only the https scheme is permitted for URLs that are not on the
+/// allowlist.
+/// - Requests whose host resolves to a loopback, link-local (including the
+/// cloud metadata endpoint 169.254.169.254), private (RFC1918), IPv6 unique local
+/// (fc00::/7), carrier-grade NAT, multicast, or reserved IP range are rejected.
+///
+///
+/// A URL that matches an entry in is an explicit allow and
+/// bypasses both the implicit https-only gate and the private-IP gate, so an allowlist can be
+/// used to opt specific intranet hosts back in.
+///
+///
+/// Known limitation: URL validation is performed before the HTTP request is sent. If the
+/// used to invoke the plugin has automatic redirect
+/// following enabled (the default), an attacker that controls a public host may redirect the
+/// request to a private address. Configure your with
+/// AllowAutoRedirect = false when consuming OpenAPI documents from untrusted sources.
+///
+///
[Experimental("SKEXP0040")]
public class RestApiOperationServerUrlValidationOptions
{
///
- /// Gets or sets the allowed base URLs.
- /// If set, only requests to URLs that start with one of these base URLs will be permitted.
- /// For example, if AllowedBaseUrls contains https://api.example.com,
- /// then requests to https://api.example.com/v1/users will be allowed,
- /// but requests to https://evil.com/data will be blocked.
- /// If null, no base URL restriction is applied (scheme validation still applies).
+ /// Gets or sets the explicit allowlist of base URLs. A request whose final URL matches one
+ /// of these entries (same scheme, host, port, and path prefix) is permitted regardless of
+ /// the implicit scheme and private-IP gates.
///
+ ///
+ /// For example, with AllowedBaseUrls = [new Uri("https://api.example.com/v1")]:
+ /// https://api.example.com/v1/users is allowed, https://api.example.com/v2/...
+ /// is rejected, and https://evil.com/... is rejected. Query strings and fragments in
+ /// the entries are ignored for comparison. Adding an http:// entry (for example,
+ /// http://localhost:5000 or http://intranet.corp) is the recommended way to
+ /// opt-in specific plaintext or intranet endpoints without weakening the global defaults.
+ ///
public IReadOnlyList? AllowedBaseUrls { get; set; }
///
- /// Gets or sets the allowed URI schemes.
- /// If null or empty, only https is permitted.
+ /// Gets or sets a value indicating whether requests to private, loopback, link-local, and
+ /// other non-public IP ranges are permitted for URLs that are not covered by
+ /// . The default is (secure).
///
- public IReadOnlyList? AllowedSchemes { get; set; }
+ ///
+ /// Setting this to disables the SSRF protections that block requests
+ /// to cloud metadata services (e.g. 169.254.169.254), localhost, RFC1918
+ /// networks, and similar ranges. Only enable this in trusted environments (such as local
+ /// development). Prefer adding specific hosts to instead.
+ ///
+ public bool AllowPrivateNetworkAccess { get; set; }
}
diff --git a/dotnet/src/Functions/Functions.OpenApi/ServerUrlValidator.cs b/dotnet/src/Functions/Functions.OpenApi/ServerUrlValidator.cs
new file mode 100644
index 000000000000..bd90bd40406b
--- /dev/null
+++ b/dotnet/src/Functions/Functions.OpenApi/ServerUrlValidator.cs
@@ -0,0 +1,407 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.SemanticKernel.Plugins.OpenApi;
+
+///
+/// Classifies URLs against a policy
+/// to prevent Server-Side Request Forgery (SSRF) via untrusted OpenAPI server URLs.
+///
+internal static class ServerUrlValidator
+{
+ private const string ImplicitAllowedScheme = "https";
+
+ ///
+ /// Validates the given URL against the supplied policy and throws
+ /// if the URL is not permitted.
+ ///
+ /// The fully resolved request URL to validate.
+ /// The validation policy. If , the secure
+ /// default policy (https-only and private/loopback/link-local IPs blocked) is applied.
+ /// Cancellation token for the asynchronous DNS resolution.
+ /// Optional DNS resolver for testing. When ,
+ /// is used.
+ public static async Task ValidateAsync(
+ Uri url,
+ RestApiOperationServerUrlValidationOptions? options,
+ CancellationToken cancellationToken = default,
+ Func>? dnsResolver = null)
+ {
+ if (url is null)
+ {
+ throw new ArgumentNullException(nameof(url));
+ }
+
+ // Treat null as a default-constructed instance so protection is on by default.
+ options ??= new RestApiOperationServerUrlValidationOptions();
+
+ // 1. Explicit allow: a matching AllowedBaseUrls entry bypasses the implicit gates.
+ if (TryMatchAllowedBaseUrl(url, options.AllowedBaseUrls))
+ {
+ return;
+ }
+
+ // If the caller set AllowedBaseUrls and the URL didn't match, reject before further
+ // checks so the failure message points the developer at the right knob.
+ if (options.AllowedBaseUrls is { Count: > 0 })
+ {
+ throw new InvalidOperationException(
+ $"The request URI '{url}' is not allowed. It does not match any of the allowed base URLs.");
+ }
+
+ // 2. Implicit scheme gate: only https is allowed by default.
+ if (!string.Equals(url.Scheme, ImplicitAllowedScheme, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ $"The request URI scheme '{url.Scheme}' is not allowed. " +
+ $"Only '{ImplicitAllowedScheme}' is permitted by default. " +
+ "To allow this URL, add it to " +
+ $"{nameof(RestApiOperationServerUrlValidationOptions)}.{nameof(RestApiOperationServerUrlValidationOptions.AllowedBaseUrls)}.");
+ }
+
+ // 3. Implicit private-IP gate.
+ if (options.AllowPrivateNetworkAccess)
+ {
+ return;
+ }
+
+ await EnsurePublicHostAsync(url, cancellationToken, dnsResolver).ConfigureAwait(false);
+ }
+
+ private static bool TryMatchAllowedBaseUrl(Uri url, IReadOnlyList? allowedBaseUrls)
+ {
+ if (allowedBaseUrls is not { Count: > 0 })
+ {
+ return false;
+ }
+
+ var urlPath = url.GetLeftPart(UriPartial.Path);
+
+ foreach (var baseUrl in allowedBaseUrls)
+ {
+ if (baseUrl is null)
+ {
+ continue;
+ }
+
+ // Scheme, host, and port must all match for the explicit allow to apply.
+ if (!string.Equals(url.Scheme, baseUrl.Scheme, StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(url.Host, baseUrl.Host, StringComparison.OrdinalIgnoreCase) ||
+ url.Port != baseUrl.Port)
+ {
+ continue;
+ }
+
+ var baseUrlPath = baseUrl.GetLeftPart(UriPartial.Path);
+ var baseUrlWithSlash = baseUrlPath.EndsWith("/", StringComparison.Ordinal)
+ ? baseUrlPath
+ : baseUrlPath + "/";
+
+ if (string.Equals(urlPath, baseUrlPath, StringComparison.OrdinalIgnoreCase) ||
+ urlPath.StartsWith(baseUrlWithSlash, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static async Task EnsurePublicHostAsync(
+ Uri url,
+ CancellationToken cancellationToken,
+ Func>? dnsResolver)
+ {
+ var host = url.DnsSafeHost;
+
+ // Case 1: literal IP in the URL.
+ if (url.HostNameType == UriHostNameType.IPv4 || url.HostNameType == UriHostNameType.IPv6)
+ {
+ if (!IPAddress.TryParse(host, out var ip))
+ {
+ // Should be unreachable — .NET's Uri already classified this as an IP address.
+ // Fail closed: block the request rather than silently skipping validation.
+ throw new InvalidOperationException(
+ $"The server URL '{url}' has a host identified as an IP address but could not be parsed. The request is blocked as a precaution.");
+ }
+
+ EnsurePublicAddress(url, ip);
+ return;
+ }
+
+ // Case 2: hostname - resolve and validate every returned address (defeats DNS rebinding).
+ IPAddress[] addresses;
+ try
+ {
+ if (dnsResolver is not null)
+ {
+ addresses = await dnsResolver(host, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+#if NET
+ addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
+#else
+ addresses = await Dns.GetHostAddressesAsync(host).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+#endif
+ }
+ }
+ catch (SocketException)
+ {
+ // DNS resolution failed. Fail closed: block the request rather than allowing
+ // it to proceed unvalidated. A TOCTOU race could otherwise allow a private
+ // target to be reached if DNS fails here but succeeds when HttpClient resolves.
+ throw new InvalidOperationException(
+ $"The request URI '{url}' is not allowed: DNS resolution for host '{host}' failed. " +
+ "The request is blocked as a precaution to prevent potential access to private network addresses. " +
+ "To allow this URL, add it to " +
+ $"{nameof(RestApiOperationServerUrlValidationOptions)}.{nameof(RestApiOperationServerUrlValidationOptions.AllowedBaseUrls)} " +
+ $"or set {nameof(RestApiOperationServerUrlValidationOptions)}.{nameof(RestApiOperationServerUrlValidationOptions.AllowPrivateNetworkAccess)} = true.");
+ }
+
+ if (addresses is null || addresses.Length == 0)
+ {
+ // No addresses returned. Fail closed for the same TOCTOU reason.
+ throw new InvalidOperationException(
+ $"The request URI '{url}' is not allowed: DNS resolution for host '{host}' returned no addresses. " +
+ "The request is blocked as a precaution. To allow this URL, add it to " +
+ $"{nameof(RestApiOperationServerUrlValidationOptions)}.{nameof(RestApiOperationServerUrlValidationOptions.AllowedBaseUrls)} " +
+ $"or set {nameof(RestApiOperationServerUrlValidationOptions)}.{nameof(RestApiOperationServerUrlValidationOptions.AllowPrivateNetworkAccess)} = true.");
+ }
+
+ foreach (var address in addresses)
+ {
+ EnsurePublicAddress(url, address);
+ }
+ }
+
+ private static void EnsurePublicAddress(Uri url, IPAddress address)
+ {
+ if (TryCategorizeNonPublicAddress(address, out var category))
+ {
+ throw new InvalidOperationException(
+ $"The request URI '{url}' is not allowed: host resolves to a {category} address ({address}), " +
+ "which is blocked by default to prevent Server-Side Request Forgery (SSRF). " +
+ "To allow this URL, add it to " +
+ $"{nameof(RestApiOperationServerUrlValidationOptions)}.{nameof(RestApiOperationServerUrlValidationOptions.AllowedBaseUrls)} " +
+ $"or set {nameof(RestApiOperationServerUrlValidationOptions)}.{nameof(RestApiOperationServerUrlValidationOptions.AllowPrivateNetworkAccess)} = true.");
+ }
+ }
+
+ ///
+ /// Returns true and sets to a human-readable label when the
+ /// supplied address is in a non-public IP range that should be blocked by default.
+ ///
+ internal static bool TryCategorizeNonPublicAddress(IPAddress address, out string category)
+ {
+ // Normalize IPv4-mapped IPv6 (::ffff:a.b.c.d) to its IPv4 form so the v4 checks apply.
+ if (address.AddressFamily == AddressFamily.InterNetworkV6 && IsIPv4MappedToIPv6(address))
+ {
+ address = MapToIPv4(address);
+ }
+
+ if (address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ return TryClassifyIPv4(address, out category);
+ }
+
+ if (address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ return TryClassifyIPv6(address, out category);
+ }
+
+ // Not IPv4 or IPv6 - reject conservatively.
+ category = "non-IP";
+ return true;
+ }
+
+ private static bool TryClassifyIPv4(IPAddress address, out string category)
+ {
+ var bytes = address.GetAddressBytes();
+ var b0 = bytes[0];
+ var b1 = bytes[1];
+ var b2 = bytes[2];
+
+ // 0.0.0.0/8 - "this network", unspecified.
+ if (b0 == 0)
+ {
+ category = "unspecified";
+ return true;
+ }
+
+ // 10.0.0.0/8
+ if (b0 == 10)
+ {
+ category = "private (RFC1918)";
+ return true;
+ }
+
+ // 127.0.0.0/8 - loopback.
+ if (b0 == 127)
+ {
+ category = "loopback";
+ return true;
+ }
+
+ // 169.254.0.0/16 - link-local (includes cloud metadata 169.254.169.254).
+ if (b0 == 169 && b1 == 254)
+ {
+ category = "link-local";
+ return true;
+ }
+
+ // 172.16.0.0/12
+ if (b0 == 172 && b1 >= 16 && b1 <= 31)
+ {
+ category = "private (RFC1918)";
+ return true;
+ }
+
+ // 192.168.0.0/16
+ if (b0 == 192 && b1 == 168)
+ {
+ category = "private (RFC1918)";
+ return true;
+ }
+
+ // 100.64.0.0/10 - carrier-grade NAT.
+ if (b0 == 100 && b1 >= 64 && b1 <= 127)
+ {
+ category = "carrier-grade NAT";
+ return true;
+ }
+
+ // 198.18.0.0/15 - benchmarking.
+ if (b0 == 198 && (b1 == 18 || b1 == 19))
+ {
+ category = "benchmarking";
+ return true;
+ }
+
+ // 192.0.0.0/24 (IETF protocol assignments) and 192.0.2.0/24 (TEST-NET-1).
+ if (b0 == 192 && b1 == 0 && (b2 == 0 || b2 == 2))
+ {
+ category = "reserved";
+ return true;
+ }
+
+ // 198.51.100.0/24 (TEST-NET-2).
+ if (b0 == 198 && b1 == 51 && b2 == 100)
+ {
+ category = "reserved";
+ return true;
+ }
+
+ // 203.0.113.0/24 (TEST-NET-3).
+ if (b0 == 203 && b1 == 0 && b2 == 113)
+ {
+ category = "reserved";
+ return true;
+ }
+
+ // 224.0.0.0/4 - multicast.
+ if (b0 >= 224 && b0 <= 239)
+ {
+ category = "multicast";
+ return true;
+ }
+
+ // 240.0.0.0/4 - reserved (includes 255.255.255.255 broadcast).
+ if (b0 >= 240)
+ {
+ category = "reserved";
+ return true;
+ }
+
+ category = string.Empty;
+ return false;
+ }
+
+ private static bool TryClassifyIPv6(IPAddress address, out string category)
+ {
+ if (IPAddress.IsLoopback(address))
+ {
+ category = "loopback";
+ return true;
+ }
+
+ var bytes = address.GetAddressBytes();
+
+ // :: (unspecified)
+ if (address.Equals(IPAddress.IPv6None))
+ {
+ category = "unspecified";
+ return true;
+ }
+
+ // fe80::/10 - link-local.
+ if (bytes[0] == 0xfe && (bytes[1] & 0xC0) == 0x80)
+ {
+ category = "link-local";
+ return true;
+ }
+
+ // fc00::/7 - unique local (private).
+ if ((bytes[0] & 0xfe) == 0xfc)
+ {
+ category = "private (IPv6 ULA)";
+ return true;
+ }
+
+ // ff00::/8 - multicast.
+ if (bytes[0] == 0xff)
+ {
+ category = "multicast";
+ return true;
+ }
+
+ // 2001:db8::/32 - documentation.
+ if (bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && bytes[3] == 0xb8)
+ {
+ category = "reserved";
+ return true;
+ }
+
+ category = string.Empty;
+ return false;
+ }
+
+ private static bool IsIPv4MappedToIPv6(IPAddress address)
+ {
+#if NET
+ return address.IsIPv4MappedToIPv6;
+#else
+ var bytes = address.GetAddressBytes();
+ if (bytes.Length != 16)
+ {
+ return false;
+ }
+ for (int i = 0; i < 10; i++)
+ {
+ if (bytes[i] != 0)
+ {
+ return false;
+ }
+ }
+ return bytes[10] == 0xff && bytes[11] == 0xff;
+#endif
+ }
+
+ private static IPAddress MapToIPv4(IPAddress address)
+ {
+#if NET
+ return address.MapToIPv4();
+#else
+ var bytes = address.GetAddressBytes();
+ var v4 = new byte[4] { bytes[12], bytes[13], bytes[14], bytes[15] };
+ return new IPAddress(v4);
+#endif
+ }
+}
diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/Extensions/OpenApiKernelExtensionsTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/Extensions/OpenApiKernelExtensionsTests.cs
index 960f40d8a58a..65b3c211c319 100644
--- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/Extensions/OpenApiKernelExtensionsTests.cs
+++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/Extensions/OpenApiKernelExtensionsTests.cs
@@ -45,7 +45,18 @@ public OpenApiKernelExtensionsTests()
{
this._kernel = new Kernel();
- this._executionParameters = new OpenApiFunctionExecutionParameters() { EnableDynamicPayload = false };
+ this._executionParameters = new OpenApiFunctionExecutionParameters()
+ {
+ EnableDynamicPayload = false,
+ ServerUrlValidationOptions = new RestApiOperationServerUrlValidationOptions
+ {
+ AllowedBaseUrls =
+ [
+ new Uri("https://my-key-vault.vault.azure.net"),
+ new Uri("https://server-override.com")
+ ]
+ }
+ };
this._openApiDocument = ResourcePluginsProvider.LoadFromResource("documentV2_0.json");
@@ -170,6 +181,12 @@ public async Task ItUsesOpenApiDocumentHostUrlWhenServerUrlIsNotProvidedAsync(st
using var httpClient = new HttpClient(messageHandlerStub, false);
this._executionParameters.HttpClient = httpClient;
+ // Permit the test scenario URLs (including http://localhost:3001/) under the
+ // secure-by-default SSRF policy by explicitly allowlisting the expected base.
+ this._executionParameters.ServerUrlValidationOptions = new RestApiOperationServerUrlValidationOptions
+ {
+ AllowedBaseUrls = [new Uri(expectedServerUrl)]
+ };
var arguments = this.GetFakeFunctionArguments();
diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiKernelPluginFactoryTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiKernelPluginFactoryTests.cs
index 7196b1f5c1fa..fe4f1f6d89e7 100644
--- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiKernelPluginFactoryTests.cs
+++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiKernelPluginFactoryTests.cs
@@ -35,7 +35,18 @@ public sealed class OpenApiKernelPluginFactoryTests
///
public OpenApiKernelPluginFactoryTests()
{
- this._executionParameters = new OpenApiFunctionExecutionParameters() { EnableDynamicPayload = false };
+ this._executionParameters = new OpenApiFunctionExecutionParameters()
+ {
+ EnableDynamicPayload = false,
+ ServerUrlValidationOptions = new RestApiOperationServerUrlValidationOptions
+ {
+ AllowedBaseUrls =
+ [
+ new Uri("https://my-key-vault.vault.azure.net"),
+ new Uri("https://server-override.com")
+ ]
+ }
+ };
this._openApiDocument = ResourcePluginsProvider.LoadFromResource("documentV2_0.json");
}
@@ -174,6 +185,12 @@ public async Task ItUsesOpenApiDocumentHostUrlWhenServerUrlIsNotProvidedAsync(st
using var httpClient = new HttpClient(messageHandlerStub, false);
this._executionParameters.HttpClient = httpClient;
+ // Permit the test scenario URLs (including http://localhost:3001/) under the
+ // secure-by-default SSRF policy by explicitly allowlisting the expected base.
+ this._executionParameters.ServerUrlValidationOptions = new RestApiOperationServerUrlValidationOptions
+ {
+ AllowedBaseUrls = [new Uri(expectedServerUrl)]
+ };
var arguments = new KernelArguments
{
diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs
index 19f6d5de45d9..809e43db4877 100644
--- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs
+++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs
@@ -40,6 +40,14 @@ public sealed class RestApiOperationRunnerTests : IDisposable
///
private readonly HttpClient _httpClient;
+ ///
+ /// Default validation options that allowlist the fake test host used by most tests.
+ ///
+ private readonly RestApiOperationServerUrlValidationOptions _defaultValidationOptions = new()
+ {
+ AllowedBaseUrls = [new Uri("https://fake-random-test-host")]
+ };
+
///
/// Creates an instance of a class.
///
@@ -91,7 +99,7 @@ public async Task ItCanRunCreateAndUpdateOperationsWithJsonPayloadSuccessfullyAs
{ "content-type", "application/json" }
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -161,7 +169,7 @@ public async Task ItCanRunCreateAndUpdateOperationsWithPlainTextPayloadSuccessfu
{ "content-type", "text/plain"}
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -238,7 +246,7 @@ public async Task ItShouldAddHeadersToHttpRequestAsync()
["X-HD-3"] = new DateTimeOffset(2023, 12, 06, 11, 53, 36, TimeSpan.FromHours(-2)),
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, userAgent: "fake-agent");
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, userAgent: "fake-agent", serverUrlValidationOptions: this._defaultValidationOptions);
// Act
await sut.RunAsync(operation, arguments);
@@ -294,7 +302,7 @@ public async Task ItShouldAddUserAgentHeaderToHttpRequestIfConfiguredAsync()
{ "fake-header", "fake-header-value" }
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, "fake-user-agent");
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, "fake-user-agent", serverUrlValidationOptions: this._defaultValidationOptions);
// Act
await sut.RunAsync(operation, arguments);
@@ -343,7 +351,7 @@ public async Task ItShouldBuildJsonPayloadDynamicallyAsync()
{ "enabled", true }
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -413,7 +421,7 @@ public async Task ItShouldBuildJsonPayloadDynamicallyUsingPayloadMetadataDataTyp
{ "params", "[1,2,3]" }
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -506,7 +514,8 @@ public async Task ItShouldBuildJsonPayloadDynamicallyResolvingArgumentsByFullNam
this._httpClient,
this._authenticationHandlerMock.Object,
enableDynamicPayload: true,
- enablePayloadNamespacing: true);
+ enablePayloadNamespacing: true,
+ serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -571,7 +580,8 @@ public async Task ItShouldThrowExceptionIfPayloadMetadataDoesNotHaveContentTypeA
var sut = new RestApiOperationRunner(
this._httpClient,
this._authenticationHandlerMock.Object,
- enableDynamicPayload: true);
+ enableDynamicPayload: true,
+ serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var exception = await Assert.ThrowsAsync(async () => await sut.RunAsync(operation, arguments));
@@ -600,7 +610,8 @@ public async Task ItShouldThrowExceptionIfContentTypeArgumentIsNotProvidedAsync(
var sut = new RestApiOperationRunner(
this._httpClient,
this._authenticationHandlerMock.Object,
- enableDynamicPayload: false);
+ enableDynamicPayload: false,
+ serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var exception = await Assert.ThrowsAsync(async () => await sut.RunAsync(operation, arguments));
@@ -633,7 +644,7 @@ public async Task ItShouldUsePayloadArgumentForPlainTextContentTypeWhenBuildingP
{ "payload", "fake-input-value" },
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -676,7 +687,7 @@ public async Task ItShouldUsePayloadAndContentTypeArgumentsIfDynamicPayloadBuild
{ "content-type", $"{contentType}" },
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: false);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: false, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -724,7 +735,8 @@ public async Task ItShouldBuildJsonPayloadDynamicallyExcludingOptionalParameters
this._httpClient,
this._authenticationHandlerMock.Object,
enableDynamicPayload: true,
- enablePayloadNamespacing: true);
+ enablePayloadNamespacing: true,
+ serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -772,7 +784,8 @@ public async Task ItShouldBuildJsonPayloadDynamicallyIncludingOptionalParameters
this._httpClient,
this._authenticationHandlerMock.Object,
enableDynamicPayload: true,
- enablePayloadNamespacing: true);
+ enablePayloadNamespacing: true,
+ serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -828,7 +841,7 @@ public async Task ItShouldAddRequiredQueryStringParametersIfTheirArgumentsProvid
{ "p2", 28 },
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -877,7 +890,7 @@ public async Task ItShouldAddNotRequiredQueryStringParametersIfTheirArgumentsPro
{ "p2", "v2" },
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -925,7 +938,7 @@ public async Task ItShouldSkipNotRequiredQueryStringParametersIfNoArgumentsProvi
{ "p2", "v2" }, //Providing argument for the required parameter only
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -962,7 +975,7 @@ public async Task ItShouldThrowExceptionIfNoArgumentProvidedForRequiredQueryStri
var arguments = new KernelArguments(); //Providing no arguments
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: this._defaultValidationOptions);
// Act and Assert
await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments));
@@ -998,7 +1011,7 @@ public async Task ItShouldReadContentAsStringSuccessfullyAsync(string contentTyp
{ "content-type", "application/json" }
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -1041,7 +1054,7 @@ public async Task ItShouldReadContentAsBytesSuccessfullyAsync(string contentType
{ "content-type", "application/json" }
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -1077,7 +1090,7 @@ public async Task ItShouldThrowExceptionForUnsupportedContentTypeAsync()
{ "content-type", "application/json" }
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: this._defaultValidationOptions);
// Act & Assert
var kernelException = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments));
@@ -1122,7 +1135,7 @@ public async Task ItShouldReturnRequestUriAndContentAsync()
{ "enabled", true }
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -1175,7 +1188,7 @@ public async Task ItShouldHandleNoContentAsync(System.Net.HttpStatusCode statusC
{ "enabled", true }
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments);
@@ -1232,7 +1245,7 @@ public async Task ItShouldSetHttpRequestMessageOptionsAsync()
KernelArguments = arguments,
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var result = await sut.RunAsync(operation, arguments, options);
@@ -1271,7 +1284,7 @@ public async Task ItShouldIncludeRequestDataWhenOperationExecutionFailsAsync(Typ
{ "content-type", "application/json" }
};
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: this._defaultValidationOptions);
// Act & Assert
var actualException = await Assert.ThrowsAsync(expectedExceptionType, () => sut.RunAsync(operation, arguments));
@@ -1316,7 +1329,7 @@ public async Task ItShouldUseCustomHttpResponseContentReaderAsync()
return await context.Response.Content.ReadAsStreamAsync(cancellationToken);
}
- var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, httpResponseContentReader: ReadHttpResponseContentAsync);
+ var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, httpResponseContentReader: ReadHttpResponseContentAsync, serverUrlValidationOptions: this._defaultValidationOptions);
// Act
var response = await sut.RunAsync(operation, [], cancellationToken: expectedCancellationToken);
@@ -1350,7 +1363,7 @@ public async Task ItShouldUseDefaultHttpResponseContentReaderIfCustomDoesNotRetu
return Task.FromResult