mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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>
|
||||
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-11
@@ -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}" };
|
||||
|
||||
Reference in New Issue
Block a user