diff --git a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs
index d70d68792..aac551546 100644
--- a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs
+++ b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs
@@ -7,7 +7,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
-using Squidex.Infrastructure.Http;
+using Squidex.Hosting.Ssrf;
using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Webhook;
diff --git a/backend/extensions/Squidex.Extensions/LogMessages.cs b/backend/extensions/Squidex.Extensions/LogMessages.cs
index 8d43ba40e..ff0171037 100644
--- a/backend/extensions/Squidex.Extensions/LogMessages.cs
+++ b/backend/extensions/Squidex.Extensions/LogMessages.cs
@@ -1,4 +1,4 @@
-// ==========================================================================
+// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
@@ -12,7 +12,9 @@ namespace Squidex.Extensions;
internal static partial class LogMessages
{
[LoggerMessage(Level = LogLevel.Warning, Message = "Kafka error with {code} and {reason}.")]
+#pragma warning disable LOGGEN036 // A value being logged doesn't have an effective way to be converted into a string
public static partial void LogKafkaError(ILogger logger, object code, string reason);
+#pragma warning restore LOGGEN036 // A value being logged doesn't have an effective way to be converted into a string
[LoggerMessage(Level = LogLevel.Error, Message = "Failed to enrich asset.")]
public static partial void LogFailedToEnrichAsset(ILogger logger, Exception exception);
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.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs
index 33e4be47c..a472ef80d 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs
@@ -17,7 +17,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates;
public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory, IOptions options)
{
- private static readonly Regex RegexTemplate = BuildTemplateRegex();
private readonly TemplatesOptions options = options.Value;
public async Task GetRepositoryUrl(string name,
@@ -31,7 +30,7 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
var text = await httpClient.GetStringAsync(url, ct);
- foreach (var match in RegexTemplate.Matches(text).OfType())
+ foreach (var match in TemplateRegex.Matches(text).OfType())
{
var currentName = match.Groups["Name"].Value;
@@ -58,7 +57,7 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
var text = await httpClient.GetStringAsync(url, ct);
- foreach (var match in RegexTemplate.Matches(text).OfType())
+ foreach (var match in TemplateRegex.Matches(text).OfType())
{
var templateName = match.Groups["Name"].Value;
var templateTitle = match.Groups["Title"].Value;
@@ -109,7 +108,7 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
var text = await response.Content.ReadAsStringAsync(ct);
string? logo = null;
- text = BuildLogoRegex().Replace(text, match =>
+ text = LogoRegex.Replace(text, match =>
{
var imageRelative = new Uri(match.Groups["Url"].Value, UriKind.Relative);
var imageAbsolute = new Uri(url, imageRelative);
@@ -161,7 +160,7 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
{
if (inline is LiteralInline literal)
{
- return literal.Content.AsSpan().Trim().Equals("Usage", StringComparison.Ordinal);
+ return literal.Content.AsSpan().Trim() is "Usage";
}
if (inline is ContainerInline container)
@@ -182,8 +181,8 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
}
[GeneratedRegex("\\* \\[(?.*)\\]\\((?.*)\\/README\\.md\\): (?.*)", RegexOptions.ExplicitCapture | RegexOptions.Compiled)]
- private static partial Regex BuildTemplateRegex();
+ private static partial Regex TemplateRegex { get; }
[GeneratedRegex("Logo: \\[Logo\\]\\((?(.*))\\)", RegexOptions.ExplicitCapture | RegexOptions.Compiled)]
- private static partial Regex BuildLogoRegex();
+ private static partial Regex LogoRegex { get; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs
index 351e761b3..2bde89881 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs
@@ -18,6 +18,9 @@ using Squidex.Infrastructure.Translations;
using Squidex.Messaging.Subscriptions;
using Squidex.Shared;
+#pragma warning disable MA0005 // Use Array.Empty()
+#pragma warning disable CA1825 // Avoid zero-length array allocations
+
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets;
internal static class AssetActions
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
index f20bc699d..597d8537e 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
@@ -20,6 +20,9 @@ using Squidex.Infrastructure.Translations;
using Squidex.Messaging.Subscriptions;
using Squidex.Shared;
+#pragma warning disable MA0005 // Use Array.Empty()
+#pragma warning disable CA1825 // Avoid zero-length array allocations
+
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents;
internal static class ContentActions
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs
index 56b29e239..9e787b4b7 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs
@@ -14,6 +14,8 @@ using Squidex.Domain.Apps.Core.ExtractReferenceIds;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives;
using Squidex.Infrastructure.Json.Objects;
+#pragma warning disable MA0005 // Use Array.Empty()
+
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents;
internal static class ContentFields
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..090c90128 100644
--- a/backend/src/Squidex/Config/Domain/AssetServices.cs
+++ b/backend/src/Squidex/Config/Domain/AssetServices.cs
@@ -11,8 +11,8 @@ 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;
namespace Squidex.Config.Domain;
diff --git a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs
index 90b0a4c0d..c256f7fe2 100644
--- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs
+++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs
@@ -19,9 +19,9 @@ 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;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.UsageTracking;
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.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
index 80395b4e5..fcc924cd2 100644
--- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
+++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
@@ -99,7 +99,7 @@ public class JintScriptEngineTests : IClassFixture
invalid(()
";
- await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script));
+ await Assert.ThrowsAsync(() => sut.ExecuteAsync([], script));
}
[Fact]
@@ -109,7 +109,7 @@ public class JintScriptEngineTests : IClassFixture
throw 'Error';
";
- await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script));
+ await Assert.ThrowsAsync(() => sut.ExecuteAsync([], script));
}
[Fact]
@@ -179,7 +179,7 @@ public class JintScriptEngineTests : IClassFixture
throw 'Error';
";
- await Assert.ThrowsAsync(() => sut.TransformAsync(new DataScriptVars(), script));
+ await Assert.ThrowsAsync(() => sut.TransformAsync([], script));
}
[Fact]
diff --git a/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs
deleted file mode 100644
index 44de7eb65..000000000
--- a/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs
+++ /dev/null
@@ -1,193 +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 class SsrfHelperTests
-{
- [Theory]
- [InlineData("127.0.0.1")]
- [InlineData("::1")]
- public void Should_block_loopback_addresses(string ip)
- {
- var address = IPAddress.Parse(ip);
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.True(result);
- }
-
- [Theory]
- [InlineData("10.0.0.1")]
- [InlineData("10.255.255.255")]
- [InlineData("172.16.0.1")]
- [InlineData("172.31.255.255")]
- [InlineData("192.168.0.1")]
- [InlineData("192.168.255.255")]
- public void Should_block_private_ipv4_ranges(string ip)
- {
- var address = IPAddress.Parse(ip);
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.True(result);
- }
-
- [Theory]
- [InlineData("169.254.0.1")]
- [InlineData("169.254.169.254")]
- public void Should_block_link_local_addresses(string ip)
- {
- var address = IPAddress.Parse(ip);
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.True(result);
- }
-
- [Theory]
- [InlineData("0.0.0.0")]
- [InlineData("0.255.255.255")]
- public void Should_block_current_network_addresses(string ip)
- {
- var address = IPAddress.Parse(ip);
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.True(result);
- }
-
- [Theory]
- [InlineData("224.0.0.1")]
- [InlineData("239.255.255.255")]
- public void Should_block_multicast_addresses(string ip)
- {
- var address = IPAddress.Parse(ip);
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.True(result);
- }
-
- [Theory]
- [InlineData("240.0.0.1")]
- [InlineData("255.255.255.255")]
- public void Should_block_reserved_addresses(string ip)
- {
- var address = IPAddress.Parse(ip);
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.True(result);
- }
-
- [Theory]
- [InlineData("fe80::1")]
- [InlineData("fec0::1")]
- public void Should_block_ipv6_link_local_and_site_local(string ip)
- {
- var address = IPAddress.Parse(ip);
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.True(result);
- }
-
- [Theory]
- [InlineData("fc00::1")]
- [InlineData("fd00::1")]
- public void Should_block_ipv6_unique_local_addresses(string ip)
- {
- var address = IPAddress.Parse(ip);
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.True(result);
- }
-
- [Theory]
- [InlineData("ff00::1")]
- [InlineData("ff02::1")]
- public void Should_block_ipv6_multicast_addresses(string ip)
- {
- var address = IPAddress.Parse(ip);
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.True(result);
- }
-
- [Theory]
- [InlineData("8.8.8.8")]
- [InlineData("1.1.1.1")]
- [InlineData("203.0.113.1")]
- public void Should_allow_public_ipv4_addresses(string ip)
- {
- var address = IPAddress.Parse(ip);
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.False(result);
- }
-
- [Theory]
- [InlineData("2001:4860:4860::8888")]
- [InlineData("2606:4700:4700::1111")]
- public void Should_allow_public_ipv6_addresses(string ip)
- {
- var address = IPAddress.Parse(ip);
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.False(result);
- }
-
- [Fact]
- public void Should_block_custom_blacklisted_ip()
- {
- var address = IPAddress.Parse("1.2.3.4");
- var blacklist = new HashSet { IPAddress.Parse("1.2.3.4") };
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, blacklist);
-
- Assert.True(result);
- }
-
- [Fact]
- public void Should_allow_ip_not_in_blacklist()
- {
- var address = IPAddress.Parse("8.8.8.8");
- var blacklist = new HashSet { IPAddress.Parse("1.2.3.4") };
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, blacklist);
-
- Assert.False(result);
- }
-
- [Fact]
- public void Should_handle_null_blacklist()
- {
- var address = IPAddress.Parse("8.8.8.8");
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, null);
-
- Assert.False(result);
- }
-
- [Fact]
- public void Should_handle_empty_blacklist()
- {
- var address = IPAddress.Parse("8.8.8.8");
- var blacklist = new HashSet();
-
- var result = SsrfHelper.IsPrivateOrReservedIp(address, blacklist);
-
- Assert.False(result);
- }
-}
diff --git a/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfProtectionHandlerTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfProtectionHandlerTests.cs
deleted file mode 100644
index 85cea16d0..000000000
--- a/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfProtectionHandlerTests.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-// ==========================================================================
-// Squidex Headless CMS
-// ==========================================================================
-// Copyright (c) Squidex UG (haftungsbeschraenkt)
-// All rights reserved. Licensed under the MIT license.
-// ==========================================================================
-
-using System.Net;
-using Microsoft.Extensions.Options;
-
-namespace Squidex.Infrastructure.Http;
-
-public class SsrfProtectionHandlerTests
-{
- private readonly SsrfCustomHandler sut;
- private readonly SsrfOptions options = new ();
-
- private sealed class SsrfCustomHandler(IOptions options) : SsrfProtectionHandler(options)
- {
- public new async Task SendAsync(HttpRequestMessage request,
- CancellationToken cancellationToken)
- {
- return await base.SendAsync(request, cancellationToken);
- }
- }
-
- private sealed class TestHttpMessageHandler : HttpMessageHandler
- {
- protected override Task SendAsync(
- HttpRequestMessage request,
- CancellationToken cancellationToken)
- {
- return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
- }
- }
-
- public SsrfProtectionHandlerTests()
- {
- sut = new SsrfCustomHandler(Options.Create(options))
- {
- InnerHandler = new TestHttpMessageHandler(),
- };
- }
-
- [Theory]
- [InlineData("http://example.com")]
- [InlineData("https://example.com")]
- public async Task Should_allow_http_and_https_schemes(string url)
- {
- var request = new HttpRequestMessage(HttpMethod.Get, url);
-
- await sut.SendAsync(request, CancellationToken.None);
- }
-
- [Theory]
- [InlineData("ftp://example.com")]
- [InlineData("file:///etc/passwd")]
- public async Task Should_block_non_http_schemes(string url)
- {
- var request = new HttpRequestMessage(HttpMethod.Get, url);
-
- await Assert.ThrowsAsync(() =>
- sut.SendAsync(request, CancellationToken.None));
- }
-
- [Fact]
- public async Task Should_throw_exception_if_request_uri_is_null()
- {
- var request = new HttpRequestMessage(HttpMethod.Get, (Uri?)null);
-
- await Assert.ThrowsAsync(() =>
- sut.SendAsync(request, CancellationToken.None));
- }
-
- [Fact]
- public async Task Should_block_request_to_localhost()
- {
- var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost");
-
- await Assert.ThrowsAsync(() =>
- sut.SendAsync(request, CancellationToken.None));
- }
-
- [Fact]
- public async Task Should_block_request_to_loopback_ip()
- {
- var request = new HttpRequestMessage(HttpMethod.Get, "http://127.0.0.1");
-
- await Assert.ThrowsAsync(() =>
- sut.SendAsync(request, CancellationToken.None));
- }
-
- [Fact]
- public async Task Should_not_block_request_to_localhost_if_whitelisted()
- {
- options.WhitelistedHosts.Add("localhost");
-
- var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost");
-
- await sut.SendAsync(request, CancellationToken.None);
- }
-
- [Fact]
- public async Task Should_not_block_request_to_localhost_if_all_hosts_are_whitelisted()
- {
- options.WhitelistedHosts.Add("*");
-
- var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost");
-
- await sut.SendAsync(request, CancellationToken.None);
- }
-
- [Fact]
- public async Task Should_allow_custom_scheme_when_configured()
- {
- options.AllowedSchemes.Add("custom");
-
- var request = new HttpRequestMessage(HttpMethod.Get, "custom://example.com");
-
- await sut.SendAsync(request, CancellationToken.None);
- }
-
- [Fact]
- public async Task Should_throw_exception_on_dns_resolution_failure()
- {
- var request = new HttpRequestMessage(HttpMethod.Get, "http://invalid.domain.that.does.not.exist.local");
-
- await Assert.ThrowsAsync(() =>
- sut.SendAsync(request, CancellationToken.None));
- }
-}