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")]