What changed, and why it matters
This commit rewrites how BTCPay Server decides whether a network address is 'internal' versus 'public'. The old check only recognized a few private ranges (local loopback and RFC1918). The new check recognizes many more special-use ranges, such as carrier-grade NAT, link-local, documentation/test IPs, multicast, and IPv6 equivalents. The change is framed as a hardening improvement, but the commit message does not explicitly call it a security fix. If the old code was used to block or restrict access to internal services, the previous gaps could have let an attacker trick the server into treating an internal address as public, potentially enabling Server-Side Request Forgery (SSRF) or similar attacks. However, the diff alone does not show a specific vulnerable call site or exploit path.
Review all callers of IsLocalNetwork to confirm the function is used as an allow/deny gate for outbound or inbound network access. If it is used to prevent SSRF or internal service exposure, verify that the new ranges match the intended policy and that no caller bypasses the helper. Consider adding regression tests for edge cases such as IPv4-mapped IPv6, uppercase hostnames, and trailing-dot DNS names. No immediate emergency patch is indicated by the diff alone, but treat this as a defense-in-depth improvement that may close a real attack surface.
Security signals we found
Widens classification of addresses as internal/private
Adds coverage for CGNAT (100.64.0.0/10), link-local (169.254.0.0/16), documentation/test nets, multicast, and IPv6 special-use ranges
Adds unit tests for internal-network detection, indicating correctness is important
No explicit CVE, advisory, or security disclosure referenced in commit or supplied materials
No direct call site or exploit chain visible in the supplied diff
Evidence from the diff
The patch replaces the IP-based internal-network test in BTCPayServer/Extensions.cs. Previously IsLocalNetwork() returned ip.IsLocal() || ip.IsRFC1918(), which covers loopback and RFC1918 (10/0.0.0, 172.16/12, 192.168/16) only. The new implementation adds an IsPublicAddress() helper that treats the following as non-public: 0.0.0.0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.0.0/24, 192.0.2/24, 192.168/16, 198.18/15, 198.51.100/24, 203.0.113/24, multicast (224/4), IPv6 any/loopback/link-local/site-local/multicast, 2001:2::/48, 2001:db8::/32, and 3fff::/20. It also trims a trailing dot from DNS names. A new unit-test file enumerates these cases. The change is defensive: it widens the set of addresses considered internal. Without seeing where IsLocalNetwork is consumed, we cannot confirm a concrete SSRF bypass, but the pattern is a classic SSRF defense improvement.
Changed components
BTCPayServer/Extensions.cs - IsLocalNetwork helperBTCPayServer/Extensions.cs - new IsPublicAddress helperBTCPayServer.Tests/NetworkExtensionsTests.csInspect captured patch +76 / −1
### BTCPayServer.Tests/NetworkExtensionsTests.cs
@@ -0,0 +1,39 @@
+using Xunit;
+
+namespace BTCPayServer.Tests;
+
+public class NetworkExtensionsTests
+{
+ [Theory]
+ [InlineData("localhost.", true)]
+ [InlineData("server.internal.", true)]
+ [InlineData("example.com.", false)]
+ [InlineData("0.0.0.0", true)]
+ [InlineData("10.0.0.1", true)]
+ [InlineData("100.64.0.1", true)]
+ [InlineData("127.0.0.1", true)]
+ [InlineData("169.254.0.1", true)]
+ [InlineData("172.16.0.1", true)]
+ [InlineData("192.0.0.1", true)]
+ [InlineData("192.0.2.1", true)]
+ [InlineData("192.168.0.1", true)]
+ [InlineData("198.18.0.1", true)]
+ [InlineData("198.51.100.1", true)]
+ [InlineData("203.0.113.1", true)]
+ [InlineData("224.0.0.1", true)]
+ [InlineData("8.8.8.8", false)]
+ [InlineData("::", true)]
+ [InlineData("::1", true)]
+ [InlineData("::ffff:10.0.0.1", true)]
+ [InlineData("2001:2::1", true)]
+ [InlineData("2001:db8::1", true)]
+ [InlineData("3fff:fff::1", true)]
+ [InlineData("fc00::1", true)]
+ [InlineData("fe80::1", true)]
+ [InlineData("ff00::1", true)]
+ [InlineData("2001:4860:4860::8888", false)]
+ public void DetectsInternalNetworkAddresses(string server, bool expected)
+ {
+ Assert.Equal(expected, BTCPayServer.Extensions.IsLocalNetwork(server));
+ }
+}
### BTCPayServer/Extensions.cs
@@ -10,6 +10,7 @@
using System.Linq;
using System.Linq.Expressions;
using System.Net;
+using System.Net.Sockets;
using System.Net.WebSockets;
using System.Reflection;
using System.Security.Claims;
@@ -663,6 +664,7 @@ public static void SetHeader(this HttpResponse resp, string name, string? value)
public static bool IsLocalNetwork(string server)
{
ArgumentNullException.ThrowIfNull(server);
+ server = server.TrimEnd('.');
if (Uri.CheckHostName(server) == UriHostNameType.Dns)
{
return server.EndsWith(".internal", StringComparison.OrdinalIgnoreCase) ||
@@ -672,10 +674,44 @@ public static bool IsLocalNetwork(string server)
}
if (IPAddress.TryParse(server, out var ip))
{
- return ip.IsLocal() || ip.IsRFC1918();
+ return !IsPublicAddress(ip);
}
return false;
}
+
+ private static bool IsPublicAddress(IPAddress ip)
+ {
+ if (ip.IsIPv4MappedToIPv6)
+ ip = ip.MapToIPv4();
+ if (ip.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ var bytes = ip.GetAddressBytes();
+ return !IPAddress.IPv6Any.Equals(ip) &&
+ !IPAddress.IPv6Loopback.Equals(ip) &&
+ !ip.IsIPv6LinkLocal &&
+ !ip.IsIPv6SiteLocal &&
+ !ip.IsIPv6Multicast &&
+ !(bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x00 && bytes[3] == 0x02 && bytes[4] == 0x00 && bytes[5] == 0x00) &&
+ !(bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && bytes[3] == 0xb8) &&
+ !(bytes[0] == 0x3f && bytes[1] == 0xff && (bytes[2] & 0xf0) == 0) &&
+ (bytes[0] & 0xfe) != 0xfc;
+ }
+
+ if (ip.AddressFamily != AddressFamily.InterNetwork)
+ return false;
+ var b = ip.GetAddressBytes();
+ return b[0] is not (0 or 10 or 127) &&
+ !(b[0] == 100 && b[1] is >= 64 and <= 127) &&
+ !(b[0] == 169 && b[1] == 254) &&
+ !(b[0] == 172 && b[1] is >= 16 and <= 31) &&
+ !(b[0] == 192 && b[1] == 0 && b[2] == 0) &&
+ !(b[0] == 192 && b[1] == 0 && b[2] == 2) &&
+ !(b[0] == 192 && b[1] == 168) &&
+ !(b[0] == 198 && b[1] is 18 or 19) &&
+ !(b[0] == 198 && b[1] == 51 && b[2] == 100) &&
+ !(b[0] == 203 && b[1] == 0 && b[2] == 113) &&
+ b[0] < 224;
+ }
#nullable enable
public static LNURLPayPaymentHandler GetLNURLHandler(this PaymentMethodHandlerDictionary handlers, BTCPayNetwork network)
{Why this scored 63/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.