diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs index b35787055..aa164831a 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs @@ -16,7 +16,8 @@ internal static class Extensions public static StringBuilder AppendJsonPath(this StringBuilder sb, PropertyPath path) { sb.Append('`'); - sb.Append(path[0]); + // Escape embedded backticks so a crafted path segment cannot break out of the identifier. + sb.Append(path[0].Replace("`", "``", StringComparison.Ordinal)); sb.Append("`, "); sb.AppendJsonPropertyPath(path); return sb; @@ -36,7 +37,7 @@ internal static class Extensions { sb.Append('.'); sb.Append('"'); - sb.Append(property); + sb.Append(EscapeProperty(property)); sb.Append('"'); } } @@ -45,6 +46,22 @@ internal static class Extensions return sb; } + // The property name is a user-controlled JSON path segment that is embedded as a double-quoted + // member inside a single-quoted SQL string literal. Escape double-quotes/backslashes at the + // JSON-path level, then backslashes/single-quotes at the MySQL string-literal level. MySQL treats + // the backslash as a string-literal escape character, so the JSON-path escapes must themselves be + // escaped again to survive string-literal parsing. This prevents SQL injection. + private static string EscapeProperty(string property) + { + return property + // JSON path escaping. + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal) + // MySQL string-literal escaping. + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("'", "''", StringComparison.Ordinal); + } + public static string JsonSubPath(this PropertyPath path) { return new StringBuilder().AppendJsonPropertyPath(path).ToString(); diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Extensions.cs b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Extensions.cs index 45d288471..7aa2a9791 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Extensions.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Extensions.cs @@ -16,7 +16,8 @@ public static class Extensions public static StringBuilder AppendJsonPath(this StringBuilder sb, PropertyPath path, bool asString) { sb.Append('"'); - sb.Append(path[0]); + // Escape embedded quotes so a crafted path segment cannot break out of the quoted identifier. + sb.Append(path[0].Replace("\"", "\"\"", StringComparison.Ordinal)); sb.Append('"'); var i = 1; @@ -37,7 +38,11 @@ public static class Extensions } else { - sb.Append($"'{property}'"); + // The property name is a user-controlled JSON path segment that is embedded as a + // single-quoted string literal. Escape embedded quotes to prevent SQL injection. + sb.Append('\''); + sb.Append(property.Replace("'", "''", StringComparison.Ordinal)); + sb.Append('\''); } i++; diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs index f2e4bdb8e..6b8e05385 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs @@ -16,7 +16,8 @@ internal static class Extensions public static StringBuilder AppendJsonPath(this StringBuilder sb, PropertyPath path) { sb.Append('['); - sb.Append(path[0]); + // Escape embedded closing brackets so a crafted path segment cannot break out of the identifier. + sb.Append(path[0].Replace("]", "]]", StringComparison.Ordinal)); sb.Append("], "); sb.AppendJsonSubPath(path); return sb; @@ -36,7 +37,7 @@ internal static class Extensions { sb.Append('.'); sb.Append('"'); - sb.Append(property); + sb.Append(EscapeProperty(property)); sb.Append('"'); } } @@ -45,6 +46,19 @@ internal static class Extensions return sb; } + // The property name is a user-controlled JSON path segment that is embedded as a double-quoted + // member inside a single-quoted SQL string literal. Escape backslashes and double-quotes at the + // JSON-path level and single-quotes at the SQL-literal level to prevent SQL injection. SQL Server + // does not treat the backslash as a string-literal escape character, so the JSON-path escapes + // reach the JSON parser verbatim. + private static string EscapeProperty(string property) + { + return property + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal) + .Replace("'", "''", StringComparison.Ordinal); + } + public static string JsonSubPath(this PropertyPath path) { return new StringBuilder().AppendJsonSubPath(path).ToString(); diff --git a/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj b/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj index ffa089d41..b945ed025 100644 --- a/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj +++ b/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj @@ -43,13 +43,13 @@ - - - - - - - + + + + + + + diff --git a/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj b/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj index 9504e6a67..b7ccc948c 100644 --- a/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj +++ b/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj @@ -20,17 +20,17 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - - - - - - + + + + + + diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj b/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj index a84ae89e0..9c50b0ea1 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj @@ -20,7 +20,7 @@ - + diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index 83b2b9f14..cea511d8c 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -29,8 +29,8 @@ - - + + diff --git a/backend/src/Squidex.Infrastructure/Http/SsrfExtensions.cs b/backend/src/Squidex.Infrastructure/Http/SsrfExtensions.cs deleted file mode 100644 index 8c5b41ff2..000000000 --- a/backend/src/Squidex.Infrastructure/Http/SsrfExtensions.cs +++ /dev/null @@ -1,72 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Net; -using System.Net.Sockets; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Squidex.Infrastructure.Http; - -public static class SsrfExtensions -{ - public static IHttpClientBuilder EnableSsrfProtection(this IHttpClientBuilder builder ) - { - builder.Services.AddTransient(); - - builder.AddHttpMessageHandler(); - builder.ConfigurePrimaryHttpMessageHandler(services => - { - var options = services.GetService>()?.Value ?? new (); - - return new SocketsHttpHandler - { - ConnectCallback = options.EnableDnsRebindingProtection - ? CreateSecureConnectCallback(options) - : null, - AllowAutoRedirect = options.AllowAutoRedirect, - }; - }); - - return builder; - } - - private static Func> CreateSecureConnectCallback(SsrfOptions options) - { - return async (context, cancellationToken) => - { - var host = context.DnsEndPoint.Host; - - if (options.IsWhitelistedHost(host)) - { - return await CreateSockedAsync(context, cancellationToken); - } - - // Re-validate DNS to prevent DNS rebinding attacks - var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken); - - foreach (var address in addresses) - { - if (SsrfHelper.IsPrivateOrReservedIp(address, options.BlockedIpAddresses)) - { - throw new HttpRequestException($"Connection to private IP blocked: {address}"); - } - } - - return await CreateSockedAsync(context, cancellationToken); - }; - } - - private static async Task CreateSockedAsync(SocketsHttpConnectionContext context, - CancellationToken ct) - { - var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); - await socket.ConnectAsync(context.DnsEndPoint, ct); - - return new NetworkStream(socket, ownsSocket: true); - } -} diff --git a/backend/src/Squidex.Infrastructure/Http/SsrfHelper.cs b/backend/src/Squidex.Infrastructure/Http/SsrfHelper.cs deleted file mode 100644 index 653d50a1f..000000000 --- a/backend/src/Squidex.Infrastructure/Http/SsrfHelper.cs +++ /dev/null @@ -1,66 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Net; -using System.Net.Sockets; - -#pragma warning disable SA1025 // Code should not contain multiple whitespace in a row - -namespace Squidex.Infrastructure.Http; - -public static class SsrfHelper -{ - public static bool IsPrivateOrReservedIp(IPAddress ip, HashSet? blackList) - { - if (IPAddress.IsLoopback(ip)) - { - return true; - } - - if (ip.AddressFamily == AddressFamily.InterNetwork) - { - var bytes = ip.GetAddressBytes(); - - var isBlocked = - (bytes[0] == 10) || // 10.0.0.0/8 - (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) || // 172.16.0.0/12 - (bytes[0] == 192 && bytes[1] == 168) || // 192.168.0.0/16 - (bytes[0] == 169 && bytes[1] == 254) || // link-local - (bytes[0] == 0) || // 0.0.0.0/8 - (bytes[0] >= 224 && bytes[0] <= 239) || // 224.0.0.0/4 multicast - (bytes[0] >= 240); // 240.0.0.0/4 reserved - - if (isBlocked) - { - return true; - } - } - - if (ip.AddressFamily == AddressFamily.InterNetworkV6) - { - var bytes = ip.GetAddressBytes(); - - var isBlocked = - ip.IsIPv6LinkLocal || // fe80::/10 - ip.IsIPv6SiteLocal || // fec0::/10 (deprecated) - ip.IsIPv6Multicast || // ff00::/8 - ((bytes[0] & 0xfe) == 0xfc); // fc00::/7 - Unique local - - if (isBlocked) - { - return true; - } - } - - if (blackList is { Count: > 0 }) - { - return blackList.Contains(ip); - } - - return false; - } -} diff --git a/backend/src/Squidex.Infrastructure/Http/SsrfOptions.cs b/backend/src/Squidex.Infrastructure/Http/SsrfOptions.cs deleted file mode 100644 index 3dd223e5d..000000000 --- a/backend/src/Squidex.Infrastructure/Http/SsrfOptions.cs +++ /dev/null @@ -1,37 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Net; - -namespace Squidex.Infrastructure.Http; - -public sealed class SsrfOptions -{ - public HashSet WhitelistedHosts { get; set; } = - new HashSet( - [], - StringComparer.OrdinalIgnoreCase); - - public HashSet AllowedSchemes { get; set; } = - new HashSet( - ["http", "https"], - StringComparer.OrdinalIgnoreCase); - - public HashSet BlockedIpAddresses { get; set; } = - new HashSet( - [IPAddress.Parse("169.254.169.254")], - EqualityComparer.Default); - - public bool AllowAutoRedirect { get; set; } - - public bool EnableDnsRebindingProtection { get; set; } = true; - - public bool IsWhitelistedHost(string host) - { - return WhitelistedHosts.Contains(host) || WhitelistedHosts.Contains("*"); - } -} diff --git a/backend/src/Squidex.Infrastructure/Http/SsrfProtectionHandler.cs b/backend/src/Squidex.Infrastructure/Http/SsrfProtectionHandler.cs deleted file mode 100644 index 9296bee13..000000000 --- a/backend/src/Squidex.Infrastructure/Http/SsrfProtectionHandler.cs +++ /dev/null @@ -1,56 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Net; -using System.Net.Sockets; -using Microsoft.Extensions.Options; - -namespace Squidex.Infrastructure.Http; - -public class SsrfProtectionHandler(IOptions options) : DelegatingHandler -{ - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - if (request.RequestUri == null) - { - throw new HttpRequestException("Request URI is null"); - } - - if (!options.Value.AllowedSchemes.Contains(request.RequestUri.Scheme)) - { - throw new HttpRequestException($"Scheme '{request.RequestUri.Scheme}' is not allowed"); - } - - var host = request.RequestUri.Host; - - if (options.Value.IsWhitelistedHost(host)) - { - return await base.SendAsync(request, cancellationToken); - } - - try - { - var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken); - - foreach (var address in addresses) - { - if (SsrfHelper.IsPrivateOrReservedIp(address, options.Value.BlockedIpAddresses)) - { - throw new HttpRequestException($"Request blocked: '{host}' resolves to private IP {address}"); - } - } - } - catch (SocketException ex) - { - throw new HttpRequestException($"DNS resolution failed for '{host}'", ex); - } - - return await base.SendAsync(request, cancellationToken); - } -} diff --git a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index bb2e06f18..de440b882 100644 --- a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -24,13 +24,13 @@ - - - - - - - + + + + + + + diff --git a/backend/src/Squidex.Shared/PermissionIds.cs b/backend/src/Squidex.Shared/PermissionIds.cs index dae8a43fe..26e246c13 100644 --- a/backend/src/Squidex.Shared/PermissionIds.cs +++ b/backend/src/Squidex.Shared/PermissionIds.cs @@ -34,6 +34,7 @@ public static class PermissionIds public const string AdminUsers = "squidex.admin.users"; public const string AdminUsersRead = "squidex.admin.users.read"; public const string AdminUsersCreate = "squidex.admin.users.create"; + public const string AdminUsersDelete = "squidex.admin.users.delete"; public const string AdminUsersUpdate = "squidex.admin.users.update"; public const string AdminUsersUnlock = "squidex.admin.users.unlock"; public const string AdminUsersLock = "squidex.admin.users.lock"; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs b/backend/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs index e05856a06..65d7e1adb 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs @@ -167,7 +167,7 @@ public sealed class UserManagementController(ICommandBus commandBus, IUserServic [HttpDelete] [Route("user-management/{id}/")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ApiPermission(PermissionIds.AdminUsersUnlock)] + [ApiPermission(PermissionIds.AdminUsersDelete)] public async Task DeleteUser(string id) { if (this.IsUser(id)) diff --git a/backend/src/Squidex/Config/Authentication/IdentityServices.cs b/backend/src/Squidex/Config/Authentication/IdentityServices.cs index 14589250c..d468dcb2d 100644 --- a/backend/src/Squidex/Config/Authentication/IdentityServices.cs +++ b/backend/src/Squidex/Config/Authentication/IdentityServices.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Domain.Users; +using Squidex.Hosting.Ssrf; using Squidex.Shared.Users; namespace Squidex.Config.Authentication; @@ -17,7 +18,8 @@ public static class IdentityServices services.Configure(config, "identity"); - services.AddHttpClient("Users"); + services.AddHttpClient("Users") + .EnableSsrfProtection(); services.AddSingletonAs() .AsOptional(); diff --git a/backend/src/Squidex/Config/Domain/AssetServices.cs b/backend/src/Squidex/Config/Domain/AssetServices.cs index ff47df2da..f7102b4cd 100644 --- a/backend/src/Squidex/Config/Domain/AssetServices.cs +++ b/backend/src/Squidex/Config/Domain/AssetServices.cs @@ -11,6 +11,7 @@ using Squidex.Domain.Apps.Entities.Assets.Queries; using Squidex.Domain.Apps.Entities.Assets.Queries.Steps; using Squidex.Domain.Apps.Entities.History; using Squidex.Domain.Apps.Entities.Search; +using Squidex.Hosting.Ssrf; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Http; diff --git a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs index 90b0a4c0d..f88678654 100644 --- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -19,6 +19,7 @@ using Squidex.Domain.Apps.Core.Templates; using Squidex.Domain.Apps.Core.Templates.Extensions; using Squidex.Domain.Apps.Entities.Contents.Counter; using Squidex.Domain.Apps.Entities.Tags; +using Squidex.Hosting.Ssrf; using Squidex.Infrastructure; using Squidex.Infrastructure.Diagnostics; using Squidex.Infrastructure.Http; diff --git a/backend/src/Squidex/Config/Domain/RuleServices.cs b/backend/src/Squidex/Config/Domain/RuleServices.cs index e9d35f57d..72054c4f0 100644 --- a/backend/src/Squidex/Config/Domain/RuleServices.cs +++ b/backend/src/Squidex/Config/Domain/RuleServices.cs @@ -22,7 +22,6 @@ using Squidex.Domain.Apps.Entities.Rules.UsageTracking; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Flows.Internal.Execution; using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Http; using Squidex.Infrastructure.Reflection; namespace Squidex.Config.Domain; @@ -34,9 +33,6 @@ public static class RuleServices services.Configure(config, "rules"); - services.Configure(config, - "ssrf"); - services.AddSingletonAs() .As(); diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 738523142..b893282dd 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -57,16 +57,16 @@ - - - - - - + + + + + + - - - + + + @@ -80,12 +80,12 @@ - - + + - + diff --git a/backend/src/Squidex/Startup.cs b/backend/src/Squidex/Startup.cs index f003ba996..7686ff81a 100644 --- a/backend/src/Squidex/Startup.cs +++ b/backend/src/Squidex/Startup.cs @@ -27,6 +27,7 @@ public sealed class Startup(IConfiguration config) services.AddHealthChecks(); services.AddDefaultWebServices(config); services.AddDefaultForwardRules(); + services.AddSsrfProtectedHttpClient(config); // They must be called in this order. services.AddSquidexMvcWithPlugins(config); diff --git a/backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs b/backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs index e4dce705a..42e2fe093 100644 --- a/backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs +++ b/backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs @@ -890,6 +890,24 @@ public abstract class EFQueryTests(ISqlFixture fixture) Assert.Equal(AllExept(7), actual.Order().ToArray()); } + [Theory] + [InlineData("x' OR '1'='1")] + [InlineData("x\" OR \"1\"=\"1")] + [InlineData("x') OR (1=1) --")] + [InlineData("x\\")] + public async Task Should_not_allow_sql_injection_through_json_path(string malicious) + { + // A crafted JSON path segment must be treated as a literal (non-matching) key and must not + // break out of the generated SQL. If escaping failed, an OR-based payload would either raise + // a SQL syntax error or leak every row instead of returning nothing. + var actual = await QueryAsync(new ClrQuery + { + Filter = ClrFilter.Eq($"Json.mixed.{malicious}", "value"), + }); + + Assert.Empty(actual); + } + [Fact] public async Task Should_filter_by_string_contains_in_json() { diff --git a/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs index 44de7eb65..e929cf1ae 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs @@ -123,6 +123,56 @@ public class SsrfHelperTests Assert.True(result); } + [Theory] + [InlineData("::ffff:127.0.0.1")] + [InlineData("::ffff:10.0.0.1")] + [InlineData("::ffff:172.16.0.1")] + [InlineData("::ffff:192.168.0.1")] + [InlineData("::ffff:169.254.169.254")] + [InlineData("::ffff:0.0.0.0")] + public void Should_block_ipv4_mapped_ipv6_private_addresses(string ip) + { + var address = IPAddress.Parse(ip); + + var result = SsrfHelper.IsPrivateOrReservedIp(address, null); + + Assert.True(result); + } + + [Fact] + public void Should_block_ipv4_mapped_ipv6_of_blacklisted_ipv4() + { + var address = IPAddress.Parse("::ffff:169.254.169.254"); + var blacklist = new HashSet { IPAddress.Parse("169.254.169.254") }; + + var result = SsrfHelper.IsPrivateOrReservedIp(address, blacklist); + + Assert.True(result); + } + + [Fact] + public void Should_block_ipv4_of_blacklisted_ipv4_mapped_ipv6() + { + var address = IPAddress.Parse("1.2.3.4"); + var blacklist = new HashSet { IPAddress.Parse("::ffff:1.2.3.4") }; + + var result = SsrfHelper.IsPrivateOrReservedIp(address, blacklist); + + Assert.True(result); + } + + [Theory] + [InlineData("::ffff:8.8.8.8")] + [InlineData("::ffff:1.1.1.1")] + public void Should_allow_ipv4_mapped_ipv6_public_addresses(string ip) + { + var address = IPAddress.Parse(ip); + + var result = SsrfHelper.IsPrivateOrReservedIp(address, null); + + Assert.False(result); + } + [Theory] [InlineData("8.8.8.8")] [InlineData("1.1.1.1")]