diff --git a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs
index 1922aa8d1a..651f842910 100644
--- a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs
+++ b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs
@@ -1,5 +1,6 @@
using FluentValidation;
using FSH.Modules.Webhooks.Contracts.v1.CreateWebhookSubscription;
+using FSH.Modules.Webhooks.Services;
namespace FSH.Modules.Webhooks.Features.v1.CreateWebhookSubscription;
@@ -10,7 +11,9 @@ public CreateWebhookSubscriptionCommandValidator()
RuleFor(x => x.Url).NotEmpty()
.Must(url => Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
- .WithMessage("A valid absolute URL is required.");
+ .WithMessage("A valid absolute URL is required.")
+ .Must(url => !Uri.TryCreate(url, UriKind.Absolute, out var uri) || !WebhookUrlGuard.IsBlockedHost(uri.Host))
+ .WithMessage("The URL must not target a private, loopback, link-local, or metadata address.");
RuleFor(x => x.Events).NotEmpty().WithMessage("At least one event type is required.");
}
}
diff --git a/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookUrlGuard.cs b/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookUrlGuard.cs
new file mode 100644
index 0000000000..9e1d839192
--- /dev/null
+++ b/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookUrlGuard.cs
@@ -0,0 +1,112 @@
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
+
+namespace FSH.Modules.Webhooks.Services;
+
+///
+/// SSRF guard for outbound webhook delivery. A tenant registers the destination URL, so the
+/// target is untrusted input: without a guard a tenant can point a subscription at the cloud
+/// metadata endpoint, loopback, or an internal RFC1918 host and use the delivery log as a
+/// blind-SSRF oracle. gives fast feedback at the create boundary;
+/// is the authoritative check applied at connect time (after DNS
+/// resolution) so DNS-rebinding cannot slip a public hostname past the create check and then
+/// resolve to an internal address at delivery time.
+///
+public static class WebhookUrlGuard
+{
+ public static bool IsBlockedHost(string? host)
+ {
+ if (string.IsNullOrWhiteSpace(host))
+ {
+ return true;
+ }
+
+ // "localhost" and any *.localhost never leave the box — block by name; a literal IP is
+ // checked against the address ranges, real hostnames defer to the connect-time check.
+ if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
+ || host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return IPAddress.TryParse(host.Trim('[', ']'), out var ip) && IsBlockedAddress(ip);
+ }
+
+ public static bool IsBlockedAddress(IPAddress address)
+ {
+ ArgumentNullException.ThrowIfNull(address);
+
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ if (IPAddress.IsLoopback(address)
+ || address.Equals(IPAddress.Any)
+ || address.Equals(IPAddress.IPv6Any))
+ {
+ return true;
+ }
+
+ if (address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ var b = address.GetAddressBytes();
+ return b[0] switch
+ {
+ 10 => true, // 10.0.0.0/8 private
+ 127 => true, // 127.0.0.0/8 loopback
+ 169 when b[1] == 254 => true, // 169.254.0.0/16 link-local (cloud metadata)
+ 172 when b[1] >= 16 && b[1] <= 31 => true, // 172.16.0.0/12 private
+ 192 when b[1] == 168 => true, // 192.168.0.0/16 private
+ 100 when b[1] >= 64 && b[1] <= 127 => true, // 100.64.0.0/10 carrier-grade NAT
+ 0 => true, // 0.0.0.0/8 unspecified
+ >= 224 => true, // multicast / reserved
+ _ => false,
+ };
+ }
+
+ return address.IsIPv6LinkLocal
+ || address.IsIPv6SiteLocal
+ || address.IsIPv6Multicast
+ || IsIPv6UniqueLocal(address);
+ }
+
+ // fc00::/7 — unique local addresses (the IPv6 equivalent of RFC1918).
+ private static bool IsIPv6UniqueLocal(IPAddress address) =>
+ (address.GetAddressBytes()[0] & 0xFE) == 0xFC;
+
+ ///
+ /// that resolves the destination host and
+ /// refuses to connect to any non-routable/internal address. This is the authoritative SSRF
+ /// gate: it runs at delivery time on the real resolved IP, closing the DNS-rebinding window
+ /// left open by a create-time hostname check.
+ ///
+ public static async ValueTask ConnectAsync(SocketsHttpConnectionContext context, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ var host = context.DnsEndPoint.Host;
+ var addresses = await Dns.GetHostAddressesAsync(host, ct).ConfigureAwait(false);
+ var target = Array.Find(addresses, a => !IsBlockedAddress(a));
+ if (target is null)
+ {
+ throw new HttpRequestException(
+ $"Blocked webhook target '{host}': it resolves only to non-routable or internal addresses.");
+ }
+
+ Socket? socket = null;
+ try
+ {
+ socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
+ await socket.ConnectAsync(new IPEndPoint(target, context.DnsEndPoint.Port), ct).ConfigureAwait(false);
+ var stream = new NetworkStream(socket, ownsSocket: true);
+ socket = null; // ownership transferred to the stream
+ return stream;
+ }
+ finally
+ {
+ socket?.Dispose();
+ }
+ }
+}
diff --git a/src/Modules/Webhooks/Modules.Webhooks/WebhooksModule.cs b/src/Modules/Webhooks/Modules.Webhooks/WebhooksModule.cs
index 693ea2e1d5..cf38141f20 100644
--- a/src/Modules/Webhooks/Modules.Webhooks/WebhooksModule.cs
+++ b/src/Modules/Webhooks/Modules.Webhooks/WebhooksModule.cs
@@ -19,6 +19,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
+using System.Net.Http;
[assembly: FshModule(typeof(FSH.Modules.Webhooks.WebhooksModule), 400)]
@@ -46,6 +47,14 @@ public void ConfigureServices(IHostApplicationBuilder builder)
typeof(WebhookFanoutHandler<>));
builder.Services.AddHttpClient("Webhooks")
+ .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
+ {
+ // Untrusted tenant-supplied destination: never follow redirects (a 302 could bounce
+ // to an internal host) and screen the resolved IP at connect time so DNS-rebinding
+ // cannot map a public hostname to an internal address after the create-time check.
+ AllowAutoRedirect = false,
+ ConnectCallback = WebhookUrlGuard.ConnectAsync,
+ })
.AddHeroResilience(builder.Configuration);
builder.Services.AddHealthChecks()
diff --git a/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDispatchJobTests.cs b/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDispatchJobTests.cs
index 25e35a7ab8..756e66c82f 100644
--- a/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDispatchJobTests.cs
+++ b/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDispatchJobTests.cs
@@ -68,7 +68,7 @@ public async Task DispatchAsync_Should_PersistDeliveryRow_And_ThrowOnTransientFa
$"{TestConstants.WebhooksBasePath}/subscriptions",
new
{
- url = "https://127.0.0.1:1/dispatch-test", // unreachable → transient failure
+ url = "https://webhook-dispatch.invalid/dispatch-test", // .invalid never resolves → transient failure (and passes the SSRF create guard, unlike a loopback/private target)
events = new[] { uniqueEvent },
secret = "dispatch-secret"
});
diff --git a/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs b/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs
new file mode 100644
index 0000000000..e4bef7aa96
--- /dev/null
+++ b/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs
@@ -0,0 +1,57 @@
+using System.Net;
+using FSH.Modules.Webhooks.Contracts.v1.CreateWebhookSubscription;
+using FSH.Modules.Webhooks.Features.v1.CreateWebhookSubscription;
+using FSH.Modules.Webhooks.Services;
+
+namespace Webhooks.Tests;
+
+///
+/// Regression for audit finding SEC-01 (SSRF: outbound webhook delivery must have an egress guard).
+/// The create-boundary validator rejects URLs that point at loopback / link-local (cloud metadata) /
+/// private ranges, and is the authoritative connect-time
+/// gate applied to the resolved IP (defeats DNS rebinding) shared by both delivery sinks.
+///
+public sealed class CreateWebhookSubscriptionSsrfValidatorTests
+{
+ private readonly CreateWebhookSubscriptionCommandValidator _validator = new();
+
+ [Theory]
+ [InlineData("http://169.254.169.254/latest/meta-data/")] // cloud instance metadata
+ [InlineData("http://127.0.0.1/")] // loopback
+ [InlineData("http://10.0.0.1/")] // RFC1918 private range
+ [InlineData("http://localhost/hook")] // loopback by name
+ public void Validate_Should_Reject_SsrfTargetUrl(string url)
+ {
+ var command = new CreateWebhookSubscriptionCommand(url, ["ticket.created"], Secret: null);
+
+ var result = _validator.Validate(command);
+
+ result.IsValid.ShouldBeFalse(
+ $"webhook target '{url}' resolves to a private/loopback/metadata address and must be rejected " +
+ "at the create boundary to prevent SSRF.");
+ }
+
+ [Fact]
+ public void Validate_Should_Accept_PublicHttpsUrl()
+ {
+ var command = new CreateWebhookSubscriptionCommand("https://hooks.example.com/receive", ["ticket.created"], Secret: null);
+
+ var result = _validator.Validate(command);
+
+ result.IsValid.ShouldBeTrue();
+ }
+
+ [Theory]
+ [InlineData("127.0.0.1", true)] // loopback
+ [InlineData("10.5.6.7", true)] // RFC1918
+ [InlineData("172.16.0.1", true)] // RFC1918
+ [InlineData("192.168.1.1", true)] // RFC1918
+ [InlineData("169.254.169.254", true)] // link-local / metadata
+ [InlineData("100.64.0.1", true)] // carrier-grade NAT
+ [InlineData("::1", true)] // IPv6 loopback
+ [InlineData("fd00::1", true)] // IPv6 unique-local
+ [InlineData("8.8.8.8", false)] // public
+ [InlineData("1.1.1.1", false)] // public
+ public void IsBlockedAddress_Should_ScreenNonRoutableTargets(string ip, bool blocked) =>
+ WebhookUrlGuard.IsBlockedAddress(IPAddress.Parse(ip)).ShouldBe(blocked);
+}