Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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.");
}
}
112 changes: 112 additions & 0 deletions src/Modules/Webhooks/Modules.Webhooks/Services/WebhookUrlGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Net;
using System.Net.Http;
using System.Net.Sockets;

namespace FSH.Modules.Webhooks.Services;

/// <summary>
/// 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. <see cref="IsBlockedHost"/> gives fast feedback at the create boundary;
/// <see cref="IsBlockedAddress"/> 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.
/// </summary>
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;

/// <summary>
/// <see cref="SocketsHttpHandler.ConnectCallback"/> 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.
/// </summary>
public static async ValueTask<Stream> 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();
}
}
}
9 changes: 9 additions & 0 deletions src/Modules/Webhooks/Modules.Webhooks/WebhooksModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]

Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 <see cref="WebhookUrlGuard.IsBlockedAddress"/> is the authoritative connect-time
/// gate applied to the resolved IP (defeats DNS rebinding) shared by both delivery sinks.
/// </summary>
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);
}
Loading