fix(backend): webhook SSRF gate moves onto the connection

SsrfGuard.ValidateDestinationAsync resolved the webhook host and checked
it, then HttpClient resolved it again for the POST, so a DNS-rebinding
host could pass the check and be connected to an internal address.

The webhook client's SocketsHttpHandler now connects through
SsrfGuard.ConnectToPublicAddress: it resolves once per connection,
refuses the connection if any address is blocked, enforces HTTPS and
connects the socket only to the checked addresses; TLS still runs against
the request host. The pre-flight check is removed, the registration moves
to AddWebhookNotificationHttpClient so a test can prove the wiring, the
notification retry policy no longer retries a refused destination, and
the test endpoint reports the refusal with the same message as before.

IsBlockedAddress follows the same model as the MCP server: all IANA
special-purpose space, IPv6 only 2000::/3, zone ids and IPv4-mapped
addresses refused (IPNetwork.Contains judges a mapped address by its
IPv4 form, so those are refused first).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-27 12:36:06 +02:00
parent 89aa9e1f10
commit c59e65ee0a
7 changed files with 507 additions and 153 deletions
@@ -1,8 +1,14 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Format checks and the address classifier. The connection gate itself is covered on real sockets
/// in <see cref="WebhookConnectGateTests"/>.
/// </summary>
public class SsrfGuardTests
{
// ── ValidateWebhookUrlFormat: valid URLs ──
@@ -145,37 +151,124 @@ public class SsrfGuardTests
Assert.False(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
}
// ── ValidateDestinationAsync: integration ──
// ── IsBlockedAddress: IPv6 beyond the classic ranges ──
[Fact]
public async Task ValidateDestination_Localhost_ThrowsSsrfException()
[Theory]
[InlineData("::")] // unspecified
[InlineData("::ffff:8.8.8.8")] // IPv4-mapped, even of a public address
[InlineData("::8.8.8.8")] // IPv4-compatible (deprecated)
[InlineData("64:ff9b::a00:1")] // NAT64 of 10.0.0.1
[InlineData("2002:a00:1::1")] // 6to4 of 10.0.0.1
[InlineData("2001::1")] // Teredo
[InlineData("2001:db8::1")] // documentation
[InlineData("fec0::1")] // deprecated site-local
[InlineData("4000::1")] // outside global unicast
[InlineData("e000::1")] // outside global unicast
public void IsBlocked_Ipv6OutsidePublicUnicast_ReturnsTrue(string ip)
{
var ex = await Assert.ThrowsAsync<SsrfException>(
() => SsrfGuard.ValidateDestinationAsync("https://localhost/webhook"));
Assert.Contains("private or reserved", ex.Message);
Assert.True(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
}
[Fact]
public async Task ValidateDestination_HttpScheme_ThrowsSsrfException()
[Theory]
[InlineData("fe80::1%5")]
[InlineData("2606:4700:4700::1111%5")]
public void IsBlocked_Ipv6WithZoneId_ReturnsTrue(string ip)
{
var ex = await Assert.ThrowsAsync<SsrfException>(
() => SsrfGuard.ValidateDestinationAsync("http://example.com/webhook"));
Assert.Contains("HTTPS", ex.Message);
Assert.True(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
}
[Fact]
public async Task ValidateDestination_InvalidUrl_ThrowsSsrfException()
[Theory]
[InlineData("2606:4700:4700::1111")] // Cloudflare
[InlineData("2a00:1450:4001::1")] // Google
[InlineData("2001:200::1")] // just above 2001::/23
[InlineData("2003::1")] // just above 6to4 2002::/16
[InlineData("2001:db9::1")] // just above documentation 2001:db8::/32
[InlineData("192.0.1.0")] // just above 192.0.0.0/24
[InlineData("198.20.0.0")] // just above benchmarking 198.18.0.0/15
[InlineData("223.255.255.255")] // just below multicast
public void IsBlocked_PublicNeighbourOfRefusedBlock_ReturnsFalse(string ip)
{
await Assert.ThrowsAsync<SsrfException>(
() => SsrfGuard.ValidateDestinationAsync("not-a-url"));
Assert.False(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
}
// ── IsBlockedAddress: every IANA special-purpose block (tests/fixtures/iana-special-purpose-addresses) ──
private static readonly string[] RegistryFiles = { "iana-ipv4-special-registry-1.csv", "iana-ipv6-special-registry-1.csv" };
public static IEnumerable<object[]> RegistryBlocks() =>
RegistryFiles.SelectMany(file => ReadRegistryBlocks(file).Select(block => new object[] { file, block }));
[Fact]
public async Task ValidateDestination_UnresolvableHost_ThrowsSsrfException()
public void Registry_IsReadCompletely()
{
var ex = await Assert.ThrowsAsync<SsrfException>(
() => SsrfGuard.ValidateDestinationAsync("https://this-host-does-not-exist-xyz123.invalid/webhook"));
Assert.Contains("resolve", ex.Message);
foreach (var file in RegistryFiles)
{
var blocks = ReadRegistryBlocks(file);
Assert.True(blocks.Count >= 20, $"{file}: only {blocks.Count} blocks read");
Assert.All(blocks, block => Assert.Matches(@"^[0-9a-f.:]+/\d{1,3}$", block));
}
}
[Theory]
[MemberData(nameof(RegistryBlocks))]
public void IsBlocked_EveryIanaSpecialPurposeBlock_FirstAndLastAddress(string file, string block)
{
var network = IPNetwork.Parse(block);
var first = network.BaseAddress.GetAddressBytes();
var last = (byte[])first.Clone();
for (var bit = network.PrefixLength; bit < last.Length * 8; bit++)
last[bit / 8] |= (byte)(0x80 >> (bit % 8));
foreach (var ip in new[] { new IPAddress(first), new IPAddress(last) })
Assert.True(SsrfGuard.IsBlockedAddress(ip), $"{file}: {block} -> {ip}");
}
private static List<string> ReadRegistryBlocks(string file)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
var rows = ParseCsv(File.ReadAllText(Path.Combine(dir!.FullName, "tests", "fixtures", "iana-special-purpose-addresses", file)));
Assert.Equal("Address Block", rows[0][0]);
// Footnote markers ("2002::/16 [3]") dropped, cells that list two blocks split.
return rows.Skip(1)
.SelectMany(row => row[0].Split(','))
.Select(block => Regex.Replace(block, @"\[\d+\]", "").Trim())
.Where(block => block.Length > 0)
.ToList();
}
/// <summary>RFC 4180 reader — the registry quotes cells, wraps the RFC column over lines and doubles quotes.</summary>
private static List<List<string>> ParseCsv(string text)
{
var rows = new List<List<string>>();
var row = new List<string>();
var cell = new StringBuilder();
var quoted = false;
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (quoted)
{
if (c == '"' && i + 1 < text.Length && text[i + 1] == '"') { cell.Append('"'); i++; }
else if (c == '"') quoted = false;
else cell.Append(c);
}
else if (c == '"') quoted = true;
else if (c == ',') { row.Add(cell.ToString()); cell.Clear(); }
else if (c == '\n' || c == '\r')
{
if (c == '\r' && i + 1 < text.Length && text[i + 1] == '\n') i++;
row.Add(cell.ToString());
rows.Add(row);
row = new List<string>();
cell.Clear();
}
else cell.Append(c);
}
if (cell.Length > 0 || row.Count > 0) { row.Add(cell.ToString()); rows.Add(row); }
return rows;
}
// ── ValidateAzureBlobSasUrlFormat ──
@@ -0,0 +1,247 @@
using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The webhook SSRF gate on real loopback sockets. DNS is injected: <c>rebind.example.test</c> does
/// not exist, so a socket reaching a listener proves it used the gate's answer rather than a
/// second resolution.
/// </summary>
public class WebhookConnectGateTests
{
private const string Host = "rebind.example.test";
private sealed class CountingResolver
{
private readonly IPAddress[] _answer;
public List<string> Calls { get; } = new();
public CountingResolver(params string[] answer) => _answer = answer.Select(IPAddress.Parse).ToArray();
public Task<IPAddress[]> Resolve(string host, CancellationToken _)
{
Calls.Add(host);
return Task.FromResult(_answer);
}
}
private static HttpClient ClientThrough(Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>> connect,
X509Certificate2? trustedServerCertificate = null)
{
var handler = new SocketsHttpHandler { AllowAutoRedirect = false, ConnectCallback = connect };
if (trustedServerCertificate != null)
{
// Pinned to the test's own self-signed certificate — validation stays on, only the trust anchor differs.
var pinned = trustedServerCertificate.GetCertHashString();
handler.SslOptions.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString() == pinned;
}
// Short timeout: with a broken gate the TLS handshake waits on a listener that never accepts.
return new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
}
private static TcpListener StartListener()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return listener;
}
private static int PortOf(TcpListener listener) => ((IPEndPoint)listener.LocalEndpoint).Port;
[Fact]
public async Task PrivateAnswer_FailsClosed_WithoutOpeningASocket()
{
var listener = StartListener();
try
{
var resolver = new CountingResolver("127.0.0.1");
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}:{PortOf(listener)}/hook"));
var refused = Assert.IsType<SsrfException>(ex.InnerException);
Assert.Contains("private or reserved", refused.Message);
Assert.Equal(new[] { Host }, resolver.Calls);
Assert.False(listener.Pending());
}
finally
{
listener.Stop();
}
}
[Fact]
public async Task MixedAnswer_WithOnePrivateAddress_FailsClosed()
{
var resolver = new CountingResolver("1.1.1.1", "169.254.169.254");
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}/hook"));
Assert.IsType<SsrfException>(ex.InnerException);
}
[Fact]
public async Task PrivateIpLiteral_FailsClosed_WithoutResolving()
{
var listener = StartListener();
try
{
var resolver = new CountingResolver("1.1.1.1");
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://127.0.0.1:{PortOf(listener)}/hook"));
Assert.IsType<SsrfException>(ex.InnerException);
Assert.Empty(resolver.Calls);
Assert.False(listener.Pending());
}
finally
{
listener.Stop();
}
}
[Fact]
public async Task PlainHttp_IsRefused_BeforeResolving()
{
var resolver = new CountingResolver("1.1.1.1");
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"http://{Host}/hook"));
Assert.Contains("HTTPS", Assert.IsType<SsrfException>(ex.InnerException).Message);
Assert.Empty(resolver.Calls);
}
[Fact]
public async Task UnresolvableOrEmptyAnswer_FailsClosed()
{
Task<IPAddress[]> NotFound(string _, CancellationToken __) => throw new SocketException((int)SocketError.HostNotFound);
using (var client = ClientThrough(SsrfGuard.CreateConnectCallback(NotFound)))
{
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}/hook"));
Assert.Contains("resolve", Assert.IsType<SsrfException>(ex.InnerException).Message);
}
using (var client = ClientThrough(SsrfGuard.CreateConnectCallback(new CountingResolver().Resolve)))
{
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}/hook"));
Assert.Contains("no addresses", Assert.IsType<SsrfException>(ex.InnerException).Message);
}
}
[Fact]
public async Task AllowedAnswer_ConnectsToExactlyThatAddress_AndTlsStillTargetsTheRequestHost()
{
using var certificate = CreateServerCertificate(Host);
var listener = StartListener();
try
{
string? serverName = null;
var server = Task.Run(async () =>
{
using var tcp = await listener.AcceptTcpClientAsync();
using var tls = new SslStream(tcp.GetStream());
await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { ServerCertificate = certificate });
serverName = tls.TargetHostName;
var buffer = new byte[4096];
var request = new StringBuilder();
while (!request.ToString().Contains("\r\n\r\n"))
{
var read = await tls.ReadAsync(buffer);
if (read == 0) break;
request.Append(Encoding.ASCII.GetString(buffer, 0, read));
}
await tls.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"));
});
// The classifier is widened to reach the loopback listener; the socket path is the production one.
var resolver = new CountingResolver("127.0.0.1");
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve, _ => false), certificate);
var response = await client.GetAsync($"https://{Host}:{PortOf(listener)}/hook");
await server;
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("ok", await response.Content.ReadAsStringAsync());
Assert.Equal(Host, serverName);
Assert.Equal(new[] { Host }, resolver.Calls);
}
finally
{
listener.Stop();
}
}
[Fact]
public void WebhookClient_PrimaryHandler_ConnectsOnlyThroughTheGate()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ResiliencePolicies>();
services.AddWebhookNotificationHttpClient();
using var provider = services.BuildServiceProvider();
HttpMessageHandler handler = provider.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler(nameof(WebhookNotificationService));
while (handler is DelegatingHandler delegating)
handler = delegating.InnerHandler!;
var primary = Assert.IsType<SocketsHttpHandler>(handler);
Assert.Same(SsrfGuard.ConnectToPublicAddress, primary.ConnectCallback);
Assert.False(primary.AllowAutoRedirect);
}
[Fact]
public async Task NotificationPolicy_DoesNotRetryARefusedDestination()
{
var policies = new ResiliencePolicies(NullLoggerFactory.Instance);
var attempts = 0;
await Assert.ThrowsAsync<HttpRequestException>(() => policies.Notification.ExecuteAsync(() =>
{
attempts++;
throw new HttpRequestException("refused", new SsrfException("Webhook URL targets a private or reserved network."));
}));
Assert.Equal(1, attempts);
}
[Fact]
public async Task TestNotification_ReportsTheRefusal()
{
var resolver = new CountingResolver("10.0.0.8");
var webhook = new WebhookNotificationService(ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve)),
NullLogger<WebhookNotificationService>.Instance);
var result = await webhook.SendNotificationWithResultAsync($"https://{Host}/hook", WebhookProviderType.GenericJson,
new NotificationAlert { Title = "T", Summary = "S" });
Assert.False(result.Success);
Assert.Equal("Webhook URL targets a private or reserved network.", result.Message);
}
private static X509Certificate2 CreateServerCertificate(string host)
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest($"CN={host}", key, HashAlgorithmName.SHA256);
var san = new SubjectAlternativeNameBuilder();
san.AddDnsName(host);
request.CertificateExtensions.Add(san.Build());
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
using var ephemeral = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
// Round-trip through PKCS#12: SChannel cannot serve a certificate whose key is ephemeral.
return X509CertificateLoader.LoadPkcs12(ephemeral.Export(X509ContentType.Pfx), null);
}
}
@@ -1,4 +1,5 @@
using System.Net;
using AutopilotMonitor.Functions.Security;
using Microsoft.Extensions.Logging;
using Polly;
@@ -89,7 +90,8 @@ public sealed class ResiliencePolicies
private static IAsyncPolicy<HttpResponseMessage> BuildNotification(ILogger logger)
=> Policy<HttpResponseMessage>
.HandleResult(r => TransientCodes.Contains(r.StatusCode))
.Or<HttpRequestException>()
// A destination the SSRF gate refused stays refused; a retry would only resolve it again.
.Or<HttpRequestException>(ex => ex.InnerException is not SsrfException)
.Or<TaskCanceledException>()
.WaitAndRetryAsync(
retryCount: 2,
@@ -7,6 +7,7 @@ using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -470,14 +471,7 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphResolutio
AutopilotMonitor.Functions.Services.GraphResolution.GraphFeatureDetector>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphResolution.IScriptDisplayNameResolver,
AutopilotMonitor.Functions.Services.GraphResolution.ScriptDisplayNameResolver>();
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>()
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectTimeout = TimeSpan.FromSeconds(10),
})
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddWebhookNotificationHttpClient();
builder.Services.AddHttpClient<TelegramNotificationService>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
// Channel-level send API — routes each NotificationChannel to its transport (webhook renderer
@@ -1,13 +1,17 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Prevents Server-Side Request Forgery (SSRF) by validating webhook URLs
/// against private/reserved network ranges before allowing outbound requests.
/// Prevents Server-Side Request Forgery (SSRF) for customer-configured outbound URLs: format
/// checks at config save time, and an address gate on the webhook client's connections.
/// </summary>
public static class SsrfGuard
{
@@ -69,129 +73,123 @@ namespace AutopilotMonitor.Functions.Security
}
/// <summary>
/// Resolves the webhook URL hostname via DNS and validates all resolved IPs
/// against blocked ranges. Call before every outbound HTTP request.
/// Throws <see cref="SsrfException"/> if the destination is blocked.
/// Fails closed: DNS resolution errors are treated as blocked.
/// <see cref="SocketsHttpHandler.ConnectCallback"/> of the webhook client — the SSRF gate.
/// A refused destination surfaces as an <see cref="HttpRequestException"/> whose
/// <see cref="Exception.InnerException"/> is the <see cref="SsrfException"/>.
/// </summary>
public static async Task ValidateDestinationAsync(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
throw new SsrfException("Webhook URL is not a valid URL.");
if (uri.Scheme != Uri.UriSchemeHttps)
throw new SsrfException("Webhook URL must use HTTPS.");
IPAddress[] addresses;
try
{
addresses = await Dns.GetHostAddressesAsync(uri.Host);
}
catch (Exception)
{
throw new SsrfException("Could not resolve webhook hostname.");
}
if (addresses.Length == 0)
throw new SsrfException("Webhook hostname resolved to no addresses.");
foreach (var addr in addresses)
{
if (IsBlockedAddress(addr))
throw new SsrfException("Webhook URL targets a private or reserved network.");
}
}
public static Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>> ConnectToPublicAddress { get; } =
CreateConnectCallback(Dns.GetHostAddressesAsync);
/// <summary>
/// Returns true if the IP address is private, loopback, link-local, multicast,
/// cloud metadata, reserved, or an IPv6-mapped version of any of the above.
/// Builds the gate: it resolves the host exactly once per connection and connects the socket
/// only to the addresses that resolution returned, after every one of them passed
/// <see cref="IsBlockedAddress"/> — so the address checked is the address connected to. A
/// separate pre-flight resolution would let a DNS-rebinding host answer the check and the
/// connect differently. HTTPS only; TLS then runs on the returned stream against the request
/// host as usual. <paramref name="isBlocked"/> is widened only by tests that need to reach a
/// loopback listener.
/// </summary>
internal static bool IsBlockedAddress(IPAddress addr)
internal static Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>> CreateConnectCallback(
Func<string, CancellationToken, Task<IPAddress[]>> resolve,
Func<IPAddress, bool>? isBlocked = null)
{
// Normalize IPv6-mapped IPv4 (e.g. ::ffff:10.0.0.1 -> 10.0.0.1)
if (addr.IsIPv4MappedToIPv6)
addr = addr.MapToIPv4();
// Loopback: 127.0.0.0/8, ::1
if (IPAddress.IsLoopback(addr))
return true;
// IPv6 link-local (fe80::/10)
if (addr.IsIPv6LinkLocal)
return true;
// IPv6 multicast (ff00::/8)
if (addr.IsIPv6Multicast)
return true;
if (addr.AddressFamily == AddressFamily.InterNetwork)
isBlocked ??= IsBlockedAddress;
return async (context, cancellationToken) =>
{
var bytes = addr.GetAddressBytes();
if (context.InitialRequestMessage.RequestUri?.Scheme != Uri.UriSchemeHttps)
throw new SsrfException("Webhook URL must use HTTPS.");
// 10.0.0.0/8 (RFC 1918)
if (bytes[0] == 10)
return true;
var host = context.DnsEndPoint.Host;
IPAddress[] addresses;
if (IPAddress.TryParse(host.Trim('[', ']'), out var literal))
{
addresses = new[] { literal };
}
else
{
try
{
addresses = await resolve(host, cancellationToken);
}
catch (SocketException)
{
throw new SsrfException("Could not resolve webhook hostname.");
}
}
// 172.16.0.0/12 (RFC 1918)
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31)
return true;
if (addresses.Length == 0)
throw new SsrfException("Webhook hostname resolved to no addresses.");
// 192.168.0.0/16 (RFC 1918)
if (bytes[0] == 192 && bytes[1] == 168)
return true;
if (addresses.Any(isBlocked))
throw new SsrfException("Webhook URL targets a private or reserved network.");
// 169.254.0.0/16 (link-local, includes Azure IMDS 169.254.169.254)
if (bytes[0] == 169 && bytes[1] == 254)
return true;
// 0.0.0.0/8 (current network)
if (bytes[0] == 0)
return true;
// 100.64.0.0/10 (Carrier-grade NAT, RFC 6598)
if (bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127)
return true;
// 192.0.0.0/24 (IETF Protocol Assignments)
if (bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 0)
return true;
// 192.0.2.0/24 (TEST-NET-1)
if (bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 2)
return true;
// 198.18.0.0/15 (Benchmark testing)
if (bytes[0] == 198 && (bytes[1] == 18 || bytes[1] == 19))
return true;
// 198.51.100.0/24 (TEST-NET-2)
if (bytes[0] == 198 && bytes[1] == 51 && bytes[2] == 100)
return true;
// 203.0.113.0/24 (TEST-NET-3)
if (bytes[0] == 203 && bytes[1] == 0 && bytes[2] == 113)
return true;
// 224.0.0.0/4 (Multicast) and 240.0.0.0/4 (Reserved)
if (bytes[0] >= 224)
return true;
}
if (addr.AddressFamily == AddressFamily.InterNetworkV6)
{
var bytes = addr.GetAddressBytes();
// fc00::/7 (Unique local address)
if ((bytes[0] & 0xFE) == 0xFC)
return true;
// :: (unspecified)
if (addr.Equals(IPAddress.IPv6None) || addr.Equals(IPAddress.IPv6Any))
return true;
}
return false;
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(addresses, context.DnsEndPoint.Port, cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
};
}
// IPv4: every block of the IANA IPv4 Special-Purpose Address Registry plus multicast and
// 240/4 (which holds the limited broadcast).
private static readonly IPNetwork[] BlockedIpv4 =
{
IPNetwork.Parse("0.0.0.0/8"), // "this network"
IPNetwork.Parse("10.0.0.0/8"), // private use
IPNetwork.Parse("100.64.0.0/10"), // shared address space (CGNAT)
IPNetwork.Parse("127.0.0.0/8"), // loopback
IPNetwork.Parse("169.254.0.0/16"), // link-local, holds the cloud metadata endpoints
IPNetwork.Parse("172.16.0.0/12"), // private use
IPNetwork.Parse("192.0.0.0/24"), // IETF protocol assignments
IPNetwork.Parse("192.0.2.0/24"), // documentation (TEST-NET-1)
IPNetwork.Parse("192.31.196.0/24"), // AS112-v4
IPNetwork.Parse("192.52.193.0/24"), // AMT
IPNetwork.Parse("192.88.99.0/24"), // deprecated 6to4 relay anycast
IPNetwork.Parse("192.168.0.0/16"), // private use
IPNetwork.Parse("192.175.48.0/24"), // direct delegation AS112
IPNetwork.Parse("198.18.0.0/15"), // benchmarking
IPNetwork.Parse("198.51.100.0/24"), // documentation (TEST-NET-2)
IPNetwork.Parse("203.0.113.0/24"), // documentation (TEST-NET-3)
IPNetwork.Parse("224.0.0.0/4"), // multicast
IPNetwork.Parse("240.0.0.0/4"), // reserved, limited broadcast
};
// IPv6: only global unicast 2000::/3 is allowed at all — one rule that refuses loopback,
// unspecified, IPv4-mapped/-compatible, NAT64, discard, unique-local, link-/site-local and
// multicast — and inside it the IANA special-purpose blocks are refused.
private static readonly IPNetwork GlobalUnicastIpv6 = IPNetwork.Parse("2000::/3");
private static readonly IPNetwork[] BlockedGlobalUnicastIpv6 =
{
IPNetwork.Parse("2001::/23"), // IETF protocol assignments (Teredo, benchmarking, ORCHID, AMT, AS112, ...)
IPNetwork.Parse("2001:db8::/32"), // documentation
IPNetwork.Parse("2002::/16"), // 6to4
IPNetwork.Parse("2620:4f:8000::/48"), // direct delegation AS112
IPNetwork.Parse("3fff::/20"), // documentation
};
/// <summary>
/// True for every address a webhook must not connect to: all IANA special-purpose space, and
/// any IPv6 address outside global unicast or carrying a zone id.
/// </summary>
internal static bool IsBlockedAddress(IPAddress addr) => addr.AddressFamily switch
{
AddressFamily.InterNetwork => BlockedIpv4.Any(n => n.Contains(addr)),
AddressFamily.InterNetworkV6 => addr.ScopeId != 0
// Refused before any IPNetwork check: IPNetwork.Contains judges an IPv4-mapped
// address by its IPv4 form, so 2000::/3 "contains" ::ffff:8.8.8.8.
|| addr.IsIPv4MappedToIPv6
|| !GlobalUnicastIpv6.Contains(addr)
|| BlockedGlobalUnicastIpv6.Any(n => n.Contains(addr)),
_ => true,
};
}
/// <summary>
@@ -0,0 +1,27 @@
using System;
using System.Net.Http;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using Microsoft.Extensions.DependencyInjection;
namespace AutopilotMonitor.Functions.Services.Notifications
{
public static class WebhookHttpClientRegistration
{
/// <summary>
/// Registers the typed HttpClient of <see cref="WebhookNotificationService"/>. Its primary
/// handler connects only through <see cref="SsrfGuard.ConnectToPublicAddress"/>: the webhook
/// SSRF gate lives on the connection, so it covers every request this client sends.
/// </summary>
public static IHttpClientBuilder AddWebhookNotificationHttpClient(this IServiceCollection services) =>
services.AddHttpClient<WebhookNotificationService>()
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectTimeout = TimeSpan.FromSeconds(10),
ConnectCallback = SsrfGuard.ConnectToPublicAddress,
})
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
}
}
@@ -54,8 +54,6 @@ namespace AutopilotMonitor.Functions.Services.Notifications
return;
}
await SsrfGuard.ValidateDestinationAsync(webhookUrl);
var json = renderer.RenderToJson(alert);
var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret);
@@ -90,15 +88,6 @@ namespace AutopilotMonitor.Functions.Services.Notifications
if (!_renderers.TryGetValue(providerType, out var renderer))
return new WebhookTestResult { Success = false, Message = $"Unknown provider type: {providerType}" };
try
{
await SsrfGuard.ValidateDestinationAsync(webhookUrl);
}
catch (SsrfException ex)
{
return new WebhookTestResult { Success = false, Message = ex.Message };
}
try
{
var json = renderer.RenderToJson(alert);
@@ -118,6 +107,10 @@ namespace AutopilotMonitor.Functions.Services.Notifications
Message = $"Webhook returned HTTP {statusCode}: {(body.Length > 200 ? body[..200] : body)}"
};
}
catch (HttpRequestException ex) when (ex.InnerException is SsrfException refused)
{
return new WebhookTestResult { Success = false, Message = refused.Message };
}
catch (Exception ex)
{
return new WebhookTestResult { Success = false, Message = $"Connection error: {ex.Message}" };