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("\\* \\[(?.*)\\]\\((?<Name>.*)\\/README\\.md\\): (?<Description>.*)", RegexOptions.ExplicitCapture | RegexOptions.Compiled)] - private static partial Regex BuildTemplateRegex(); + private static partial Regex TemplateRegex { get; } [GeneratedRegex("Logo: \\[Logo\\]\\((?<Url>(.*))\\)", 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<T>() +#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<T>() +#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<T>() + 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<SsrfProtectionHandler>(); - - builder.AddHttpMessageHandler<SsrfProtectionHandler>(); - builder.ConfigurePrimaryHttpMessageHandler(services => - { - var options = services.GetService<IOptions<SsrfOptions>>()?.Value ?? new (); - - return new SocketsHttpHandler - { - ConnectCallback = options.EnableDnsRebindingProtection - ? CreateSecureConnectCallback(options) - : null, - AllowAutoRedirect = options.AllowAutoRedirect, - }; - }); - - return builder; - } - - private static Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>> 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<NetworkStream> 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<IPAddress>? 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<string> WhitelistedHosts { get; set; } = - new HashSet<string>( - [], - StringComparer.OrdinalIgnoreCase); - - public HashSet<string> AllowedSchemes { get; set; } = - new HashSet<string>( - ["http", "https"], - StringComparer.OrdinalIgnoreCase); - - public HashSet<IPAddress> BlockedIpAddresses { get; set; } = - new HashSet<IPAddress>( - [IPAddress.Parse("169.254.169.254")], - EqualityComparer<IPAddress>.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<SsrfOptions> options) : DelegatingHandler -{ - protected override async Task<HttpResponseMessage> 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 @@ <PackageReference Include="NodaTime" Version="3.3.1" /> <PackageReference Include="OpenTelemetry.Api" Version="1.15.3" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> - <PackageReference Include="Squidex.Assets" Version="8.0.1" /> - <PackageReference Include="Squidex.Caching" Version="8.0.1" /> - <PackageReference Include="Squidex.Events" Version="8.0.1" /> - <PackageReference Include="Squidex.Hosting.Abstractions" Version="8.0.1" /> - <PackageReference Include="Squidex.Log" Version="8.0.1" /> - <PackageReference Include="Squidex.Messaging" Version="8.0.1" /> - <PackageReference Include="Squidex.Text" Version="8.0.1" /> + <PackageReference Include="Squidex.Assets" Version="8.0.3" /> + <PackageReference Include="Squidex.Caching" Version="8.0.3" /> + <PackageReference Include="Squidex.Events" Version="8.0.3" /> + <PackageReference Include="Squidex.Hosting.Abstractions" Version="8.0.3" /> + <PackageReference Include="Squidex.Log" Version="8.0.3" /> + <PackageReference Include="Squidex.Messaging" Version="8.0.3" /> + <PackageReference Include="Squidex.Text" Version="8.0.3" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" /> <PackageReference Include="System.Linq.Async" Version="7.0.1" /> 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<IActionResult> 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<MyIdentityOptions>(config, "identity"); - services.AddHttpClient("Users"); + services.AddHttpClient("Users") + .EnableSsrfProtection(); services.AddSingletonAs<DefaultUserResolver>() .AsOptional<IUserResolver>(); 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<RulesOptions>(config, "rules"); - services.Configure<SsrfOptions>(config, - "ssrf"); - services.AddSingletonAs<EventEnricher>() .As<IEventEnricher>(); 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 @@ <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="ReportGenerator" Version="5.5.6" PrivateAssets="all" /> - <PackageReference Include="Squidex.Assets.Azure" Version="8.0.1" /> - <PackageReference Include="Squidex.Assets.GoogleCloud" Version="8.0.1" /> - <PackageReference Include="Squidex.Assets.FTP" Version="8.0.1" /> - <PackageReference Include="Squidex.Assets.ImageSharp" Version="8.0.1" /> - <PackageReference Include="Squidex.Assets.S3" Version="8.0.1" /> - <PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.1" /> + <PackageReference Include="Squidex.Assets.Azure" Version="8.0.3" /> + <PackageReference Include="Squidex.Assets.GoogleCloud" Version="8.0.3" /> + <PackageReference Include="Squidex.Assets.FTP" Version="8.0.3" /> + <PackageReference Include="Squidex.Assets.ImageSharp" Version="8.0.3" /> + <PackageReference Include="Squidex.Assets.S3" Version="8.0.3" /> + <PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.3" /> <PackageReference Include="Squidex.ClientLibrary" Version="22.2.0" /> - <PackageReference Include="Squidex.Hosting" Version="8.0.1" /> - <PackageReference Include="Squidex.Messaging.All" Version="8.0.1" /> - <PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.1" /> + <PackageReference Include="Squidex.Hosting" Version="8.0.3" /> + <PackageReference Include="Squidex.Messaging.All" Version="8.0.3" /> + <PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.3" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="YDotNet" Version="0.6.0" /> <PackageReference Include="YDotNet.Native" Version="0.6.0" /> @@ -80,12 +80,12 @@ </ItemGroup> <ItemGroup Condition="'$(IncludeMagick)' == 'true'"> - <PackageReference Include="Squidex.Assets.ImageMagick" Version="8.0.1" /> - <PackageReference Include="Magick.NET-Q8-AnyCPU" Version="14.12.0" /> + <PackageReference Include="Squidex.Assets.ImageMagick" Version="8.0.3" /> + <PackageReference Include="Magick.NET-Q8-AnyCPU" Version="14.16.0" /> </ItemGroup> <ItemGroup Condition="'$(IncludeKafka)' == 'true'"> - <PackageReference Include="Squidex.Messaging.Kafka" Version="8.0.1" /> + <PackageReference Include="Squidex.Messaging.Kafka" Version="8.0.3" /> </ItemGroup> <PropertyGroup> 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<TContext>(ISqlFixture<TContext> 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<TranslationsFixture> invalid(() "; - await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(new ScriptVars(), script)); + await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script)); } [Fact] @@ -109,7 +109,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture> throw 'Error'; "; - await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(new ScriptVars(), script)); + await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script)); } [Fact] @@ -179,7 +179,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture> throw 'Error'; "; - await Assert.ThrowsAsync<ValidationException>(() => sut.TransformAsync(new DataScriptVars(), script)); + await Assert.ThrowsAsync<ValidationException>(() => 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> { 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> { 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<IPAddress>(); - - 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<SsrfOptions> options) : SsrfProtectionHandler(options) - { - public new async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, - CancellationToken cancellationToken) - { - return await base.SendAsync(request, cancellationToken); - } - } - - private sealed class TestHttpMessageHandler : HttpMessageHandler - { - protected override Task<HttpResponseMessage> 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<HttpRequestException>(() => - 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<HttpRequestException>(() => - 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<HttpRequestException>(() => - 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<HttpRequestException>(() => - 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<HttpRequestException>(() => - sut.SendAsync(request, CancellationToken.None)); - } -}