From c59e65ee0a8fb10ee1e7bf9dc30011d813c00a9d Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 27 Sep 2026 12:36:06 +0200 Subject: [PATCH] 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) --- .../SsrfGuardTests.cs | 131 ++++++++-- .../WebhookConnectGateTests.cs | 247 ++++++++++++++++++ .../Helpers/ResiliencePolicies.cs | 4 +- .../AutopilotMonitor.Functions/Program.cs | 10 +- .../Security/SsrfGuard.cs | 226 ++++++++-------- .../WebhookHttpClientRegistration.cs | 27 ++ .../WebhookNotificationService.cs | 15 +- 7 files changed, 507 insertions(+), 153 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/WebhookConnectGateTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookHttpClientRegistration.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SsrfGuardTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SsrfGuardTests.cs index 74e67d3d..d8a3523c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SsrfGuardTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SsrfGuardTests.cs @@ -1,8 +1,14 @@ using System.Net; +using System.Text; +using System.Text.RegularExpressions; using AutopilotMonitor.Functions.Security; namespace AutopilotMonitor.Functions.Tests; +/// +/// Format checks and the address classifier. The connection gate itself is covered on real sockets +/// in . +/// 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( - () => 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( - () => 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( - () => 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 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( - () => 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 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(); + } + + /// RFC 4180 reader — the registry quotes cells, wraps the RFC column over lines and doubles quotes. + private static List> ParseCsv(string text) + { + var rows = new List>(); + var row = new List(); + 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(); + cell.Clear(); + } + else cell.Append(c); + } + if (cell.Length > 0 || row.Count > 0) { row.Add(cell.ToString()); rows.Add(row); } + return rows; } // ── ValidateAzureBlobSasUrlFormat ── diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/WebhookConnectGateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/WebhookConnectGateTests.cs new file mode 100644 index 00000000..c5039825 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/WebhookConnectGateTests.cs @@ -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; + +/// +/// The webhook SSRF gate on real loopback sockets. DNS is injected: rebind.example.test does +/// not exist, so a socket reaching a listener proves it used the gate's answer rather than a +/// second resolution. +/// +public class WebhookConnectGateTests +{ + private const string Host = "rebind.example.test"; + + private sealed class CountingResolver + { + private readonly IPAddress[] _answer; + public List Calls { get; } = new(); + public CountingResolver(params string[] answer) => _answer = answer.Select(IPAddress.Parse).ToArray(); + + public Task Resolve(string host, CancellationToken _) + { + Calls.Add(host); + return Task.FromResult(_answer); + } + } + + private static HttpClient ClientThrough(Func> 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(() => client.GetAsync($"https://{Host}:{PortOf(listener)}/hook")); + + var refused = Assert.IsType(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(() => client.GetAsync($"https://{Host}/hook")); + + Assert.IsType(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(() => client.GetAsync($"https://127.0.0.1:{PortOf(listener)}/hook")); + + Assert.IsType(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(() => client.GetAsync($"http://{Host}/hook")); + + Assert.Contains("HTTPS", Assert.IsType(ex.InnerException).Message); + Assert.Empty(resolver.Calls); + } + + [Fact] + public async Task UnresolvableOrEmptyAnswer_FailsClosed() + { + Task NotFound(string _, CancellationToken __) => throw new SocketException((int)SocketError.HostNotFound); + using (var client = ClientThrough(SsrfGuard.CreateConnectCallback(NotFound))) + { + var ex = await Assert.ThrowsAsync(() => client.GetAsync($"https://{Host}/hook")); + Assert.Contains("resolve", Assert.IsType(ex.InnerException).Message); + } + + using (var client = ClientThrough(SsrfGuard.CreateConnectCallback(new CountingResolver().Resolve))) + { + var ex = await Assert.ThrowsAsync(() => client.GetAsync($"https://{Host}/hook")); + Assert.Contains("no addresses", Assert.IsType(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(); + services.AddWebhookNotificationHttpClient(); + using var provider = services.BuildServiceProvider(); + + HttpMessageHandler handler = provider.GetRequiredService().CreateHandler(nameof(WebhookNotificationService)); + while (handler is DelegatingHandler delegating) + handler = delegating.InnerHandler!; + + var primary = Assert.IsType(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(() => 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.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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/ResiliencePolicies.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/ResiliencePolicies.cs index 265fcc18..0477b706 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/ResiliencePolicies.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/ResiliencePolicies.cs @@ -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 BuildNotification(ILogger logger) => Policy .HandleResult(r => TransientCodes.Contains(r.StatusCode)) - .Or() + // A destination the SSRF gate refused stays refused; a retry would only resolve it again. + .Or(ex => ex.InnerException is not SsrfException) .Or() .WaitAndRetryAsync( retryCount: 2, diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 489aed13..4aa68698 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -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(); builder.Services.AddSingleton(); -builder.Services.AddHttpClient() - .ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler - { - AllowAutoRedirect = false, - ConnectTimeout = TimeSpan.FromSeconds(10), - }) - .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15)) - .AddPolicyHandler((sp, _) => sp.GetRequiredService().Notification); +builder.Services.AddWebhookNotificationHttpClient(); builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().Notification); // Channel-level send API — routes each NotificationChannel to its transport (webhook renderer diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SsrfGuard.cs b/src/Backend/AutopilotMonitor.Functions/Security/SsrfGuard.cs index 4af7552e..1573fd8f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SsrfGuard.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SsrfGuard.cs @@ -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 { /// - /// 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. /// public static class SsrfGuard { @@ -69,129 +73,123 @@ namespace AutopilotMonitor.Functions.Security } /// - /// Resolves the webhook URL hostname via DNS and validates all resolved IPs - /// against blocked ranges. Call before every outbound HTTP request. - /// Throws if the destination is blocked. - /// Fails closed: DNS resolution errors are treated as blocked. + /// of the webhook client — the SSRF gate. + /// A refused destination surfaces as an whose + /// is the . /// - 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> ConnectToPublicAddress { get; } = + CreateConnectCallback(Dns.GetHostAddressesAsync); /// - /// 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 + /// — 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. is widened only by tests that need to reach a + /// loopback listener. /// - internal static bool IsBlockedAddress(IPAddress addr) + internal static Func> CreateConnectCallback( + Func> resolve, + Func? 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 + }; + + /// + /// 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. + /// + 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, + }; } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookHttpClientRegistration.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookHttpClientRegistration.cs new file mode 100644 index 00000000..65751e54 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookHttpClientRegistration.cs @@ -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 + { + /// + /// Registers the typed HttpClient of . Its primary + /// handler connects only through : the webhook + /// SSRF gate lives on the connection, so it covers every request this client sends. + /// + public static IHttpClientBuilder AddWebhookNotificationHttpClient(this IServiceCollection services) => + services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + AllowAutoRedirect = false, + ConnectTimeout = TimeSpan.FromSeconds(10), + ConnectCallback = SsrfGuard.ConnectToPublicAddress, + }) + .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15)) + .AddPolicyHandler((sp, _) => sp.GetRequiredService().Notification); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs index d187690e..a6bbff7c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs @@ -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}" };