Skip to content
Open
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
Expand Up @@ -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<ISlackService, SlackService>();
}
else
Expand Down Expand Up @@ -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<TeamsService>();
services.TryAddSingleton<IBot>(sp => sp.GetRequiredService<TeamsService>());
services.TryAddSingleton<ITeamsService>(sp => sp.GetRequiredService<TeamsService>());
Expand Down Expand Up @@ -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<IIntegrationHandler<SlackIntegrationConfigurationDetails>, SlackIntegrationHandler>();
Expand Down
25 changes: 25 additions & 0 deletions src/Core/Utilities/HttpClientBuilderSsrfExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
ο»Ώ#nullable enable

using Microsoft.Extensions.DependencyInjection;

namespace Bit.Core.Utilities;

/// <summary>
/// Extension methods for registering SSRF protection on named HTTP clients.
/// </summary>
public static class HttpClientBuilderSsrfExtensions
{
/// <summary>
/// Adds SSRF protection to an HTTP client by registering the <see cref="SsrfProtectionHandler"/>
/// 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.
/// </summary>
/// <param name="builder">The <see cref="IHttpClientBuilder"/> to configure.</param>
/// <returns>The <see cref="IHttpClientBuilder"/> so that additional calls can be chained.</returns>
public static IHttpClientBuilder AddSsrfProtection(this IHttpClientBuilder builder)
{
builder.Services.AddTransient<SsrfProtectionHandler>();
builder.AddHttpMessageHandler<SsrfProtectionHandler>();
return builder;
}
}
52 changes: 52 additions & 0 deletions src/Core/Utilities/IPAddressExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
ο»Ώusing System.Net;

namespace Bit.Core.Utilities;

/// <summary>
/// Extension methods for <see cref="IPAddress"/> to determine if an address is internal/private.
/// Used for SSRF protection to block requests to private network ranges.
/// </summary>
public static class IPAddressExtensions
{
/// <summary>
/// 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.
/// </summary>
/// <param name="ip">The IP address to check.</param>
/// <returns>True if the IP is internal/private/reserved; otherwise false.</returns>
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,
};
}
}
114 changes: 114 additions & 0 deletions src/Core/Utilities/SsrfProtectionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
ο»Ώusing System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;

namespace Bit.Core.Utilities;

/// <summary>
/// A <see cref="DelegatingHandler"/> 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.
/// </summary>
public class SsrfProtectionHandler : DelegatingHandler
{
private readonly ILogger<SsrfProtectionHandler> _logger;

public SsrfProtectionHandler(ILogger<SsrfProtectionHandler> logger)
{
_logger = logger;
}

protected override async Task<HttpResponseMessage> 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);
}

/// <summary>
/// Resolves a hostname to its IP addresses. If the host is already an IP address,
/// returns it directly after validation.
/// </summary>
private static async Task<IPAddress[]> 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 [];
}
}
}

/// <summary>
/// Exception thrown when an SSRF protection check fails.
/// </summary>
public class SsrfProtectionException : Exception
{
public SsrfProtectionException(string message) : base(message) { }
public SsrfProtectionException(string message, Exception innerException) : base(message, innerException) { }
}
39 changes: 6 additions & 33 deletions src/Icons/Util/IPAddressExtension.cs
Original file line number Diff line number Diff line change
@@ -1,42 +1,15 @@
ο»Ώ#nullable enable

using System.Net;
using CoreIPAddressExtensions = Bit.Core.Utilities.IPAddressExtensions;

namespace Bit.Icons.Extensions;

/// <summary>
/// Delegates to <see cref="CoreIPAddressExtensions"/> for shared SSRF protection logic.
/// Maintained for backward compatibility within the Icons project.
/// </summary>
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);
}
5 changes: 3 additions & 2 deletions src/Icons/Util/ServiceCollectionExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

using System.Net;
using AngleSharp.Html.Parser;
using Bit.Core.Utilities;
using Bit.Icons.Services;

namespace Bit.Icons.Extensions;
Expand All @@ -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.
Expand All @@ -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)
Expand Down
71 changes: 71 additions & 0 deletions test/Core.Test/Utilities/IPAddressExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading
Loading