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
8 changes: 8 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
**Release Date:** Unreleased
**Previous Version:** 1.8.0 (Released 2026-10-01)

## Major Features & Enhancements

- **Outbound Host Validation Hardening**: Enhanced `OutboundHostValidator` with HTTPS-only URL enforcement, DNS resolution validation for public and optionally private network addresses, and an SSRF-aware `aiohttp` connector that validates resolved IPv4 and IPv6 addresses before connecting.

## Deprecation

- **Attachment Downloader HTTP Configuration**: Replaced custom `client_factory` injection with `client_session_kwargs`; the deprecated `client_factory` parameter is ignored. This ensures attachment requests always use the validator-managed middleware and connector, preventing custom client sessions from bypassing hostname allowlists, redirect validation, or resolved-address checks while still allowing supported `aiohttp.ClientSession` options to be configured.

## Bug Fixes

- **Connector User-Agent Headers**: Added the SDK `User-Agent` header to `MCSConnectorClient` and ensured `ConnectorClient`, `TeamsConnectorClient`, and `UserTokenClient` apply it to caller-provided `ClientSession` instances.
Expand Down
2 changes: 1 addition & 1 deletion libraries/microsoft-agents-hosting-aiohttp/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@
version=package_version,
install_requires=[
f"microsoft-agents-hosting-core=={package_version}",
"aiohttp>=3.11.11",
"aiohttp>=3.12",
],
)
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
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

# Security
from .security import OutboundHostValidator

# HTTP abstractions
from .http import (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
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
from .security import OutboundHostValidator, _try_create_url

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -80,7 +80,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()
self._host_validator = host_validator or OutboundHostValidator(enabled=False)

super().__init__(factory)

Expand Down Expand Up @@ -205,7 +205,7 @@ def _validate_service_url(
or not activity_url
or claim_url_host.casefold() != activity_url_host.casefold()
):
if self._host_validator and self._host_validator.enabled:
if self._host_validator.enabled:
logger.warning(
"Service URL host mismatch: %s vs %s",
claim_url_host,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
# Licensed under the MIT License.

import json
from typing import Callable
import logging
from typing import Any, Callable

import aiohttp

Expand All @@ -13,27 +14,40 @@
)

from microsoft_agents.hosting.core.turn_context import TurnContext
from microsoft_agents.hosting.core.outbound_host_validator import OutboundHostValidator
from microsoft_agents.hosting.core.security import (
OutboundHostValidator,
_SSRFError,
)

from .input_file import InputFileDownloader, InputFile
from ._utils import _parse_content_type, _basic_url_check

logger = logging.getLogger(__name__)


class AttachmentDownloader(InputFileDownloader):
"""Downloads attachments from a given turn context."""

def __init__(
self,
client_factory: Callable[[], aiohttp.ClientSession] | None = None,
host_validator: OutboundHostValidator | None = None,
*,
client_session_kwargs: dict | None = None,
client_factory: Any = None,
Comment thread
rodrigobr-msft marked this conversation as resolved.
):
"""Constructor for AttachmentDownloader.

:param client_factory: A callable that returns an aiohttp.ClientSession instance.
:param host_validator: An optional OutboundHostValidator instance.
:param client_session_kwargs: Optional keyword arguments for the aiohttp.ClientSession.
:param client_factory: (deprecated) A custom client factory, if any.
"""

self._client_factory = client_factory or aiohttp.ClientSession
if client_factory is not None:
logger.warning(
"The 'client_factory' parameter is deprecated and will be ignored."
)
Comment thread
rodrigobr-msft marked this conversation as resolved.

self._client_session_kwargs = client_session_kwargs or {}
self._host_validator = host_validator

async def download_files(self, context: TurnContext) -> list[InputFile]:
Expand All @@ -48,31 +62,36 @@ async def download_files(self, context: TurnContext) -> list[InputFile]:
if not context.activity.attachments:
return []

files: list[InputFile] = []
for attachment in context.activity.attachments:
file = await self._download_file(attachment)
if file:
files.append(file)
client_factory: Callable[[], aiohttp.ClientSession]
if self._host_validator is not None:
host_validator = self._host_validator # to capture in lambda
client_factory = lambda: host_validator.client(self._client_session_kwargs)
else:
client_factory = lambda: aiohttp.ClientSession(
**self._client_session_kwargs
)

async with client_factory() as client:
files: list[InputFile] = []
for attachment in context.activity.attachments:
file = await self._download_file(client, attachment)
if file:
files.append(file)
return files

async def _download_file(self, attachment: Attachment) -> InputFile | None:
async def _download_file(
self, client: aiohttp.ClientSession, attachment: Attachment
) -> InputFile | None:
"""Downloads a single file from the given attachment.

:param client: The aiohttp.ClientSession instance to use for downloading the file.
:param attachment: The attachment to download.
:return: An InputFile instance if the download is successful, None otherwise.
"""
if attachment.content_url and _basic_url_check(attachment.content_url):
remote_file_url = attachment.content_url

if (
self._host_validator
and self._host_validator.enabled
and not self._host_validator.is_allowed(remote_file_url)
):
return None

async with self._client_factory() as client:
try:
async with client.get(remote_file_url) as response:

if not (200 <= response.status < 300):
Expand All @@ -94,6 +113,11 @@ async def _download_file(self, attachment: Attachment) -> InputFile | None:
content_url=attachment.content_url,
filename=attachment.name,
)
except _SSRFError:
logger.warning(
"Outbound host validation failed for an attachment download URL."
)
Comment thread
Copilot marked this conversation as resolved.
return None
else:
content = bytes(json.dumps(attachment.content), "utf-8")
return InputFile(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,69 @@
# Licensed under the MIT License.

import json
import logging
import warnings

from typing import Callable, cast, Any

import aiohttp

from microsoft_agents.activity import (
Attachment,
Channels,
ChannelId,
)

from microsoft_agents.hosting.core.authorization import (
AccessTokenProviderBase,
Connections,
)
from microsoft_agents.hosting.core.turn_context import TurnContext
from microsoft_agents.hosting.core.outbound_host_validator import OutboundHostValidator
from microsoft_agents.hosting.core.security import (
OutboundHostValidator,
_SSRFError,
)

from .input_file import InputFileDownloader, InputFile
from ._utils import _parse_content_type, _basic_url_check

logger = logging.getLogger(__name__)


class M365AttachmentDownloader(InputFileDownloader):
"""Downloads attachments from M365/Teams using the configured Token Provider (from Connections)."""

def __init__(
self,
connections: Connections,
client_factory: Callable[[], aiohttp.ClientSession] | None = None,
host_validator: OutboundHostValidator | None = None,
*,
client_session_kwargs: dict[str, Any] | None = None,
Comment thread
rodrigobr-msft marked this conversation as resolved.
token_provider_name: str = "",
use_anonymous: bool = False,
scopes: list[str] | None = None,
client_factory: Any = None,
Comment thread
rodrigobr-msft marked this conversation as resolved.
):
"""Constructor for M365AttachmentDownloader.

:param connections: A Connections instance.
:param client_factory: A callable that returns an aiohttp.ClientSession instance.
:param host_validator: An optional OutboundHostValidator instance.
:param client_session_kwargs: Optional keyword arguments to pass to the aiohttp ClientSession constructor.
:param token_provider_name: The name of the token provider.
:param use_anonymous: Whether to use anonymous access.
:param scopes: A list of scopes for the access token.
:param connections: A Connections instance.
:param client_factory: (deprecated) A custom client factory, if any.
"""

if client_factory is not None:
warnings.warn(
"The 'client_factory' parameter is deprecated and will be ignored.",
DeprecationWarning,
stacklevel=2,
)

self._connections = connections
self._client_factory = client_factory or aiohttp.ClientSession
self._host_validator = host_validator
self._client_session_kwargs = client_session_kwargs or {}

self._token_provider_name = token_provider_name
self._use_anonymous = use_anonymous
Expand Down Expand Up @@ -109,19 +124,32 @@ async def download_files(self, context: TurnContext) -> list[InputFile]:
outgoing_audience_claim, self._scopes
)

