Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion dev/integration/tests/adapter/test_aiohttp_cloud_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,110 @@
from typing import cast

import pytest
from aiohttp import web

from microsoft_agents.activity import Activity, ConversationAccount
from microsoft_agents.hosting.aiohttp import CloudAdapter
from microsoft_agents.hosting.core import TurnContext
from microsoft_agents.hosting.core import OutboundHostValidator, TurnContext
from microsoft_agents.hosting.core.authorization import ClaimsIdentity, Connections
from microsoft_agents.hosting.core.connector.client import UserTokenClient


class RecordingAgent:
def __init__(self):
self.turn_count = 0

async def on_turn(self, context: TurnContext):
self.turn_count += 1


def _create_app(host_validator: OutboundHostValidator):
adapter = CloudAdapter(
connection_manager=cast(Connections, object()),
host_validator=host_validator,
)
agent = RecordingAgent()
app = web.Application()

async def messages(request: web.Request):
return await adapter.process(request, agent)

app.router.add_post("/api/messages", messages)
return app, agent


@pytest.mark.asyncio
@pytest.mark.parametrize(
("host_validator", "service_url", "expected_status", "expected_turn_count"),
[
pytest.param(
OutboundHostValidator(enabled=False),
"https://evil.example.com/relay",
202,
1,
id="disabled-allows-unknown-host",
),
pytest.param(
OutboundHostValidator(enabled=True),
"https://smba.trafficmanager.net/teams/",
202,
1,
id="enabled-allows-default-microsoft-host",
),
pytest.param(
OutboundHostValidator(enabled=True),
"https://evil.example.com/relay",
401,
0,
id="enabled-denies-unknown-host",
),
pytest.param(
OutboundHostValidator(
enabled=True,
hosts=["contoso.com"],
include_default_microsoft_hosts=False,
),
"https://api.contoso.com/messages",
202,
1,
id="enabled-allows-configured-host",
),
pytest.param(
OutboundHostValidator(
enabled=True,
hosts=["contoso.com"],
include_default_microsoft_hosts=False,
),
"https://graph.microsoft.com/v1.0",
401,
0,
id="enabled-without-defaults-denies-microsoft-host",
),
],
)
async def test_cloud_adapter_applies_outbound_host_validator(
aiohttp_client,
host_validator: OutboundHostValidator,
service_url: str,
expected_status: int,
expected_turn_count: int,
):
app, agent = _create_app(host_validator)
client = await aiohttp_client(app)

response = await client.post(
"/api/messages",
json={
"type": "message",
"conversation": {"id": "conversation-id"},
"serviceUrl": service_url,
},
)

assert response.status == expected_status
assert agent.turn_count == expected_turn_count


