Browse Source

Security fixes. (#1327)

* Security fixes.

* Fix build.

* Fix build.
master
Sebastian Stehle 3 weeks ago
committed by GitHub
parent
commit
b5f55fd82c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs
  2. 4
      backend/extensions/Squidex.Extensions/LogMessages.cs
  3. 21
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/Extensions.cs
  4. 9
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Extensions.cs
  5. 18
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Extensions.cs
  6. 14
      backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj
  7. 16
      backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj
  8. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  9. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  10. 13
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs
  11. 3
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs
  12. 3
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
  13. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs
  14. 72
      backend/src/Squidex.Infrastructure/Http/SsrfExtensions.cs
  15. 66
      backend/src/Squidex.Infrastructure/Http/SsrfHelper.cs
  16. 37
      backend/src/Squidex.Infrastructure/Http/SsrfOptions.cs
  17. 56
      backend/src/Squidex.Infrastructure/Http/SsrfProtectionHandler.cs
  18. 14
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  19. 1
      backend/src/Squidex.Shared/PermissionIds.cs
  20. 2
      backend/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs
  21. 4
      backend/src/Squidex/Config/Authentication/IdentityServices.cs
  22. 2
      backend/src/Squidex/Config/Domain/AssetServices.cs
  23. 2
      backend/src/Squidex/Config/Domain/InfrastructureServices.cs
  24. 4
      backend/src/Squidex/Config/Domain/RuleServices.cs
  25. 24
      backend/src/Squidex/Squidex.csproj
  26. 1
      backend/src/Squidex/Startup.cs
  27. 18
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs
  28. 6
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
  29. 193
      backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs
  30. 131
      backend/tests/Squidex.Infrastructure.Tests/Http/SsrfProtectionHandlerTests.cs

2
backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs

@ -7,7 +7,7 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Http; using Squidex.Hosting.Ssrf;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Webhook; namespace Squidex.Extensions.Actions.Webhook;

4
backend/extensions/Squidex.Extensions/LogMessages.cs

@ -1,4 +1,4 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt) // Copyright (c) Squidex UG (haftungsbeschraenkt)
@ -12,7 +12,9 @@ namespace Squidex.Extensions;
internal static partial class LogMessages internal static partial class LogMessages
{ {
[LoggerMessage(Level = LogLevel.Warning, Message = "Kafka error with {code} and {reason}.")] [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); 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.")] [LoggerMessage(Level = LogLevel.Error, Message = "Failed to enrich asset.")]
public static partial void LogFailedToEnrichAsset(ILogger logger, Exception exception); public static partial void LogFailedToEnrichAsset(ILogger logger, Exception exception);

21
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) public static StringBuilder AppendJsonPath(this StringBuilder sb, PropertyPath path)
{ {
sb.Append('`'); 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.Append("`, ");
sb.AppendJsonPropertyPath(path); sb.AppendJsonPropertyPath(path);
return sb; return sb;
@ -36,7 +37,7 @@ internal static class Extensions
{ {
sb.Append('.'); sb.Append('.');
sb.Append('"'); sb.Append('"');
sb.Append(property); sb.Append(EscapeProperty(property));
sb.Append('"'); sb.Append('"');
} }
} }
@ -45,6 +46,22 @@ internal static class Extensions
return sb; 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) public static string JsonSubPath(this PropertyPath path)
{ {
return new StringBuilder().AppendJsonPropertyPath(path).ToString(); return new StringBuilder().AppendJsonPropertyPath(path).ToString();

9
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) public static StringBuilder AppendJsonPath(this StringBuilder sb, PropertyPath path, bool asString)
{ {
sb.Append('"'); 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('"'); sb.Append('"');
var i = 1; var i = 1;
@ -37,7 +38,11 @@ public static class Extensions
} }
else 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++; i++;

18
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) public static StringBuilder AppendJsonPath(this StringBuilder sb, PropertyPath path)
{ {
sb.Append('['); 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.Append("], ");
sb.AppendJsonSubPath(path); sb.AppendJsonSubPath(path);
return sb; return sb;
@ -36,7 +37,7 @@ internal static class Extensions
{ {
sb.Append('.'); sb.Append('.');
sb.Append('"'); sb.Append('"');
sb.Append(property); sb.Append(EscapeProperty(property));
sb.Append('"'); sb.Append('"');
} }
} }
@ -45,6 +46,19 @@ internal static class Extensions
return sb; 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) public static string JsonSubPath(this PropertyPath path)
{ {
return new StringBuilder().AppendJsonSubPath(path).ToString(); return new StringBuilder().AppendJsonSubPath(path).ToString();

14
backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj

@ -43,13 +43,13 @@
<PackageReference Include="Microting.EntityFrameworkCore.MySql.Json.Microsoft" Version="10.0.6" /> <PackageReference Include="Microting.EntityFrameworkCore.MySql.Json.Microsoft" Version="10.0.6" />
<PackageReference Include="Microting.EntityFrameworkCore.MySql.NetTopologySuite" Version="10.0.6" /> <PackageReference Include="Microting.EntityFrameworkCore.MySql.NetTopologySuite" Version="10.0.6" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="8.0.1" /> <PackageReference Include="Squidex.AI.EntityFramework" Version="8.0.3" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="8.0.1" /> <PackageReference Include="Squidex.Assets.EntityFramework" Version="8.0.3" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.1" /> <PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.3" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="8.0.1" /> <PackageReference Include="Squidex.Events.EntityFramework" Version="8.0.3" />
<PackageReference Include="Squidex.Flows.EntityFramework" Version="8.0.1" /> <PackageReference Include="Squidex.Flows.EntityFramework" Version="8.0.3" />
<PackageReference Include="Squidex.Hosting" Version="8.0.1" /> <PackageReference Include="Squidex.Hosting" Version="8.0.3" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="8.0.1" /> <PackageReference Include="Squidex.Messaging.EntityFramework" Version="8.0.3" />
<PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="7.2.1" /> <PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="7.2.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.6.2" /> <PackageReference Include="System.ValueTuple" Version="4.6.2" />

16
backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj

@ -20,17 +20,17 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MongoDB.Driver" Version="3.8.0" /> <PackageReference Include="MongoDB.Driver" Version="3.10.0" />
<PackageReference Include="MongoDB.Driver.Authentication.AWS" Version="3.8.0" /> <PackageReference Include="MongoDB.Driver.Authentication.AWS" Version="3.10.0" />
<PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="3.0.0" /> <PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="3.0.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.Mongo" Version="8.0.1" /> <PackageReference Include="Squidex.AI.Mongo" Version="8.0.3" />
<PackageReference Include="Squidex.Assets.Mongo" Version="8.0.1" /> <PackageReference Include="Squidex.Assets.Mongo" Version="8.0.3" />
<PackageReference Include="Squidex.Events.Mongo" Version="8.0.1" /> <PackageReference Include="Squidex.Events.Mongo" Version="8.0.3" />
<PackageReference Include="Squidex.Flows.Mongo" Version="8.0.1" /> <PackageReference Include="Squidex.Flows.Mongo" Version="8.0.3" />
<PackageReference Include="Squidex.Hosting" Version="8.0.1" /> <PackageReference Include="Squidex.Hosting" Version="8.0.3" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="8.0.1" /> <PackageReference Include="Squidex.Messaging.Mongo" Version="8.0.3" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="7.2.1" /> <PackageReference Include="Squidex.OpenIddict.MongoDb" Version="7.2.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.6.2" /> <PackageReference Include="System.ValueTuple" Version="4.6.2" />

2
backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -20,7 +20,7 @@
<PackageReference Include="NetTopologySuite" Version="2.6.0" /> <PackageReference Include="NetTopologySuite" Version="2.6.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Flows" Version="8.0.1" /> <PackageReference Include="Squidex.Flows" Version="8.0.3" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" /> <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
</ItemGroup> </ItemGroup>

4
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -29,8 +29,8 @@
<PackageReference Include="NJsonSchema" Version="11.6.1" /> <PackageReference Include="NJsonSchema" Version="11.6.1" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI" Version="8.0.1" /> <PackageReference Include="Squidex.AI" Version="8.0.3" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.1" /> <PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.3" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Linq.Async" Version="7.0.1" /> <PackageReference Include="System.Linq.Async" Version="7.0.1" />
<PackageReference Include="ValueTaskSupplement" Version="1.1.0" /> <PackageReference Include="ValueTaskSupplement" Version="1.1.0" />

13
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<TemplatesOptions> options) public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory, IOptions<TemplatesOptions> options)
{ {
private static readonly Regex RegexTemplate = BuildTemplateRegex();
private readonly TemplatesOptions options = options.Value; private readonly TemplatesOptions options = options.Value;
public async Task<string?> GetRepositoryUrl(string name, public async Task<string?> GetRepositoryUrl(string name,
@ -31,7 +30,7 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
var text = await httpClient.GetStringAsync(url, ct); var text = await httpClient.GetStringAsync(url, ct);
foreach (var match in RegexTemplate.Matches(text).OfType<Match>()) foreach (var match in TemplateRegex.Matches(text).OfType<Match>())
{ {
var currentName = match.Groups["Name"].Value; var currentName = match.Groups["Name"].Value;
@ -58,7 +57,7 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
var text = await httpClient.GetStringAsync(url, ct); var text = await httpClient.GetStringAsync(url, ct);
foreach (var match in RegexTemplate.Matches(text).OfType<Match>()) foreach (var match in TemplateRegex.Matches(text).OfType<Match>())
{ {
var templateName = match.Groups["Name"].Value; var templateName = match.Groups["Name"].Value;
var templateTitle = match.Groups["Title"].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); var text = await response.Content.ReadAsStringAsync(ct);
string? logo = null; string? logo = null;
text = BuildLogoRegex().Replace(text, match => text = LogoRegex.Replace(text, match =>
{ {
var imageRelative = new Uri(match.Groups["Url"].Value, UriKind.Relative); var imageRelative = new Uri(match.Groups["Url"].Value, UriKind.Relative);
var imageAbsolute = new Uri(url, imageRelative); var imageAbsolute = new Uri(url, imageRelative);
@ -161,7 +160,7 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
{ {
if (inline is LiteralInline literal) 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) if (inline is ContainerInline container)
@ -182,8 +181,8 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
} }
[GeneratedRegex("\\* \\[(?<Title>.*)\\]\\((?<Name>.*)\\/README\\.md\\): (?<Description>.*)", RegexOptions.ExplicitCapture | RegexOptions.Compiled)] [GeneratedRegex("\\* \\[(?<Title>.*)\\]\\((?<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)] [GeneratedRegex("Logo: \\[Logo\\]\\((?<Url>(.*))\\)", RegexOptions.ExplicitCapture | RegexOptions.Compiled)]
private static partial Regex BuildLogoRegex(); private static partial Regex LogoRegex { get; }
} }

3
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.Messaging.Subscriptions;
using Squidex.Shared; 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; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets;
internal static class AssetActions internal static class AssetActions

3
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.Messaging.Subscriptions;
using Squidex.Shared; 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; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents;
internal static class ContentActions internal static class ContentActions

2
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.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
#pragma warning disable MA0005 // Use Array.Empty<T>()
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents;
internal static class ContentFields internal static class ContentFields

72
backend/src/Squidex.Infrastructure/Http/SsrfExtensions.cs

@ -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);
}
}

66
backend/src/Squidex.Infrastructure/Http/SsrfHelper.cs

@ -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;
}
}

37
backend/src/Squidex.Infrastructure/Http/SsrfOptions.cs

@ -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("*");
}
}

56
backend/src/Squidex.Infrastructure/Http/SsrfProtectionHandler.cs

@ -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);
}
}

