diff --git a/src/Core/Dirt/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs b/src/Core/Dirt/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs index b03a68cfa66c..2f130a167dfb 100644 --- a/src/Core/Dirt/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs +++ b/src/Core/Dirt/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs @@ -205,7 +205,7 @@ public static IServiceCollection AddSlackService(this IServiceCollection service CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) && CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes)) { - services.AddHttpClient(SlackService.HttpClientName); + services.AddHttpClient(SlackService.HttpClientName).AddSsrfProtection(); services.TryAddSingleton(); } else @@ -235,7 +235,7 @@ public static IServiceCollection AddTeamsService(this IServiceCollection service CoreHelpers.SettingHasValue(globalSettings.Teams.ClientSecret) && CoreHelpers.SettingHasValue(globalSettings.Teams.Scopes)) { - services.AddHttpClient(TeamsService.HttpClientName); + services.AddHttpClient(TeamsService.HttpClientName).AddSsrfProtection(); services.TryAddSingleton(); services.TryAddSingleton(sp => sp.GetRequiredService()); services.TryAddSingleton(sp => sp.GetRequiredService()); @@ -299,8 +299,8 @@ internal static IServiceCollection AddEventIntegrationServices(this IServiceColl services.AddSlackService(globalSettings); services.AddTeamsService(globalSettings); services.TryAddSingleton(TimeProvider.System); - services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); - services.AddHttpClient(DatadogIntegrationHandler.HttpClientName); + services.AddHttpClient(WebhookIntegrationHandler.HttpClientName).AddSsrfProtection(); + services.AddHttpClient(DatadogIntegrationHandler.HttpClientName).AddSsrfProtection(); // Add integration handlers services.TryAddSingleton, SlackIntegrationHandler>(); diff --git a/src/Core/Utilities/HttpClientBuilderSsrfExtensions.cs b/src/Core/Utilities/HttpClientBuilderSsrfExtensions.cs new file mode 100644 index 000000000000..4f228758b266 --- /dev/null +++ b/src/Core/Utilities/HttpClientBuilderSsrfExtensions.cs @@ -0,0 +1,25 @@ +#nullable enable + +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Utilities; + +/// +/// Extension methods for registering SSRF protection on named HTTP clients. +/// +public static class HttpClientBuilderSsrfExtensions +{ + /// + /// Adds SSRF protection to an HTTP client by registering the + /// as an additional delegating handler. This ensures that all requests made by the client + /// resolve DNS before connecting and block requests to internal/private IP ranges. + /// + /// The to configure. + /// The so that additional calls can be chained. + public static IHttpClientBuilder AddSsrfProtection(this IHttpClientBuilder builder) + { + builder.Services.AddTransient(); + builder.AddHttpMessageHandler(); + return builder; + } +} diff --git a/src/Core/Utilities/IPAddressExtensions.cs b/src/Core/Utilities/IPAddressExtensions.cs new file mode 100644 index 000000000000..38ccd1602d6a --- /dev/null +++ b/src/Core/Utilities/IPAddressExtensions.cs @@ -0,0 +1,52 @@ +using System.Net; + +namespace Bit.Core.Utilities; + +/// +/// Extension methods for to determine if an address is internal/private. +/// Used for SSRF protection to block requests to private network ranges. +/// +public static class IPAddressExtensions +{ + /// + /// Determines whether the given IP address is an internal/private/reserved address. + /// This includes loopback, private RFC 1918, link-local, CGNAT (RFC 6598), + /// IPv6 unique local, and other reserved ranges. + /// + /// The IP address to check. + /// True if the IP is internal/private/reserved; otherwise false. + public static bool IsInternal(this IPAddress ip) + { + if (IPAddress.IsLoopback(ip)) + { + return true; + } + + var ipString = ip.ToString(); + if (ipString == "::1" || ipString == "::" || ipString.StartsWith("::ffff:")) + { + return true; + } + + // IPv6 + if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) + { + return ipString.StartsWith("fc") || ipString.StartsWith("fd") || + ipString.StartsWith("fe") || ipString.StartsWith("ff"); + } + + // IPv4 + var bytes = ip.GetAddressBytes(); + return bytes[0] switch + { + 0 => true, // "This" network (RFC 1122) + 10 => true, // Private (RFC 1918) + 100 => bytes[1] >= 64 && bytes[1] < 128, // CGNAT (RFC 6598) - 100.64.0.0/10 + 127 => true, // Loopback (RFC 1122) + 169 => bytes[1] == 254, // Link-local / cloud metadata (RFC 3927) + 172 => bytes[1] >= 16 && bytes[1] < 32, // Private (RFC 1918) + 192 => bytes[1] == 168, // Private (RFC 1918) + _ => false, + }; + } +} diff --git a/src/Core/Utilities/SsrfProtectionHandler.cs b/src/Core/Utilities/SsrfProtectionHandler.cs new file mode 100644 index 000000000000..605e9d56ea30 --- /dev/null +++ b/src/Core/Utilities/SsrfProtectionHandler.cs @@ -0,0 +1,114 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Utilities; + +/// +/// A that protects against Server-Side Request Forgery (SSRF) +/// by resolving hostnames to IP addresses before connecting and blocking requests +/// to internal/private/reserved IP ranges. +/// +/// This handler performs DNS resolution on the request URI, validates that none of the +/// resolved addresses are internal, and then rewrites the request to connect directly +/// to the validated IP while preserving the original Host header for TLS/SNI. +/// +public class SsrfProtectionHandler : DelegatingHandler +{ + private readonly ILogger _logger; + + public SsrfProtectionHandler(ILogger logger) + { + _logger = logger; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.RequestUri is null) + { + throw new SsrfProtectionException("Request URI is null."); + } + + var uri = request.RequestUri; + var host = uri.Host; + + // Resolve the host to IP addresses + var resolvedAddresses = await ResolveHostAsync(host).ConfigureAwait(false); + + if (resolvedAddresses.Length == 0) + { + throw new SsrfProtectionException($"Unable to resolve DNS for host: {host}"); + } + + // Validate ALL resolved addresses — block if any are internal + foreach (var address in resolvedAddresses) + { + var ipToCheck = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; + if (ipToCheck.IsInternal()) + { + _logger.LogWarning( + "SSRF protection blocked request to {Host} resolving to internal IP {Address}", + host, + ipToCheck); + throw new SsrfProtectionException( + $"Request to '{host}' was blocked because it resolves to an internal IP address."); + } + } + + // Pick the first valid IPv4 address (prefer IPv4 for compatibility) + var selectedIp = resolvedAddresses + .Select(a => a.IsIPv4MappedToIPv6 ? a.MapToIPv4() : a) + .FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) + ?? resolvedAddresses.First(); + + // Rewrite the request URI to use the IP directly, preserving the Host header + var builder = new UriBuilder(uri) + { + Host = selectedIp.ToString() + }; + + // Preserve the original Host header for TLS SNI and virtual hosting + if (!request.Headers.Contains("Host")) + { + request.Headers.Host = uri.IsDefaultPort ? host : $"{host}:{uri.Port}"; + } + + request.RequestUri = builder.Uri; + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resolves a hostname to its IP addresses. If the host is already an IP address, + /// returns it directly after validation. + /// + private static async Task ResolveHostAsync(string host) + { + // If the host is already an IP address, validate and return it directly + if (IPAddress.TryParse(host, out var directIp)) + { + return [directIp]; + } + + try + { + var hostEntry = await Dns.GetHostEntryAsync(host).ConfigureAwait(false); + return hostEntry.AddressList; + } + catch (SocketException) + { + return []; + } + } +} + +/// +/// Exception thrown when an SSRF protection check fails. +/// +public class SsrfProtectionException : Exception +{ + public SsrfProtectionException(string message) : base(message) { } + public SsrfProtectionException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/src/Icons/Util/IPAddressExtension.cs b/src/Icons/Util/IPAddressExtension.cs index 668548c5af68..3bbaa32e25bd 100644 --- a/src/Icons/Util/IPAddressExtension.cs +++ b/src/Icons/Util/IPAddressExtension.cs @@ -1,42 +1,13 @@ -#nullable enable - -using System.Net; +using System.Net; +using CoreIPAddressExtensions = Bit.Core.Utilities.IPAddressExtensions; namespace Bit.Icons.Extensions; +/// +/// Delegates to for shared SSRF protection logic. +/// Maintained for backward compatibility within the Icons project. +/// public static class IPAddressExtension { - public static bool IsInternal(this IPAddress ip) - { - if (IPAddress.IsLoopback(ip)) - { - return true; - } - - var ipString = ip.ToString(); - if (ipString == "::1" || ipString == "::" || ipString.StartsWith("::ffff:")) - { - return true; - } - - // IPv6 - if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) - { - return ipString.StartsWith("fc") || ipString.StartsWith("fd") || - ipString.StartsWith("fe") || ipString.StartsWith("ff"); - } - - // IPv4 - var bytes = ip.GetAddressBytes(); - return (bytes[0]) switch - { - 0 => true, - 10 => true, - 127 => true, - 169 => bytes[1] == 254, // Cloud environments, such as AWS - 172 => bytes[1] < 32 && bytes[1] >= 16, - 192 => bytes[1] == 168, - _ => false, - }; - } + public static bool IsInternal(this IPAddress ip) => CoreIPAddressExtensions.IsInternal(ip); } diff --git a/src/Icons/Util/ServiceCollectionExtension.cs b/src/Icons/Util/ServiceCollectionExtension.cs index 3bd35371989c..c6ba9c1c6148 100644 --- a/src/Icons/Util/ServiceCollectionExtension.cs +++ b/src/Icons/Util/ServiceCollectionExtension.cs @@ -2,6 +2,7 @@ using System.Net; using AngleSharp.Html.Parser; +using Bit.Core.Utilities; using Bit.Icons.Services; namespace Bit.Icons.Extensions; @@ -27,7 +28,7 @@ public static void ConfigureHttpClients(this IServiceCollection services) { AllowAutoRedirect = false, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, - }); + }).AddSsrfProtection(); // The CreatePasswordUri handler wants similar headers as Icons to portray coming from a browser but // needs to follow redirects to get the final URL. @@ -45,7 +46,7 @@ public static void ConfigureHttpClients(this IServiceCollection services) }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, - }); + }).AddSsrfProtection(); } public static void AddHtmlParsing(this IServiceCollection services) diff --git a/test/Core.Test/Utilities/IPAddressExtensionsTests.cs b/test/Core.Test/Utilities/IPAddressExtensionsTests.cs new file mode 100644 index 000000000000..9992da7b5d18 --- /dev/null +++ b/test/Core.Test/Utilities/IPAddressExtensionsTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class IPAddressExtensionsTests +{ + [Theory] + [InlineData("127.0.0.1", true)] // Loopback + [InlineData("127.0.0.2", true)] // Loopback range + [InlineData("10.0.0.1", true)] // Private Class A + [InlineData("10.255.255.255", true)] // Private Class A end + [InlineData("172.16.0.1", true)] // Private Class B start + [InlineData("172.31.255.255", true)] // Private Class B end + [InlineData("172.15.0.1", false)] // Just below private Class B + [InlineData("172.32.0.1", false)] // Just above private Class B + [InlineData("192.168.0.1", true)] // Private Class C + [InlineData("192.168.255.255", true)] // Private Class C end + [InlineData("192.167.0.1", false)] // Not private Class C + [InlineData("169.254.0.1", true)] // Link-local / cloud metadata + [InlineData("169.253.0.1", false)] // Not link-local + [InlineData("0.0.0.0", true)] // "This" network + [InlineData("8.8.8.8", false)] // Google DNS - public + [InlineData("1.1.1.1", false)] // Cloudflare DNS - public + [InlineData("52.20.30.40", false)] // Public IP + public void IsInternal_IPv4_ReturnsExpected(string ipString, bool expected) + { + var ip = IPAddress.Parse(ipString); + Assert.Equal(expected, ip.IsInternal()); + } + + [Theory] + [InlineData("100.64.0.0", true)] // CGNAT start + [InlineData("100.64.0.1", true)] // CGNAT + [InlineData("100.100.0.1", true)] // CGNAT middle + [InlineData("100.127.255.254", true)] // CGNAT near end + [InlineData("100.127.255.255", true)] // CGNAT end + [InlineData("100.63.255.255", false)] // Just below CGNAT + [InlineData("100.128.0.0", false)] // Just above CGNAT + [InlineData("100.0.0.1", false)] // 100.x but not CGNAT + public void IsInternal_CGNAT_ReturnsExpected(string ipString, bool expected) + { + var ip = IPAddress.Parse(ipString); + Assert.Equal(expected, ip.IsInternal()); + } + + [Theory] + [InlineData("::1", true)] // IPv6 loopback + [InlineData("::", true)] // IPv6 unspecified + [InlineData("fc00::1", true)] // IPv6 unique local + [InlineData("fd00::1", true)] // IPv6 unique local + [InlineData("fe80::1", true)] // IPv6 link-local + [InlineData("ff02::1", true)] // IPv6 multicast + [InlineData("2607:f8b0:4004:800::200e", false)] // Google public IPv6 + public void IsInternal_IPv6_ReturnsExpected(string ipString, bool expected) + { + var ip = IPAddress.Parse(ipString); + Assert.Equal(expected, ip.IsInternal()); + } + + [Theory] + [InlineData("::ffff:127.0.0.1", true)] // IPv4-mapped IPv6 loopback + [InlineData("::ffff:10.0.0.1", true)] // IPv4-mapped IPv6 private + [InlineData("::ffff:192.168.1.1", true)] // IPv4-mapped IPv6 private + public void IsInternal_IPv4MappedIPv6_ReturnsExpected(string ipString, bool expected) + { + var ip = IPAddress.Parse(ipString); + Assert.Equal(expected, ip.IsInternal()); + } +} diff --git a/test/Core.Test/Utilities/SsrfProtectionHandlerTests.cs b/test/Core.Test/Utilities/SsrfProtectionHandlerTests.cs new file mode 100644 index 000000000000..3e75c1418d79 --- /dev/null +++ b/test/Core.Test/Utilities/SsrfProtectionHandlerTests.cs @@ -0,0 +1,126 @@ +using System.Net; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class SsrfProtectionHandlerTests +{ + private readonly ILogger _logger = Substitute.For>(); + + /// + /// A test handler that captures the request and returns a canned response. + /// Used as the inner handler for . + /// + private class TestInnerHandler : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } + + /// + /// Creates an SsrfProtectionHandler wrapping a TestInnerHandler for testing purposes. + /// + private (HttpClient client, TestInnerHandler inner) CreateClient() + { + var inner = new TestInnerHandler(); + var handler = new SsrfProtectionHandler(_logger) + { + InnerHandler = inner + }; + var client = new HttpClient(handler); + return (client, inner); + } + + [Fact] + public async Task SendAsync_NullRequestUri_ThrowsInvalidOperationException() + { + var (client, _) = CreateClient(); + var request = new HttpRequestMessage + { + RequestUri = null, + Method = HttpMethod.Get + }; + + // HttpClient validates the URI before the handler runs + await Assert.ThrowsAsync(() => client.SendAsync(request)); + } + + [Theory] + [InlineData("http://127.0.0.1/test")] + [InlineData("http://10.0.0.1/test")] + [InlineData("http://192.168.1.1/test")] + [InlineData("http://172.16.0.1/test")] + [InlineData("http://169.254.169.254/latest/meta-data/")] // AWS metadata + [InlineData("http://100.64.0.1/test")] // CGNAT + [InlineData("http://[::1]/test")] // IPv6 loopback + public async Task SendAsync_DirectIpInternal_ThrowsSsrfProtectionException(string url) + { + var (client, _) = CreateClient(); + + await Assert.ThrowsAsync( + () => client.GetAsync(url)); + } + + [Theory] + [InlineData("http://8.8.8.8/test")] + [InlineData("http://1.1.1.1/test")] + [InlineData("http://52.20.30.40/test")] + public async Task SendAsync_DirectIpPublic_Succeeds(string url) + { + var (client, inner) = CreateClient(); + + var response = await client.GetAsync(url); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(inner.LastRequest); + } + + [Fact] + public async Task SendAsync_PublicHost_PreservesHostHeader() + { + // This test verifies that when a hostname resolves to a public IP, + // the handler rewrites the URI to the IP but preserves the Host header. + // We can't easily mock DNS in C#, so we test with a direct IP that + // doesn't need DNS resolution. + var (client, inner) = CreateClient(); + + var response = await client.GetAsync("http://8.8.8.8/test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(inner.LastRequest); + // URI host should be rewritten to the IP + Assert.Equal("8.8.8.8", inner.LastRequest!.RequestUri!.Host); + } + + [Fact] + public async Task SendAsync_HostnameResolvingToInternalIp_ThrowsSsrfProtectionException() + { + // "localhost" should always resolve to 127.0.0.1 or ::1 + var (client, _) = CreateClient(); + + await Assert.ThrowsAsync( + () => client.GetAsync("http://localhost/test")); + } + + [Theory] + [InlineData("http://0.0.0.0/test")] + [InlineData("http://127.0.0.2/test")] + [InlineData("http://100.100.100.100/test")] // CGNAT + public async Task SendAsync_VariousInternalIps_ThrowsSsrfProtectionException(string url) + { + var (client, _) = CreateClient(); + + await Assert.ThrowsAsync( + () => client.GetAsync(url)); + } +}