@pytest.mark.asyncio
@pytest.mark.parametrize(
"token_service_endpoint",
Expand Down
96 changes: 95 additions & 1 deletion dev/integration/tests/adapter/test_fastapi_cloud_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,108 @@
from typing import cast

import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient

from microsoft_agents.activity import Activity, ConversationAccount
from microsoft_agents.hosting.core import TurnContext
from microsoft_agents.hosting.core import OutboundHostValidator, TurnContext
from microsoft_agents.hosting.core.authorization import ClaimsIdentity, Connections
from microsoft_agents.hosting.core.connector.client import UserTokenClient
from microsoft_agents.hosting.fastapi import CloudAdapter


class RecordingAgent:
def __init__(self):
self.turn_count = 0

async def on_turn(self, context: TurnContext):
self.turn_count += 1


def _create_client(host_validator: OutboundHostValidator):
adapter = CloudAdapter(
connection_manager=cast(Connections, object()),
host_validator=host_validator,
)
agent = RecordingAgent()
app = FastAPI()

@app.post("/api/messages")
async def messages(request: Request):
return await adapter.process(request, agent)

return TestClient(app), agent


@pytest.mark.parametrize(
("host_validator", "service_url", "expected_status", "expected_turn_count"),
[
pytest.param(
OutboundHostValidator(enabled=False),
"https://evil.example.com/relay",
202,
1,
id="disabled-allows-unknown-host",
),
pytest.param(
OutboundHostValidator(enabled=True),
"https://smba.trafficmanager.net/teams/",
202,
1,
id="enabled-allows-default-microsoft-host",
),
pytest.param(
OutboundHostValidator(enabled=True),
"https://evil.example.com/relay",
401,
0,
id="enabled-denies-unknown-host",
),
pytest.param(
OutboundHostValidator(
enabled=True,
hosts=["contoso.com"],
include_default_microsoft_hosts=False,
),
"https://api.contoso.com/messages",
202,
1,
id="enabled-allows-configured-host",
),
pytest.param(
OutboundHostValidator(
enabled=True,
hosts=["contoso.com"],
include_default_microsoft_hosts=False,
),
"https://graph.microsoft.com/v1.0",
401,
0,
id="enabled-without-defaults-denies-microsoft-host",
),
],
)
def test_cloud_adapter_applies_outbound_host_validator(
host_validator: OutboundHostValidator,
service_url: str,
expected_status: int,
expected_turn_count: int,
):
client, agent = _create_client(host_validator)

response = client.post(
"/api/messages",
json={
"type": "message",
"conversation": {"id": "conversation-id"},
"serviceUrl": service_url,
},
)

assert response.status_code == expected_status
assert agent.turn_count == expected_turn_count


@pytest.mark.asyncio
@pytest.mark.parametrize(
"token_service_endpoint",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
from microsoft_agents.hosting.core.http import (
HttpResponse,
)
from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase
from microsoft_agents.hosting.core import (
ChannelServiceClientFactoryBase,
OutboundHostValidator,
)

from .agent_http_adapter import AgentHttpAdapter
from ._aiohttp_request_adapter import AiohttpRequestAdapter
Expand All @@ -25,6 +28,7 @@ def __init__(
connection_manager: Connections | None = None,
channel_service_client_factory: ChannelServiceClientFactoryBase | None = None,
channel_service_client_factory_options: dict | None = None,
host_validator: OutboundHostValidator | None = None,
):
"""
Initializes a new instance of the CloudAdapter class.
Expand All @@ -38,6 +42,7 @@ def __init__(
connection_manager=connection_manager,
channel_service_client_factory=channel_service_client_factory,
channel_service_client_factory_options=channel_service_client_factory_options,
host_validator=host_validator,
)

async def process(self, request: Request, agent: Agent) -> Optional[Response]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .middleware_set import Middleware, MiddlewareSet
from .rest_channel_service_client_factory import RestChannelServiceClientFactory
from .turn_context import TurnContext
from .outbound_host_validator import OutboundHostValidator

# HTTP abstractions
from .http import (
Expand Down Expand Up @@ -122,6 +123,7 @@
"Middleware",
"RestChannelServiceClientFactory",
"TurnContext",
"OutboundHostValidator",
"HttpRequestProtocol",
"HttpResponse",
"HttpResponseFactory",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from http import HTTPStatus
from traceback import format_exc

import logging

from microsoft_agents.activity import Activity, DeliveryModes
from microsoft_agents.hosting.core.telemetry.adapter import spans

Expand All @@ -20,6 +22,9 @@
from .message_factory import MessageFactory
from .rest_channel_service_client_factory import RestChannelServiceClientFactory
from .turn_context import TurnContext
from .outbound_host_validator import OutboundHostValidator, _try_create_url

logger = logging.getLogger(__name__)


class HttpAdapterBase(ChannelServiceAdapter, ABC):
Expand All @@ -35,6 +40,7 @@ def __init__(
connection_manager: Connections | None = None,
channel_service_client_factory: ChannelServiceClientFactoryBase | None = None,
channel_service_client_factory_options: dict | None = None,
host_validator: OutboundHostValidator | None = None,
):
"""Initialize the HTTP adapter.

Expand Down Expand Up @@ -73,6 +79,7 @@ async def on_turn_error(context: TurnContext, error: Exception):
connection_manager,
**(channel_service_client_factory_options or {}),
)
self._host_validator = host_validator or OutboundHostValidator()

super().__init__(factory)

Expand Down Expand Up @@ -126,6 +133,11 @@ async def process_request(
"Activity must have type and conversation.id"
)

if not self._validate_service_url(claims_identity, activity):
return HttpResponseFactory.unauthorized(
"Service URL is not allowed by the host validator."
)

try:
# Process the inbound activity with the agent
invoke_response = await self.process_activity(
Expand All @@ -151,3 +163,57 @@ async def process_request(

except PermissionError:
return HttpResponseFactory.unauthorized()

def _validate_service_url(
self, claims_identity: ClaimsIdentity, activity: Activity
) -> bool:
"""Validate the service URL against the claims identity.

Args:
claims_identity: The claims identity to validate against.
activity: The activity containing the service URL to validate.

Returns:
True if the service URL is valid, False otherwise.
"""

if (
self._host_validator.enabled
and activity.service_url
and not self._host_validator.is_allowed(activity.service_url)
):
logger.warning(
"Service URL %s is not allowed by the host validator.",
activity.service_url,
)
return False

if not claims_identity:
return True

claims_service_url = claims_identity.get_claim_value("serviceurl")
if activity.service_url and claims_service_url:
claim_url = _try_create_url(claims_service_url)
activity_url = _try_create_url(activity.service_url)
claim_url_host = (claim_url.host or "") if claim_url else ""
activity_url_host = (activity_url.host or "") if activity_url else ""
if (
not claim_url
or not activity_url
or claim_url_host.casefold() != activity_url_host.casefold()
):
Comment thread
rodrigobr-msft marked this conversation as resolved.
if self._host_validator and self._host_validator.enabled:
logger.warning(
"Service URL host mismatch: %s vs %s",
claim_url_host,
activity_url_host,
)
return False
else:
logger.warning(
"Service URL host mismatch (host validator disabled): %s vs %s",
claim_url_host,
activity_url_host,
)

return True
Loading
Loading