14
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -24,13 +24,13 @@
<PackageReference Include="NodaTime" Version="3.3.1" /> <PackageReference Include="NodaTime" Version="3.3.1" />
<PackageReference Include="OpenTelemetry.Api" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Api" Version="1.15.3" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="8.0.1" /> <PackageReference Include="Squidex.Assets" Version="8.0.3" />
<PackageReference Include="Squidex.Caching" Version="8.0.1" /> <PackageReference Include="Squidex.Caching" Version="8.0.3" />
<PackageReference Include="Squidex.Events" Version="8.0.1" /> <PackageReference Include="Squidex.Events" Version="8.0.3" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="8.0.1" /> <PackageReference Include="Squidex.Hosting.Abstractions" Version="8.0.3" />
<PackageReference Include="Squidex.Log" Version="8.0.1" /> <PackageReference Include="Squidex.Log" Version="8.0.3" />
<PackageReference Include="Squidex.Messaging" Version="8.0.1" /> <PackageReference Include="Squidex.Messaging" Version="8.0.3" />
<PackageReference Include="Squidex.Text" Version="8.0.1" /> <PackageReference Include="Squidex.Text" Version="8.0.3" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" /> <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="System.Linq.Async" Version="7.0.1" /> <PackageReference Include="System.Linq.Async" Version="7.0.1" />