files: list[InputFile] = []
for att in attachments:
file = await self._download_file(att, access_token)
if file:
files.append(file)
client_factory: Callable[[], aiohttp.ClientSession]
if self._host_validator is not None:
host_validator = self._host_validator # to
client_factory = lambda: host_validator.client(self._client_session_kwargs)
else:
client_factory = lambda: aiohttp.ClientSession(
**self._client_session_kwargs
)

async with client_factory() as client:
files: list[InputFile] = []
for att in attachments:
file = await self._download_file(client, att, access_token)
if file:
files.append(file)
return files

async def _download_file(
self, attachment: Attachment, access_token: str
self,
client: aiohttp.ClientSession,
attachment: Attachment,
access_token: str,
) -> InputFile | None:
"""Download a single file from the given attachment.

:param client: The aiohttp.ClientSession instance to use for downloading the file.
:param attachment: The Attachment instance.
:param access_token: The access token for authentication.
:return: An InputFile instance or None if the download fails.
Expand All @@ -136,16 +164,10 @@ async def _download_file(
download_url = attachment.content_url

if download_url and _basic_url_check(download_url):
if (
self._host_validator is not None
and self._host_validator.enabled
and not self._host_validator.is_allowed(download_url)
):
return None

async with self._client_factory() as client:
try:
async with client.get(
download_url, headers={"Authorization": f"Bearer {access_token}"}
download_url,
headers={"Authorization": f"Bearer {access_token}"},
) as response:
if not (200 <= response.status < 300):
return None
Expand All @@ -165,6 +187,11 @@ async def _download_file(
content_url=attachment.content_url,
filename=name,
)
except _SSRFError:
logger.warning(
"Outbound host validation failed for an attachment download URL."
)
Comment thread
Copilot marked this conversation as resolved.
return None
else:
content = bytes(json.dumps(attachment.content), "utf-8")
return InputFile(
Expand Down
Loading
Loading