mirror of
https://github.com/microsoft/semantic-kernel.git
synced 2026-10-12 02:19:06 +00:00
Python: .NET/Python: Share HTTP request validation utilities (#14546)
### Motivation and Context <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> Improve consistency and maintainability of HTTP request validation in the .NET and Python SDKs. ### Description <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> - Extract existing request validation into shared internal utilities and reuse them in the HTTP plugins. - Update HTTP plugin configuration and documentation. - Extend unit coverage for shared validation and plugin behavior. ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [x] The code builds clean without any errors or warnings - [ ] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [ ] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone 😄 --------- Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
co-authored by
Copilot App
parent
d5234384a0
commit
71b2193130
@@ -3,9 +3,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.SemanticKernel.Http;
|
||||
|
||||
namespace Microsoft.SemanticKernel.Plugins.OpenApi;
|
||||
|
||||
@@ -71,7 +71,11 @@ internal static class ServerUrlValidator
|
||||
return;
|
||||
}
|
||||
|
||||
await EnsurePublicHostAsync(url, cancellationToken, dnsResolver).ConfigureAwait(false);
|
||||
await PublicNetworkAddressValidator.ValidateAsync(
|
||||
url, cancellationToken, dnsResolver,
|
||||
"To allow this URL, add it to " +
|
||||
$"{nameof(RestApiOperationServerUrlValidationOptions)}.{nameof(RestApiOperationServerUrlValidationOptions.AllowedBaseUrls)} " +
|
||||
$"or set {nameof(RestApiOperationServerUrlValidationOptions)}.{nameof(RestApiOperationServerUrlValidationOptions.AllowPrivateNetworkAccess)} = true.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool TryMatchAllowedBaseUrl(Uri url, IReadOnlyList<Uri>? allowedBaseUrls)
|
||||
@@ -112,296 +116,4 @@ internal static class ServerUrlValidator
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task EnsurePublicHostAsync(
|
||||
Uri url,
|
||||
CancellationToken cancellationToken,
|
||||
Func<string, CancellationToken, Task<IPAddress[]>>? 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true and sets <paramref name="category"/> to a human-readable label when the
|
||||
/// supplied address is in a non-public IP range that should be blocked by default.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,24 +45,29 @@ public class ServerUrlValidatorTests
|
||||
[InlineData("::1", "loopback")]
|
||||
[InlineData("::", "unspecified")]
|
||||
[InlineData("fe80::1", "link-local")]
|
||||
[InlineData("febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff", "link-local")]
|
||||
[InlineData("fec0::", "site-local")]
|
||||
[InlineData("fec0::1", "site-local")]
|
||||
[InlineData("feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", "site-local")]
|
||||
[InlineData("fc00::1", "private (IPv6 ULA)")]
|
||||
[InlineData("fd00::1", "private (IPv6 ULA)")]
|
||||
[InlineData("ff00::", "multicast")]
|
||||
[InlineData("ff02::1", "multicast")]
|
||||
[InlineData("2001:db8::1", "reserved")]
|
||||
// IPv4-mapped IPv6 of a private address
|
||||
[InlineData("::ffff:127.0.0.1", "loopback")]
|
||||
[InlineData("::ffff:169.254.169.254", "link-local")]
|
||||
public void ItShouldClassifyNonPublicAddresses(string address, string expectedCategory)
|
||||
public async Task ItShouldRejectNonPublicAddressesAsync(string address, string expectedCategory)
|
||||
{
|
||||
// Arrange
|
||||
var ip = IPAddress.Parse(address);
|
||||
var url = new UriBuilder(Uri.UriSchemeHttps, address).Uri;
|
||||
|
||||
// Act
|
||||
var blocked = ServerUrlValidator.TryCategorizeNonPublicAddress(ip, out var category);
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
ServerUrlValidator.ValidateAsync(url, options: null));
|
||||
|
||||
// Assert
|
||||
Assert.True(blocked, $"Expected {address} to be classified as non-public.");
|
||||
Assert.Equal(expectedCategory, category);
|
||||
Assert.Contains($"host resolves to a {expectedCategory} address", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -78,16 +83,13 @@ public class ServerUrlValidatorTests
|
||||
[InlineData("100.128.0.1")] // just outside CGNAT
|
||||
// Public IPv6
|
||||
[InlineData("2606:4700:4700::1111")] // Cloudflare DNS
|
||||
public void ItShouldNotBlockPublicAddresses(string address)
|
||||
public async Task ItShouldNotBlockPublicAddressesAsync(string address)
|
||||
{
|
||||
// Arrange
|
||||
var ip = IPAddress.Parse(address);
|
||||
var url = new UriBuilder(Uri.UriSchemeHttps, address).Uri;
|
||||
|
||||
// Act
|
||||
var blocked = ServerUrlValidator.TryCategorizeNonPublicAddress(ip, out _);
|
||||
|
||||
// Assert
|
||||
Assert.False(blocked, $"Expected {address} to be treated as public.");
|
||||
// Act & Assert
|
||||
await ServerUrlValidator.ValidateAsync(url, options: null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -135,6 +137,20 @@ public class ServerUrlValidatorTests
|
||||
await ServerUrlValidator.ValidateAsync(url, options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItShouldPreserveExplicitHostnameAllowlistDnsBypassAsync()
|
||||
{
|
||||
var url = new Uri("http://trusted.example.com/v1/orders");
|
||||
var options = new RestApiOperationServerUrlValidationOptions
|
||||
{
|
||||
AllowedBaseUrls = [new Uri("http://trusted.example.com/v1")]
|
||||
};
|
||||
Task<IPAddress[]> UnexpectedResolver(string host, CancellationToken token) =>
|
||||
throw new InvalidOperationException("An explicitly trusted URL should not be resolved.");
|
||||
|
||||
await ServerUrlValidator.ValidateAsync(url, options, dnsResolver: UnexpectedResolver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItShouldAllowPublicHttpsHostByDefaultAsync()
|
||||
{
|
||||
@@ -143,27 +159,30 @@ public class ServerUrlValidatorTests
|
||||
await ServerUrlValidator.ValidateAsync(url, options: null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItShouldBypassPrivateGateWhenAllowPrivateNetworkAccessTrueAsync()
|
||||
[Theory]
|
||||
[InlineData("https://10.0.0.5/")]
|
||||
[InlineData("https://[fec0::1]/")]
|
||||
public async Task ItShouldBypassPrivateGateWhenAllowPrivateNetworkAccessTrueAsync(string url)
|
||||
{
|
||||
var url = new Uri("https://10.0.0.5/");
|
||||
var options = new RestApiOperationServerUrlValidationOptions { AllowPrivateNetworkAccess = true };
|
||||
|
||||
await ServerUrlValidator.ValidateAsync(url, options);
|
||||
await ServerUrlValidator.ValidateAsync(new Uri(url), options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItShouldBlockHostnameResolvingToPrivateIpAsync()
|
||||
[Theory]
|
||||
[InlineData("169.254.169.254", "link-local")]
|
||||
[InlineData("fec0::", "site-local")]
|
||||
[InlineData("fec0::1", "site-local")]
|
||||
[InlineData("feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", "site-local")]
|
||||
public async Task ItShouldBlockHostnameResolvingToPrivateIpAsync(string address, string expectedCategory)
|
||||
{
|
||||
// Simulates an attacker-controlled hostname (e.g., evil.com) resolving to the
|
||||
// cloud metadata address — the most realistic SSRF vector.
|
||||
var url = new Uri("https://evil.example.com/latest/meta-data/");
|
||||
Task<IPAddress[]> FakeResolver(string _, CancellationToken _1) =>
|
||||
Task.FromResult(new[] { IPAddress.Parse("169.254.169.254") });
|
||||
Task.FromResult(new[] { IPAddress.Parse(address) });
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
ServerUrlValidator.ValidateAsync(url, options: null, dnsResolver: FakeResolver));
|
||||
Assert.Contains("link-local", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(expectedCategory, ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -229,4 +248,38 @@ public class ServerUrlValidatorTests
|
||||
ServerUrlValidator.ValidateAsync(url, options: null, dnsResolver: FakeResolver));
|
||||
Assert.Contains("no addresses", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItShouldPropagateDnsCancellationAsync()
|
||||
{
|
||||
using var source = new CancellationTokenSource();
|
||||
source.Cancel();
|
||||
var url = new Uri("https://api.example.com/");
|
||||
Task<IPAddress[]> ResolveAsync(string host, CancellationToken token)
|
||||
{
|
||||
Assert.Equal(source.Token, token);
|
||||
return Task.FromCanceled<IPAddress[]>(token);
|
||||
}
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
ServerUrlValidator.ValidateAsync(url, options: null, cancellationToken: source.Token, dnsResolver: ResolveAsync));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://fa\u00df.test/", "xn--fa-hia.test")]
|
||||
[InlineData("https://b\u00fccher.test/", "xn--bcher-kva.test")]
|
||||
[InlineData("https://\uFF25\uFF38\uFF21\uFF2D\uFF30\uFF2C\uFF25.com/", "example.com")]
|
||||
public async Task ItShouldResolveTheSameAsciiHostAsHttpClientAsync(string url, string expectedHost)
|
||||
{
|
||||
string? resolvedHost = null;
|
||||
Task<IPAddress[]> ResolveAsync(string host, CancellationToken _)
|
||||
{
|
||||
resolvedHost = host;
|
||||
return Task.FromResult(new[] { IPAddress.Parse("93.184.216.34") });
|
||||
}
|
||||
|
||||
await ServerUrlValidator.ValidateAsync(new Uri(url), options: null, dnsResolver: ResolveAsync);
|
||||
|
||||
Assert.Equal(expectedHost, resolvedHost);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.SemanticKernel.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves and rejects non-public request destinations before an HTTP request is sent.
|
||||
/// This check does not pin the validated addresses to the subsequent HTTP connection.
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal static class PublicNetworkAddressValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates all addresses of a request host, failing closed on DNS errors.
|
||||
/// </summary>
|
||||
public static async Task ValidateAsync(
|
||||
Uri url,
|
||||
CancellationToken cancellationToken = default,
|
||||
Func<string, CancellationToken, Task<IPAddress[]>>? dnsResolver = null,
|
||||
string configurationHint = "")
|
||||
{
|
||||
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, configurationHint);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the ASCII host used by HttpClient. DnsSafeHost can contain Unicode IDN labels.
|
||||
host = url.IdnHost;
|
||||
|
||||
// Reject a mixed public/private DNS response as well as an entirely private one.
|
||||
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. " +
|
||||
configurationHint);
|
||||
}
|
||||
|
||||
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. " + configurationHint);
|
||||
}
|
||||
|
||||
foreach (var address in addresses)
|
||||
{
|
||||
EnsurePublicAddress(url, address, configurationHint);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true and sets <paramref name="category"/> to a human-readable label when the
|
||||
/// supplied address is in a non-public IP range that should be blocked by default.
|
||||
/// </summary>
|
||||
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 void EnsurePublicAddress(Uri url, IPAddress address, string configurationHint)
|
||||
{
|
||||
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). " +
|
||||
configurationHint);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// fec0::/10 - deprecated site-local.
|
||||
if (bytes[0] == 0xfe && (bytes[1] & 0xC0) == 0xC0)
|
||||
{
|
||||
category = "site-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
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,14 @@ namespace Microsoft.SemanticKernel.Plugins.Core;
|
||||
/// <para>
|
||||
/// The default HTTP client does not follow redirects to prevent bypassing the allow-list.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Allowed hosts are resolved before each request and non-public addresses are rejected unless
|
||||
/// <see cref="AllowPrivateNetworkAccess"/> is explicitly enabled. DNS failures are rejected.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This pre-request check does not pin the connection's IP address and does not
|
||||
/// protect against DNS changing between validation and connection.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1054:URI-like parameters should not be strings",
|
||||
Justification = "Semantic Kernel operates on strings")]
|
||||
@@ -66,6 +74,16 @@ public sealed class HttpPlugin
|
||||
set => this._allowedDomains = value is null ? null : new HashSet<string>(value, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether allowed domains may resolve to private, loopback, link-local,
|
||||
/// or other non-public addresses. Defaults to <see langword="false"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Enable only for trusted internal endpoints. Domain restrictions still apply and
|
||||
/// the default HTTP client still rejects redirects.
|
||||
/// </remarks>
|
||||
public bool AllowPrivateNetworkAccess { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP GET request to the specified URI and returns the response body as a string.
|
||||
/// </summary>
|
||||
@@ -147,6 +165,13 @@ public sealed class HttpPlugin
|
||||
throw new InvalidOperationException("Sending requests to the provided location is not allowed.");
|
||||
}
|
||||
|
||||
if (!this.AllowPrivateNetworkAccess)
|
||||
{
|
||||
await PublicNetworkAddressValidator.ValidateAsync(
|
||||
uri, cancellationToken,
|
||||
configurationHint: $"To allow trusted internal endpoints, set {nameof(this.AllowPrivateNetworkAccess)} = true.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(method, uri) { Content = requestContent };
|
||||
request.Headers.Add("User-Agent", HttpHeaderConstant.Values.UserAgent);
|
||||
request.Headers.Add(HttpHeaderConstant.Names.SemanticKernelVersion, HttpHeaderConstant.Values.GetAssemblyVersion(typeof(HttpPlugin)));
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace SemanticKernel.Plugins.UnitTests.Core;
|
||||
public sealed class HttpPluginTests : IDisposable
|
||||
{
|
||||
private readonly string _content = "hello world";
|
||||
private readonly string _uriString = "http://www.example.com";
|
||||
private readonly string _uriString = "http://1.1.1.1";
|
||||
|
||||
private readonly HttpResponseMessage _response = new()
|
||||
{
|
||||
@@ -44,7 +44,7 @@ public sealed class HttpPluginTests : IDisposable
|
||||
// Arrange
|
||||
var mockHandler = this.CreateMock();
|
||||
using var client = new HttpClient(mockHandler.Object);
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = ["www.example.com"] };
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = ["1.1.1.1"] };
|
||||
|
||||
// Act
|
||||
var result = await plugin.GetAsync(this._uriString);
|
||||
@@ -60,7 +60,7 @@ public sealed class HttpPluginTests : IDisposable
|
||||
// Arrange
|
||||
var mockHandler = this.CreateMock();
|
||||
using var client = new HttpClient(mockHandler.Object);
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = ["www.example.com"] };
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = ["1.1.1.1"] };
|
||||
|
||||
// Act
|
||||
var result = await plugin.PostAsync(this._uriString, this._content);
|
||||
@@ -76,7 +76,7 @@ public sealed class HttpPluginTests : IDisposable
|
||||
// Arrange
|
||||
var mockHandler = this.CreateMock();
|
||||
using var client = new HttpClient(mockHandler.Object);
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = ["www.example.com"] };
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = ["1.1.1.1"] };
|
||||
|
||||
// Act
|
||||
var result = await plugin.PutAsync(this._uriString, this._content);
|
||||
@@ -92,7 +92,7 @@ public sealed class HttpPluginTests : IDisposable
|
||||
// Arrange
|
||||
var mockHandler = this.CreateMock();
|
||||
using var client = new HttpClient(mockHandler.Object);
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = ["www.example.com"] };
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = ["1.1.1.1"] };
|
||||
|
||||
// Act
|
||||
var result = await plugin.DeleteAsync(this._uriString);
|
||||
@@ -141,7 +141,8 @@ public sealed class HttpPluginTests : IDisposable
|
||||
|
||||
var plugin = new HttpPlugin()
|
||||
{
|
||||
AllowedDomains = [server.BaseUri.Host]
|
||||
AllowedDomains = [server.BaseUri.Host],
|
||||
AllowPrivateNetworkAccess = true
|
||||
};
|
||||
|
||||
// Act & Assert - the plugin should throw because 302 is a non-success status
|
||||
@@ -149,6 +150,135 @@ public sealed class HttpPluginTests : IDisposable
|
||||
Assert.False(server.RedirectTargetContacted, "The redirect target should not have been contacted.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET")]
|
||||
[InlineData("POST")]
|
||||
[InlineData("PUT")]
|
||||
[InlineData("DELETE")]
|
||||
public async Task ItBlocksNonPublicAddressesBeforeSendingAsync(string method)
|
||||
{
|
||||
string[] addresses =
|
||||
[
|
||||
"169.254.169.254", "127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.0.1",
|
||||
"100.64.0.1", "0.0.0.0", "224.0.0.1", "198.18.0.1", "192.0.2.1",
|
||||
"::1", "fe80::1", "fec0::", "fec0::1", "feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
|
||||
"fc00::1", "ff02::1", "::ffff:169.254.169.254"
|
||||
];
|
||||
|
||||
foreach (var address in addresses)
|
||||
{
|
||||
var mockHandler = this.CreateMock();
|
||||
using var client = new HttpClient(mockHandler.Object);
|
||||
var host = address.Contains(':') ? $"[{address}]" : address;
|
||||
var uri = new Uri($"http://{host}/");
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = [uri.Host] };
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => SendAsync(plugin, method, uri.AbsoluteUri));
|
||||
|
||||
Assert.Contains("host resolves to", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("AllowPrivateNetworkAccess", exception.Message, StringComparison.Ordinal);
|
||||
VerifyNoRequest(mockHandler);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.1.1.1")]
|
||||
[InlineData("[2606:4700:4700::1111]")]
|
||||
public async Task ItAllowsPublicLiteralAddressesAsync(string address)
|
||||
{
|
||||
var mockHandler = this.CreateMock();
|
||||
using var client = new HttpClient(mockHandler.Object);
|
||||
var uri = new Uri($"http://{address}/");
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = [uri.Host] };
|
||||
|
||||
Assert.Equal(this._content, await plugin.GetAsync(uri.AbsoluteUri));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "127.0.0.1")]
|
||||
[InlineData("POST", "127.0.0.1")]
|
||||
[InlineData("PUT", "127.0.0.1")]
|
||||
[InlineData("DELETE", "127.0.0.1")]
|
||||
[InlineData("GET", "[fec0::1]")]
|
||||
[InlineData("POST", "[fec0::1]")]
|
||||
[InlineData("PUT", "[fec0::1]")]
|
||||
[InlineData("DELETE", "[fec0::1]")]
|
||||
[InlineData("GET", "unresolved.example.invalid")]
|
||||
[InlineData("POST", "unresolved.example.invalid")]
|
||||
[InlineData("PUT", "unresolved.example.invalid")]
|
||||
[InlineData("DELETE", "unresolved.example.invalid")]
|
||||
public async Task ItAllowsExplicitPrivateNetworkAccessAsync(string method, string host)
|
||||
{
|
||||
var mockHandler = this.CreateMock();
|
||||
using var client = new HttpClient(mockHandler.Object);
|
||||
var plugin = new HttpPlugin(client)
|
||||
{
|
||||
AllowedDomains = [host],
|
||||
AllowPrivateNetworkAccess = true
|
||||
};
|
||||
|
||||
Assert.Equal(this._content, await SendAsync(plugin, method, $"http://{host}/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItStillRestrictsDomainsWithPrivateNetworkAccessEnabledAsync()
|
||||
{
|
||||
var mockHandler = this.CreateMock();
|
||||
using var client = new HttpClient(mockHandler.Object);
|
||||
var plugin = new HttpPlugin(client)
|
||||
{
|
||||
AllowedDomains = ["www.example.com"],
|
||||
AllowPrivateNetworkAccess = true
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.GetAsync("http://127.0.0.1/"));
|
||||
VerifyNoRequest(mockHandler);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItRejectsDisallowedDomainBeforeSendingAsync()
|
||||
{
|
||||
var mockHandler = this.CreateMock();
|
||||
using var client = new HttpClient(mockHandler.Object);
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = ["www.example.com"] };
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.GetAsync("http://notallowed.example.invalid/"));
|
||||
Assert.Equal("Sending requests to the provided location is not allowed.", exception.Message);
|
||||
VerifyNoRequest(mockHandler);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET")]
|
||||
[InlineData("POST")]
|
||||
[InlineData("PUT")]
|
||||
[InlineData("DELETE")]
|
||||
public async Task ItBlocksAllowedLocalhostUsingDefaultDnsResolverAsync(string method)
|
||||
{
|
||||
var mockHandler = this.CreateMock();
|
||||
using var client = new HttpClient(mockHandler.Object);
|
||||
var plugin = new HttpPlugin(client) { AllowedDomains = ["localhost"] };
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => SendAsync(plugin, method, "http://localhost/"));
|
||||
|
||||
Assert.Contains("loopback", exception.Message, StringComparison.Ordinal);
|
||||
VerifyNoRequest(mockHandler);
|
||||
}
|
||||
|
||||
private static Task<string> SendAsync(HttpPlugin plugin, string method, string uri) =>
|
||||
method switch
|
||||
{
|
||||
"GET" => plugin.GetAsync(uri),
|
||||
"POST" => plugin.PostAsync(uri, "body"),
|
||||
"PUT" => plugin.PutAsync(uri, "body"),
|
||||
"DELETE" => plugin.DeleteAsync(uri),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(method))
|
||||
};
|
||||
|
||||
private static void VerifyNoRequest(Mock<HttpMessageHandler> mockHandler) =>
|
||||
mockHandler.Protected().Verify(
|
||||
"SendAsync", Times.Never(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
|
||||
|
||||
private Mock<HttpMessageHandler> CreateMock()
|
||||
{
|
||||
var mockHandler = new Mock<HttpMessageHandler>();
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Any
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
|
||||
@@ -11,8 +8,7 @@ from pydantic import Field
|
||||
|
||||
from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
|
||||
DnsResolver = Callable[[str], Awaitable[Sequence[str | ipaddress.IPv4Address | ipaddress.IPv6Address]]]
|
||||
from semantic_kernel.utils.public_network_address_validator import DnsResolver, ensure_public_host
|
||||
|
||||
DEFAULT_ALLOWED_SCHEME = "https"
|
||||
|
||||
@@ -70,22 +66,14 @@ async def validate_server_url(
|
||||
if options.allow_private_network_access:
|
||||
return []
|
||||
|
||||
return await _ensure_public_host(parsed_url, dns_resolver)
|
||||
|
||||
|
||||
def try_categorize_non_public_address(
|
||||
address: str | ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
) -> tuple[bool, str]:
|
||||
"""Return whether an IP address is non-public and the category when blocked."""
|
||||
ip_address = ipaddress.ip_address(address)
|
||||
|
||||
if isinstance(ip_address, ipaddress.IPv6Address) and ip_address.ipv4_mapped:
|
||||
ip_address = ip_address.ipv4_mapped
|
||||
|
||||
if isinstance(ip_address, ipaddress.IPv4Address):
|
||||
return _try_classify_ipv4(ip_address)
|
||||
|
||||
return _try_classify_ipv6(ip_address)
|
||||
return await ensure_public_host(
|
||||
parsed_url,
|
||||
dns_resolver,
|
||||
configuration_hint=(
|
||||
"To allow this URL, add it to server_url_validation_allowed_base_urls or set "
|
||||
"allow_private_network_access=True."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_absolute_url(url: str, option_name: str = "url") -> ParseResult:
|
||||
@@ -134,121 +122,3 @@ def _matches_path_prefix(url_path: str, base_path: str) -> bool:
|
||||
|
||||
base_path_with_slash = base_path if base_path.endswith("/") else f"{base_path}/"
|
||||
return url_path.lower().startswith(base_path_with_slash.lower())
|
||||
|
||||
|
||||
async def _ensure_public_host(
|
||||
parsed_url: ParseResult, dns_resolver: DnsResolver | None
|
||||
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
host = parsed_url.hostname
|
||||
if host is None:
|
||||
raise FunctionExecutionException(f"The request URI '{parsed_url.geturl()}' does not contain a valid host.")
|
||||
|
||||
try:
|
||||
ip_address = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
addresses = await _resolve_host(host, dns_resolver)
|
||||
else:
|
||||
_ensure_public_address(parsed_url.geturl(), ip_address)
|
||||
# A literal IP address cannot be rebound between validation and connection.
|
||||
return []
|
||||
|
||||
if not addresses:
|
||||
raise FunctionExecutionException(
|
||||
f"The request URI '{parsed_url.geturl()}' is not allowed: DNS resolution for host "
|
||||
f"'{host}' returned no addresses. The request is blocked as a precaution."
|
||||
)
|
||||
|
||||
for address in addresses:
|
||||
_ensure_public_address(parsed_url.geturl(), address)
|
||||
return addresses
|
||||
|
||||
|
||||
async def _resolve_host(
|
||||
host: str,
|
||||
dns_resolver: DnsResolver | None,
|
||||
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
try:
|
||||
if dns_resolver:
|
||||
resolved_addresses = await dns_resolver(host)
|
||||
return [ipaddress.ip_address(address) for address in resolved_addresses]
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
addr_info = await loop.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise FunctionExecutionException(
|
||||
f"The request URI host '{host}' is not allowed: DNS resolution failed. "
|
||||
"The request is blocked as a precaution to prevent potential access to private network addresses."
|
||||
) from exc
|
||||
|
||||
addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
||||
seen_addresses: set[str] = set()
|
||||
for family, _, _, _, sockaddr in addr_info:
|
||||
if family not in (socket.AF_INET, socket.AF_INET6):
|
||||
continue
|
||||
address = ipaddress.ip_address(sockaddr[0])
|
||||
address_string = str(address)
|
||||
if address_string not in seen_addresses:
|
||||
addresses.append(address)
|
||||
seen_addresses.add(address_string)
|
||||
return addresses
|
||||
|
||||
|
||||
def _ensure_public_address(url: str, address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> None:
|
||||
blocked, category = try_categorize_non_public_address(address)
|
||||
if blocked:
|
||||
raise FunctionExecutionException(
|
||||
f"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 server_url_validation_allowed_base_urls or set "
|
||||
"allow_private_network_access=True."
|
||||
)
|
||||
|
||||
|
||||
def _try_classify_ipv4(address: ipaddress.IPv4Address) -> tuple[bool, str]:
|
||||
b0, b1, b2, _ = address.packed
|
||||
|
||||
if b0 == 0:
|
||||
return True, "unspecified"
|
||||
if b0 == 10:
|
||||
return True, "private (RFC1918)"
|
||||
if b0 == 127:
|
||||
return True, "loopback"
|
||||
if b0 == 169 and b1 == 254:
|
||||
return True, "link-local"
|
||||
if b0 == 172 and 16 <= b1 <= 31:
|
||||
return True, "private (RFC1918)"
|
||||
if b0 == 192 and b1 == 168:
|
||||
return True, "private (RFC1918)"
|
||||
if b0 == 100 and 64 <= b1 <= 127:
|
||||
return True, "carrier-grade NAT"
|
||||
if b0 == 198 and b1 in (18, 19):
|
||||
return True, "benchmarking"
|
||||
if b0 == 192 and b1 == 0 and b2 in (0, 2):
|
||||
return True, "reserved"
|
||||
if b0 == 198 and b1 == 51 and b2 == 100:
|
||||
return True, "reserved"
|
||||
if b0 == 203 and b1 == 0 and b2 == 113:
|
||||
return True, "reserved"
|
||||
if 224 <= b0 <= 239:
|
||||
return True, "multicast"
|
||||
if b0 >= 240:
|
||||
return True, "reserved"
|
||||
|
||||
return False, ""
|
||||
|
||||
|
||||
def _try_classify_ipv6(address: ipaddress.IPv6Address) -> tuple[bool, str]:
|
||||
if address.is_loopback:
|
||||
return True, "loopback"
|
||||
if address.is_unspecified:
|
||||
return True, "unspecified"
|
||||
if address.is_link_local:
|
||||
return True, "link-local"
|
||||
if address in ipaddress.ip_network("fc00::/7"):
|
||||
return True, "private (IPv6 ULA)"
|
||||
if address.is_multicast:
|
||||
return True, "multicast"
|
||||
if address in ipaddress.ip_network("2001:db8::/32"):
|
||||
return True, "reserved"
|
||||
|
||||
return False, ""
|
||||
|
||||
@@ -6,10 +6,12 @@ from typing import Annotated, Any, ClassVar
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
from yarl import URL
|
||||
|
||||
from semantic_kernel.exceptions import FunctionExecutionException
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
from semantic_kernel.utils.public_network_address_validator import ensure_public_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,13 +44,26 @@ class HttpPlugin(KernelBaseModel):
|
||||
- Only standard ports (80, 443) are permitted by default. Set ``allowed_ports``
|
||||
to permit additional ports. Port validation is skipped when
|
||||
``allow_all_domains`` is True.
|
||||
- Allowed hosts are normalized using aiohttp's IDNA rules before DNS validation,
|
||||
and the same normalized URL is used for the request. Non-public addresses and
|
||||
DNS failures are blocked unless ``allow_private_network_access`` is True.
|
||||
``allow_all_domains=True`` also bypasses this check.
|
||||
- This pre-request check does not pin the connection's IP address and does not
|
||||
protect against DNS changing between validation and connection.
|
||||
"""
|
||||
|
||||
allowed_domains: set[str] | None = None
|
||||
"""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."""
|
||||
"""Allow any domain, port, and network address, with redirects. Must be explicitly set."""
|
||||
|
||||
allow_private_network_access: bool = False
|
||||
"""Permit non-public addresses for allowed domains. Enable only for trusted internal endpoints.
|
||||
|
||||
Domain and port restrictions still apply, and redirects remain disabled unless
|
||||
``allow_all_domains`` is True.
|
||||
"""
|
||||
|
||||
allowed_ports: set[int] | None = None
|
||||
"""Set of ports permitted for outbound requests. Defaults to ``{80, 443}`` when not set.
|
||||
@@ -126,20 +141,24 @@ class HttpPlugin(KernelBaseModel):
|
||||
# Default: deny all
|
||||
return False
|
||||
|
||||
def _validate_url(self, url: str) -> None:
|
||||
"""Validate the URL before sending a request.
|
||||
async def _validate_url(self, url: str) -> URL:
|
||||
"""Validate and normalize the URL before sending a request.
|
||||
|
||||
Always checks that the URL is non-empty, uses an allowed scheme, and has a
|
||||
syntactically valid port. When ``allow_all_domains`` is False, additionally
|
||||
enforces the port and domain allow-lists.
|
||||
enforces the port and domain allow-lists. Non-public addresses and DNS failures
|
||||
are rejected unless private-network access or unrestricted access is enabled.
|
||||
|
||||
Args:
|
||||
url: The URL to validate.
|
||||
|
||||
Returns:
|
||||
The normalized URL to use for the request.
|
||||
|
||||
Raises:
|
||||
FunctionExecutionException: If the URL is empty, uses a disallowed scheme,
|
||||
has a malformed port, or (unless ``allow_all_domains`` is True) targets
|
||||
a port or domain that is not allowed.
|
||||
a port or domain that is not allowed, or resolves to a prohibited address.
|
||||
"""
|
||||
if not url:
|
||||
raise FunctionExecutionException("url cannot be `None` or empty")
|
||||
@@ -147,6 +166,15 @@ class HttpPlugin(KernelBaseModel):
|
||||
if not self._is_uri_allowed(url):
|
||||
raise FunctionExecutionException("Sending requests to the provided location is not allowed.")
|
||||
|
||||
request_url = URL(url)
|
||||
if not self.allow_all_domains and not self.allow_private_network_access:
|
||||
await ensure_public_host(
|
||||
urlparse(str(request_url)),
|
||||
configuration_hint="To allow trusted internal endpoints, set allow_private_network_access=True.",
|
||||
)
|
||||
|
||||
return request_url
|
||||
|
||||
@kernel_function(description="Makes a GET request to a url", name="getAsync")
|
||||
async def get(self, url: Annotated[str, "The URL to send the request to."]) -> str:
|
||||
"""Sends an HTTP GET request to the specified URI and returns the response body as a string.
|
||||
@@ -157,11 +185,11 @@ class HttpPlugin(KernelBaseModel):
|
||||
Returns:
|
||||
The response body as a string.
|
||||
"""
|
||||
self._validate_url(url)
|
||||
request_url = await self._validate_url(url)
|
||||
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.get(url, raise_for_status=True, allow_redirects=self._allow_redirects) as response,
|
||||
session.get(request_url, raise_for_status=True, allow_redirects=self._allow_redirects) as response,
|
||||
):
|
||||
return await response.text()
|
||||
|
||||
@@ -179,14 +207,14 @@ class HttpPlugin(KernelBaseModel):
|
||||
returns:
|
||||
The response body as a string.
|
||||
"""
|
||||
self._validate_url(url)
|
||||
request_url = await self._validate_url(url)
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
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
|
||||
request_url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects
|
||||
) as response,
|
||||
):
|
||||
return await response.text()
|
||||
@@ -206,14 +234,14 @@ class HttpPlugin(KernelBaseModel):
|
||||
Returns:
|
||||
The response body as a string.
|
||||
"""
|
||||
self._validate_url(url)
|
||||
request_url = await self._validate_url(url)
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
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
|
||||
request_url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects
|
||||
) as response,
|
||||
):
|
||||
return await response.text()
|
||||
@@ -228,10 +256,10 @@ class HttpPlugin(KernelBaseModel):
|
||||
Returns:
|
||||
The response body as a string.
|
||||
"""
|
||||
self._validate_url(url)
|
||||
request_url = await self._validate_url(url)
|
||||
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.delete(url, raise_for_status=True, allow_redirects=self._allow_redirects) as response,
|
||||
session.delete(request_url, raise_for_status=True, allow_redirects=self._allow_redirects) as response,
|
||||
):
|
||||
return await response.text()
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from urllib.parse import ParseResult
|
||||
|
||||
from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException
|
||||
|
||||
DnsResolver = Callable[[str], Awaitable[Sequence[str | ipaddress.IPv4Address | ipaddress.IPv6Address]]]
|
||||
|
||||
|
||||
def try_categorize_non_public_address(
|
||||
address: str | ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
) -> tuple[bool, str]:
|
||||
"""Return whether an IP address is non-public and the category when blocked."""
|
||||
ip_address = ipaddress.ip_address(address)
|
||||
|
||||
if isinstance(ip_address, ipaddress.IPv6Address) and ip_address.ipv4_mapped:
|
||||
ip_address = ip_address.ipv4_mapped
|
||||
|
||||
if isinstance(ip_address, ipaddress.IPv4Address):
|
||||
return _try_classify_ipv4(ip_address)
|
||||
|
||||
return _try_classify_ipv6(ip_address)
|
||||
|
||||
|
||||
async def ensure_public_host(
|
||||
parsed_url: ParseResult,
|
||||
dns_resolver: DnsResolver | None = None,
|
||||
configuration_hint: str = "",
|
||||
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
"""Reject non-public destinations and return vetted DNS addresses in resolver order.
|
||||
|
||||
The caller is responsible for binding the returned addresses to the connection.
|
||||
Literal IP hosts are validated but return an empty list because there is no DNS lookup to pin.
|
||||
"""
|
||||
host = parsed_url.hostname
|
||||
if host is None:
|
||||
raise FunctionExecutionException(f"The request URI '{parsed_url.geturl()}' does not contain a valid host.")
|
||||
|
||||
try:
|
||||
ip_address = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
addresses = await _resolve_host(host, dns_resolver)
|
||||
else:
|
||||
_ensure_public_address(parsed_url.geturl(), ip_address, configuration_hint)
|
||||
return []
|
||||
|
||||
if not addresses:
|
||||
raise FunctionExecutionException(
|
||||
f"The request URI '{parsed_url.geturl()}' is not allowed: DNS resolution for host "
|
||||
f"'{host}' returned no addresses. The request is blocked as a precaution."
|
||||
)
|
||||
|
||||
for address in addresses:
|
||||
_ensure_public_address(parsed_url.geturl(), address, configuration_hint)
|
||||
return addresses
|
||||
|
||||
|
||||
async def _resolve_host(
|
||||
host: str,
|
||||
dns_resolver: DnsResolver | None,
|
||||
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
try:
|
||||
if dns_resolver:
|
||||
resolved_addresses = await dns_resolver(host)
|
||||
return [ipaddress.ip_address(address) for address in resolved_addresses]
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
addr_info = await loop.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise FunctionExecutionException(
|
||||
f"The request URI host '{host}' is not allowed: DNS resolution failed. "
|
||||
"The request is blocked as a precaution to prevent potential access to private network addresses."
|
||||
) from exc
|
||||
|
||||
addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
||||
seen_addresses: set[str] = set()
|
||||
for family, _, _, _, sockaddr in addr_info:
|
||||
if family not in (socket.AF_INET, socket.AF_INET6):
|
||||
continue
|
||||
address = ipaddress.ip_address(sockaddr[0])
|
||||
address_string = str(address)
|
||||
if address_string not in seen_addresses:
|
||||
addresses.append(address)
|
||||
seen_addresses.add(address_string)
|
||||
return addresses
|
||||
|
||||
|
||||
def _ensure_public_address(
|
||||
url: str,
|
||||
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
configuration_hint: str,
|
||||
) -> None:
|
||||
blocked, category = try_categorize_non_public_address(address)
|
||||
if blocked:
|
||||
raise FunctionExecutionException(
|
||||
f"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). "
|
||||
f"{configuration_hint}"
|
||||
)
|
||||
|
||||
|
||||
def _try_classify_ipv4(address: ipaddress.IPv4Address) -> tuple[bool, str]:
|
||||
b0, b1, b2, _ = address.packed
|
||||
|
||||
if b0 == 0:
|
||||
return True, "unspecified"
|
||||
if b0 == 10:
|
||||
return True, "private (RFC1918)"
|
||||
if b0 == 127:
|
||||
return True, "loopback"
|
||||
if b0 == 169 and b1 == 254:
|
||||
return True, "link-local"
|
||||
if b0 == 172 and 16 <= b1 <= 31:
|
||||
return True, "private (RFC1918)"
|
||||
if b0 == 192 and b1 == 168:
|
||||
return True, "private (RFC1918)"
|
||||
if b0 == 100 and 64 <= b1 <= 127:
|
||||
return True, "carrier-grade NAT"
|
||||
if b0 == 198 and b1 in (18, 19):
|
||||
return True, "benchmarking"
|
||||
if b0 == 192 and b1 == 0 and b2 in (0, 2):
|
||||
return True, "reserved"
|
||||
if b0 == 198 and b1 == 51 and b2 == 100:
|
||||
return True, "reserved"
|
||||
if b0 == 203 and b1 == 0 and b2 == 113:
|
||||
return True, "reserved"
|
||||
if 224 <= b0 <= 239:
|
||||
return True, "multicast"
|
||||
if b0 >= 240:
|
||||
return True, "reserved"
|
||||
|
||||
return False, ""
|
||||
|
||||
|
||||
def _try_classify_ipv6(address: ipaddress.IPv6Address) -> tuple[bool, str]:
|
||||
if address.is_loopback:
|
||||
return True, "loopback"
|
||||
if address.is_unspecified:
|
||||
return True, "unspecified"
|
||||
if address.is_link_local:
|
||||
return True, "link-local"
|
||||
if address.is_site_local:
|
||||
return True, "site-local"
|
||||
if address in ipaddress.ip_network("fc00::/7"):
|
||||
return True, "private (IPv6 ULA)"
|
||||
if address.is_multicast:
|
||||
return True, "multicast"
|
||||
if address in ipaddress.ip_network("2001:db8::/32"):
|
||||
return True, "reserved"
|
||||
|
||||
return False, ""
|
||||
@@ -10,10 +10,8 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.openapi_runner import OpenApiRunner
|
||||
from semantic_kernel.connectors.openapi_plugin.server_url_validator import (
|
||||
ServerUrlValidationOptions,
|
||||
try_categorize_non_public_address,
|
||||
)
|
||||
from semantic_kernel.connectors.openapi_plugin.server_url_validator import ServerUrlValidationOptions
|
||||
from semantic_kernel.utils.public_network_address_validator import try_categorize_non_public_address
|
||||
|
||||
HOST = "rebind.example"
|
||||
PUBLIC_ADDRESS = "93.184.216.34"
|
||||
|
||||
@@ -7,10 +7,10 @@ import pytest
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.server_url_validator import (
|
||||
ServerUrlValidationOptions,
|
||||
try_categorize_non_public_address,
|
||||
validate_server_url,
|
||||
)
|
||||
from semantic_kernel.exceptions import FunctionExecutionException
|
||||
from semantic_kernel.utils.public_network_address_validator import try_categorize_non_public_address
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -38,8 +38,13 @@ from semantic_kernel.exceptions import FunctionExecutionException
|
||||
("::1", "loopback"),
|
||||
("::", "unspecified"),
|
||||
("fe80::1", "link-local"),
|
||||
("febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff", "link-local"),
|
||||
("fec0::", "site-local"),
|
||||
("fec0::1", "site-local"),
|
||||
("feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", "site-local"),
|
||||
("fc00::1", "private (IPv6 ULA)"),
|
||||
("fd00::1", "private (IPv6 ULA)"),
|
||||
("ff00::", "multicast"),
|
||||
("ff02::1", "multicast"),
|
||||
("2001:db8::1", "reserved"),
|
||||
("::ffff:127.0.0.1", "loopback"),
|
||||
@@ -85,6 +90,12 @@ async def test_validate_server_url_rejects_literal_loopback_ipv6():
|
||||
await validate_server_url("https://[::1]/")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("address", ["fec0::", "fec0::1", "feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"])
|
||||
async def test_validate_server_url_rejects_literal_site_local_ipv6(address):
|
||||
with pytest.raises(FunctionExecutionException, match="site-local"):
|
||||
await validate_server_url(f"https://[{address}]/")
|
||||
|
||||
|
||||
async def test_validate_server_url_rejects_http_scheme_by_default():
|
||||
with pytest.raises(FunctionExecutionException, match="scheme"):
|
||||
await validate_server_url("http://api.example.com/")
|
||||
@@ -105,6 +116,15 @@ async def test_validate_server_url_allows_explicit_base_url_for_private_http_add
|
||||
await validate_server_url("http://192.168.1.100/v1/orders", options)
|
||||
|
||||
|
||||
async def test_validate_server_url_preserves_explicit_hostname_allowlist_dns_bypass():
|
||||
options = ServerUrlValidationOptions(allowed_base_urls=["http://trusted.example.com/v1"])
|
||||
|
||||
async def unexpected_resolver(host: str):
|
||||
raise AssertionError("An explicitly trusted URL should not be resolved.")
|
||||
|
||||
await validate_server_url("http://trusted.example.com/v1/orders", options, dns_resolver=unexpected_resolver)
|
||||
|
||||
|
||||
async def test_validate_server_url_rejects_when_allowed_base_urls_do_not_match():
|
||||
options = ServerUrlValidationOptions(allowed_base_urls=["https://api.example.com/v1"])
|
||||
|
||||
@@ -112,18 +132,28 @@ async def test_validate_server_url_rejects_when_allowed_base_urls_do_not_match()
|
||||
await validate_server_url("https://api.example.com/v2/orders", options)
|
||||
|
||||
|
||||
async def test_validate_server_url_allows_private_network_access_after_scheme_gate():
|
||||
@pytest.mark.parametrize("url", ["https://10.0.0.5/", "https://[fec0::1]/"])
|
||||
async def test_validate_server_url_allows_private_network_access_after_scheme_gate(url):
|
||||
options = ServerUrlValidationOptions(allow_private_network_access=True)
|
||||
|
||||
await validate_server_url("https://10.0.0.5/", options)
|
||||
await validate_server_url(url, options)
|
||||
|
||||
|
||||
async def test_validate_server_url_blocks_hostname_resolving_to_link_local():
|
||||
@pytest.mark.parametrize(
|
||||
("address", "expected_category"),
|
||||
[
|
||||
("169.254.169.254", "link-local"),
|
||||
("fec0::", "site-local"),
|
||||
("fec0::1", "site-local"),
|
||||
("feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", "site-local"),
|
||||
],
|
||||
)
|
||||
async def test_validate_server_url_blocks_hostname_resolving_to_non_public_address(address, expected_category):
|
||||
async def fake_resolver(host: str):
|
||||
assert host == "evil.example.com"
|
||||
return ["169.254.169.254"]
|
||||
return [address]
|
||||
|
||||
with pytest.raises(FunctionExecutionException, match="link-local"):
|
||||
with pytest.raises(FunctionExecutionException, match=expected_category):
|
||||
await validate_server_url("https://evil.example.com/latest/meta-data/", dns_resolver=fake_resolver)
|
||||
|
||||
|
||||
@@ -171,14 +201,20 @@ async def test_validate_server_url_blocks_empty_dns_response():
|
||||
await validate_server_url("https://empty-dns.example.com/", dns_resolver=fake_resolver)
|
||||
|
||||
|
||||
async def test_validate_server_url_returns_validated_addresses_for_pinning():
|
||||
@pytest.mark.parametrize(
|
||||
"addresses",
|
||||
[
|
||||
["93.184.216.34", "198.41.0.4"],
|
||||
["2606:4700:4700::1111", "93.184.216.34", "2606:4700:4700::1001"],
|
||||
],
|
||||
)
|
||||
async def test_validate_server_url_returns_validated_addresses_for_pinning(addresses):
|
||||
async def fake_resolver(host: str):
|
||||
assert host == "api.example.com"
|
||||
return ["93.184.216.34", "198.41.0.4"]
|
||||
return addresses
|
||||
|
||||
assert await validate_server_url("https://api.example.com/", dns_resolver=fake_resolver) == [
|
||||
ipaddress.ip_address("93.184.216.34"),
|
||||
ipaddress.ip_address("198.41.0.4"),
|
||||
ipaddress.ip_address(address) for address in addresses
|
||||
]
|
||||
|
||||
|
||||
@@ -192,8 +228,9 @@ async def test_validate_server_url_returns_validated_ipv6_address_for_pinning():
|
||||
]
|
||||
|
||||
|
||||
async def test_validate_server_url_returns_no_addresses_for_literal_ip_host():
|
||||
assert await validate_server_url("https://93.184.216.34/api") == []
|
||||
@pytest.mark.parametrize("host", ["93.184.216.34", "[2606:4700:4700::1111]"])
|
||||
async def test_validate_server_url_returns_no_addresses_for_literal_ip_host(host):
|
||||
assert await validate_server_url(f"https://{host}/api") == []
|
||||
|
||||
|
||||
async def test_validate_server_url_returns_no_addresses_for_allowed_base_url():
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
import socket
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from yarl import URL
|
||||
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.core_plugins.http_plugin import HttpPlugin
|
||||
@@ -11,6 +13,29 @@ from semantic_kernel.exceptions import FunctionExecutionException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
|
||||
|
||||
def dns_response(*addresses: str):
|
||||
return [
|
||||
(
|
||||
socket.AF_INET6 if ":" in address else socket.AF_INET,
|
||||
socket.SOCK_STREAM,
|
||||
socket.IPPROTO_TCP,
|
||||
"",
|
||||
(address, 0),
|
||||
)
|
||||
for address in addresses
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_dns():
|
||||
with patch(
|
||||
"asyncio.BaseEventLoop.getaddrinfo",
|
||||
new_callable=AsyncMock,
|
||||
return_value=dns_response("93.184.216.34"),
|
||||
) as resolver:
|
||||
yield resolver
|
||||
|
||||
|
||||
async def test_it_can_be_instantiated():
|
||||
plugin = HttpPlugin(allow_all_domains=True)
|
||||
assert plugin is not None
|
||||
@@ -443,3 +468,210 @@ 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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["get", "post", "put", "delete"])
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
"169.254.169.254",
|
||||
"127.0.0.1",
|
||||
"10.0.0.1",
|
||||
"172.16.0.1",
|
||||
"192.168.0.1",
|
||||
"100.64.0.1",
|
||||
"0.0.0.0",
|
||||
"224.0.0.1",
|
||||
"198.18.0.1",
|
||||
"192.0.2.1",
|
||||
"::1",
|
||||
"fe80::1",
|
||||
"fec0::",
|
||||
"fec0::1",
|
||||
"feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
|
||||
"fc00::1",
|
||||
"ff02::1",
|
||||
"::ffff:169.254.169.254",
|
||||
],
|
||||
)
|
||||
async def test_allowed_hostname_resolving_to_non_public_address_is_blocked(method, address, mock_dns):
|
||||
mock_dns.return_value = dns_response(address)
|
||||
plugin = HttpPlugin(allowed_domains={"example.com"})
|
||||
|
||||
with (
|
||||
patch("aiohttp.ClientSession") as session,
|
||||
pytest.raises(FunctionExecutionException, match="host resolves to"),
|
||||
):
|
||||
await getattr(plugin, method)("http://example.com/metadata/v1/id")
|
||||
|
||||
session.assert_not_called()
|
||||
mock_dns.assert_awaited_once_with("example.com", None, type=socket.SOCK_STREAM)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["get", "post", "put", "delete"])
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
["169.254.169.254", "::1", "::ffff:127.0.0.1", "fec0::", "fec0::1", "feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"],
|
||||
)
|
||||
async def test_allowed_literal_non_public_address_is_blocked_without_dns(method, address, mock_dns):
|
||||
url_host = f"[{address}]" if ":" in address else address
|
||||
plugin = HttpPlugin(allowed_domains={address})
|
||||
|
||||
with (
|
||||
patch("aiohttp.ClientSession") as session,
|
||||
pytest.raises(FunctionExecutionException, match="host resolves to"),
|
||||
):
|
||||
await getattr(plugin, method)(f"http://{url_host}/")
|
||||
|
||||
session.assert_not_called()
|
||||
mock_dns.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"addresses",
|
||||
[("93.184.216.34", "10.0.0.1"), ("::1", "2606:4700:4700::1111"), ("93.184.216.34", "fec0::1")],
|
||||
)
|
||||
async def test_mixed_public_and_private_dns_response_is_blocked(addresses, mock_dns):
|
||||
mock_dns.return_value = dns_response(*addresses)
|
||||
plugin = HttpPlugin(allowed_domains={"example.com"})
|
||||
|
||||
with (
|
||||
patch("aiohttp.ClientSession") as session,
|
||||
pytest.raises(FunctionExecutionException, match="host resolves to"),
|
||||
):
|
||||
await plugin.get("https://example.com/")
|
||||
|
||||
session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["get", "post", "put", "delete"])
|
||||
@pytest.mark.parametrize("failure", ["error", "empty"])
|
||||
async def test_dns_failure_blocks_all_methods(method, failure, mock_dns):
|
||||
if failure == "error":
|
||||
mock_dns.side_effect = socket.gaierror()
|
||||
else:
|
||||
mock_dns.return_value = []
|
||||
plugin = HttpPlugin(allowed_domains={"example.com"})
|
||||
|
||||
with (
|
||||
patch("aiohttp.ClientSession") as session,
|
||||
pytest.raises(FunctionExecutionException, match="DNS resolution"),
|
||||
):
|
||||
await getattr(plugin, method)("https://example.com/")
|
||||
|
||||
session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["get", "post", "put", "delete"])
|
||||
async def test_public_dns_addresses_are_allowed(method, mock_dns):
|
||||
mock_dns.return_value = dns_response("93.184.216.34", "2606:4700:4700::1111")
|
||||
plugin = HttpPlugin(allowed_domains={"example.com"})
|
||||
|
||||
with patch(f"aiohttp.ClientSession.{method}") as request:
|
||||
request.return_value.__aenter__.return_value.text.return_value = "public response"
|
||||
assert await getattr(plugin, method)("http://example.com/") == "public response"
|
||||
|
||||
request.assert_called_once_with(URL("http://example.com/"), **request.call_args.kwargs)
|
||||
assert request.call_args.kwargs["allow_redirects"] is False
|
||||
mock_dns.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["get", "post", "put", "delete"])
|
||||
async def test_idna_hostname_resolving_to_loopback_is_blocked(method, mock_dns):
|
||||
def resolve(host, *args, **kwargs):
|
||||
addresses = {"fass.test": "93.184.216.34", "xn--fa-hia.test": "127.0.0.1"}
|
||||
return dns_response(addresses[host.encode("idna").decode("ascii")])
|
||||
|
||||
mock_dns.side_effect = resolve
|
||||
plugin = HttpPlugin(allowed_domains={"fa\u00df.test"})
|
||||
|
||||
with (
|
||||
patch("aiohttp.ClientSession") as session,
|
||||
pytest.raises(FunctionExecutionException, match="loopback"),
|
||||
):
|
||||
session.return_value.__aenter__.return_value = session.return_value
|
||||
await getattr(plugin, method)("http://fa\u00df.test/")
|
||||
|
||||
session.assert_not_called()
|
||||
mock_dns.assert_awaited_once_with("xn--fa-hia.test", None, type=socket.SOCK_STREAM)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["get", "post", "put", "delete"])
|
||||
@pytest.mark.parametrize("host", ["fa\u00df.test", "xn--fa-hia.test"])
|
||||
async def test_idna_hostname_is_identical_for_validation_and_request(method, host, mock_dns):
|
||||
plugin = HttpPlugin(allowed_domains={host})
|
||||
|
||||
with patch(f"aiohttp.ClientSession.{method}") as request:
|
||||
request.return_value.__aenter__.return_value.text.return_value = "public response"
|
||||
assert await getattr(plugin, method)(f"https://{host}/path") == "public response"
|
||||
|
||||
mock_dns.assert_awaited_once_with("xn--fa-hia.test", None, type=socket.SOCK_STREAM)
|
||||
request.assert_called_once_with(URL("https://xn--fa-hia.test/path"), **request.call_args.kwargs)
|
||||
assert request.call_args.kwargs["allow_redirects"] is False
|
||||
|
||||
|
||||
async def test_idna_normalization_does_not_bypass_domain_allowlist(mock_dns):
|
||||
plugin = HttpPlugin(allowed_domains={"fass.test"})
|
||||
|
||||
with (
|
||||
patch("aiohttp.ClientSession") as session,
|
||||
pytest.raises(FunctionExecutionException, match="Sending requests"),
|
||||
):
|
||||
await plugin.get("https://fa\u00df.test/")
|
||||
|
||||
session.assert_not_called()
|
||||
mock_dns.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("address", ["1.1.1.1", "2606:4700:4700::1111"])
|
||||
async def test_public_literal_address_is_allowed_without_dns(address, mock_dns):
|
||||
url_host = f"[{address}]" if ":" in address else address
|
||||
plugin = HttpPlugin(allowed_domains={address})
|
||||
|
||||
with patch("aiohttp.ClientSession.get") as request:
|
||||
request.return_value.__aenter__.return_value.text.return_value = "public response"
|
||||
assert await plugin.get(f"https://{url_host}/") == "public response"
|
||||
|
||||
mock_dns.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["get", "post", "put", "delete"])
|
||||
@pytest.mark.parametrize("host", ["example.com", "127.0.0.1", "fec0::1"])
|
||||
@pytest.mark.parametrize("unrestricted", [False, True])
|
||||
async def test_private_network_access_requires_explicit_opt_in(method, host, unrestricted, mock_dns):
|
||||
url_host = f"[{host}]" if ":" in host else host
|
||||
plugin = HttpPlugin(
|
||||
allowed_domains={host},
|
||||
allow_all_domains=unrestricted,
|
||||
allow_private_network_access=not unrestricted,
|
||||
)
|
||||
|
||||
with patch(f"aiohttp.ClientSession.{method}") as request:
|
||||
request.return_value.__aenter__.return_value.text.return_value = "internal response"
|
||||
assert await getattr(plugin, method)(f"http://{url_host}/") == "internal response"
|
||||
|
||||
assert request.call_args.kwargs["allow_redirects"] is unrestricted
|
||||
mock_dns.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["http://notallowed.com/", "http://example.com:8080/", "file://example.com/"])
|
||||
async def test_private_network_opt_in_preserves_domain_port_and_scheme_restrictions(url, mock_dns):
|
||||
plugin = HttpPlugin(allowed_domains={"example.com"}, allow_private_network_access=True)
|
||||
|
||||
with (
|
||||
patch("aiohttp.ClientSession") as session,
|
||||
pytest.raises(FunctionExecutionException, match="Sending requests"),
|
||||
):
|
||||
await plugin.get(url)
|
||||
|
||||
session.assert_not_called()
|
||||
mock_dns.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_denied_domain_is_not_resolved(mock_dns):
|
||||
plugin = HttpPlugin(allowed_domains={"example.com"})
|
||||
|
||||
with pytest.raises(FunctionExecutionException, match="Sending requests"):
|
||||
await plugin.get("http://notallowed.com/")
|
||||
|
||||
mock_dns.assert_not_awaited()
|
||||
|
||||
Reference in New Issue
Block a user