1
backend/src/Squidex.Shared/PermissionIds.cs

@ -34,6 +34,7 @@ public static class PermissionIds
public const string AdminUsers = "squidex.admin.users"; public const string AdminUsers = "squidex.admin.users";
public const string AdminUsersRead = "squidex.admin.users.read"; public const string AdminUsersRead = "squidex.admin.users.read";
public const string AdminUsersCreate = "squidex.admin.users.create"; 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 AdminUsersUpdate = "squidex.admin.users.update";
public const string AdminUsersUnlock = "squidex.admin.users.unlock"; public const string AdminUsersUnlock = "squidex.admin.users.unlock";
public const string AdminUsersLock = "squidex.admin.users.lock"; public const string AdminUsersLock = "squidex.admin.users.lock";

2
backend/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs

@ -167,7 +167,7 @@ public sealed class UserManagementController(ICommandBus commandBus, IUserServic
[HttpDelete] [HttpDelete]
[Route("user-management/{id}/")] [Route("user-management/{id}/")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermission(PermissionIds.AdminUsersUnlock)] [ApiPermission(PermissionIds.AdminUsersDelete)]
public async Task<IActionResult> DeleteUser(string id) public async Task<IActionResult> DeleteUser(string id)
{ {
if (this.IsUser(id)) if (this.IsUser(id))

4
backend/src/Squidex/Config/Authentication/IdentityServices.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using Squidex.Domain.Users; using Squidex.Domain.Users;
using Squidex.Hosting.Ssrf;
using Squidex.Shared.Users; using Squidex.Shared.Users;
namespace Squidex.Config.Authentication; namespace Squidex.Config.Authentication;
@ -17,7 +18,8 @@ public static class IdentityServices
services.Configure<MyIdentityOptions>(config, services.Configure<MyIdentityOptions>(config,
"identity"); "identity");
services.AddHttpClient("Users"); services.AddHttpClient("Users")
.EnableSsrfProtection();
services.AddSingletonAs<DefaultUserResolver>() services.AddSingletonAs<DefaultUserResolver>()
.AsOptional<IUserResolver>(); .AsOptional<IUserResolver>();

2
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.Assets.Queries.Steps;
using Squidex.Domain.Apps.Entities.History; using Squidex.Domain.Apps.Entities.History;
using Squidex.Domain.Apps.Entities.Search; using Squidex.Domain.Apps.Entities.Search;
using Squidex.Hosting.Ssrf;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Http;
namespace Squidex.Config.Domain; namespace Squidex.Config.Domain;

2
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.Core.Templates.Extensions;
using Squidex.Domain.Apps.Entities.Contents.Counter; using Squidex.Domain.Apps.Entities.Contents.Counter;
using Squidex.Domain.Apps.Entities.Tags; using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Hosting.Ssrf;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Diagnostics; using Squidex.Infrastructure.Diagnostics;
using Squidex.Infrastructure.Http;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.UsageTracking; using Squidex.Infrastructure.UsageTracking;

4
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.Domain.Apps.Entities.Schemas;
using Squidex.Flows.Internal.Execution; using Squidex.Flows.Internal.Execution;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Http;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
namespace Squidex.Config.Domain; namespace Squidex.Config.Domain;
@ -34,9 +33,6 @@ public static class RuleServices
services.Configure<RulesOptions>(config, services.Configure<RulesOptions>(config,
"rules"); "rules");
services.Configure<SsrfOptions>(config,
"ssrf");
services.AddSingletonAs<EventEnricher>() services.AddSingletonAs<EventEnricher>()
.As<IEventEnricher>(); .As<IEventEnricher>();

24
backend/src/Squidex/Squidex.csproj

@ -57,16 +57,16 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.5.6" PrivateAssets="all" /> <PackageReference Include="ReportGenerator" Version="5.5.6" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="8.0.1" /> <PackageReference Include="Squidex.Assets.Azure" Version="8.0.3" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="8.0.1" /> <PackageReference Include="Squidex.Assets.GoogleCloud" Version="8.0.3" />
<PackageReference Include="Squidex.Assets.FTP" Version="8.0.1" /> <PackageReference Include="Squidex.Assets.FTP" Version="8.0.3" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="8.0.1" /> <PackageReference Include="Squidex.Assets.ImageSharp" Version="8.0.3" />
<PackageReference Include="Squidex.Assets.S3" Version="8.0.1" /> <PackageReference Include="Squidex.Assets.S3" Version="8.0.3" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.1" /> <PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.3" />
<PackageReference Include="Squidex.ClientLibrary" Version="22.2.0" /> <PackageReference Include="Squidex.ClientLibrary" Version="22.2.0" />
<PackageReference Include="Squidex.Hosting" Version="8.0.1" /> <PackageReference Include="Squidex.Hosting" Version="8.0.3" />
<PackageReference Include="Squidex.Messaging.All" Version="8.0.1" /> <PackageReference Include="Squidex.Messaging.All" Version="8.0.3" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.1" /> <PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.3" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="YDotNet" Version="0.6.0" /> <PackageReference Include="YDotNet" Version="0.6.0" />
<PackageReference Include="YDotNet.Native" Version="0.6.0" /> <PackageReference Include="YDotNet.Native" Version="0.6.0" />
@ -80,12 +80,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(IncludeMagick)' == 'true'"> <ItemGroup Condition="'$(IncludeMagick)' == 'true'">
<PackageReference Include="Squidex.Assets.ImageMagick" Version="8.0.1" /> <PackageReference Include="Squidex.Assets.ImageMagick" Version="8.0.3" />
<PackageReference Include="Magick.NET-Q8-AnyCPU" Version="14.12.0" /> <PackageReference Include="Magick.NET-Q8-AnyCPU" Version="14.16.0" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(IncludeKafka)' == 'true'"> <ItemGroup Condition="'$(IncludeKafka)' == 'true'">
<PackageReference Include="Squidex.Messaging.Kafka" Version="8.0.1" /> <PackageReference Include="Squidex.Messaging.Kafka" Version="8.0.3" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

1
backend/src/Squidex/Startup.cs

@ -27,6 +27,7 @@ public sealed class Startup(IConfiguration config)
services.AddHealthChecks(); services.AddHealthChecks();
services.AddDefaultWebServices(config); services.AddDefaultWebServices(config);
services.AddDefaultForwardRules(); services.AddDefaultForwardRules();
services.AddSsrfProtectedHttpClient(config);
// They must be called in this order. // They must be called in this order.
services.AddSquidexMvcWithPlugins(config); services.AddSquidexMvcWithPlugins(config);

18
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()); 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] [Fact]
public async Task Should_filter_by_string_contains_in_json() public async Task Should_filter_by_string_contains_in_json()
{ {

6
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs

@ -99,7 +99,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
invalid(() invalid(()
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(new ScriptVars(), script)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script));
} }
[Fact] [Fact]
@ -109,7 +109,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
throw 'Error'; throw 'Error';
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(new ScriptVars(), script)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script));
} }
[Fact] [Fact]
@ -179,7 +179,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
throw 'Error'; throw 'Error';
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.TransformAsync(new DataScriptVars(), script)); await Assert.ThrowsAsync<ValidationException>(() => sut.TransformAsync([], script));
} }
[Fact] [Fact]

193
backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs

@ -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);
}
}

131
backend/tests/Squidex.Infrastructure.Tests/Http/SsrfProtectionHandlerTests.cs

@ -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));
}
}
Loading…
Cancel
Save