From 1f15e493ba47cc78b31454223ce12ba4f6a5dc89 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 1 Feb 2026 22:20:34 +0100 Subject: [PATCH] Ssrf protection (#1279) * Menu * Move to components. * Show a marker for unsaved changes. * User info field. * User info implementation. * Fix tests * Update tests * Update auth. * Fix DB provider. * User info * Instructions. * Add SSRF protection. * Disable ssrf * Fix ssrf settings. * Fix index. * Fix conflicts * Fix tests * Fix derivate check. --- .../Actions/Webhook/WebhookPlugin.cs | 4 + .../Entities/Contents/Text/AtlasTextIndex.cs | 4 +- .../Contents/Text/DocumentDbTextIndex.cs | 3 +- .../Entities/Contents/Text/MongoTextIndex.cs | 5 +- .../Contents/Text/MongoTextIndexBase.cs | 14 +- .../Infrastructure/MongoExtensions.cs | 15 -- .../Squidex.Data.MongoDb/ServiceExtensions.cs | 4 +- .../Http/SsrfExtensions.cs | 72 +++++++ .../Squidex.Infrastructure/Http/SsrfHelper.cs | 66 ++++++ .../Http/SsrfOptions.cs | 37 ++++ .../Http/SsrfProtectionHandler.cs | 56 +++++ .../src/Squidex/Config/Domain/RuleServices.cs | 4 + backend/src/Squidex/appsettings.json | 18 ++ .../Contents/Text/MongoTextIndexTests.cs | 3 +- .../Http/SsrfHelperTests.cs | 193 ++++++++++++++++++ .../Http/SsrfProtectionHandlerTests.cs | 131 ++++++++++++ .../TestSuite.ApiTests/ContentQueryTests.cs | 6 + .../TestSuite.ApiTests/ContentUserTests.cs | 1 + tools/TestSuite/docker-compose-base.yml | 1 + 19 files changed, 610 insertions(+), 27 deletions(-) create mode 100644 backend/src/Squidex.Infrastructure/Http/SsrfExtensions.cs create mode 100644 backend/src/Squidex.Infrastructure/Http/SsrfHelper.cs create mode 100644 backend/src/Squidex.Infrastructure/Http/SsrfOptions.cs create mode 100644 backend/src/Squidex.Infrastructure/Http/SsrfProtectionHandler.cs create mode 100644 backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs create mode 100644 backend/tests/Squidex.Infrastructure.Tests/Http/SsrfProtectionHandlerTests.cs diff --git a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs index cb0bdc208..d70d68792 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Squidex.Infrastructure.Http; using Squidex.Infrastructure.Plugins; namespace Squidex.Extensions.Actions.Webhook; @@ -15,6 +16,9 @@ public sealed class WebhookPlugin : IPlugin { public void ConfigureServices(IServiceCollection services, IConfiguration config) { + services.AddHttpClient("FlowClient") + .EnableSsrfProtection(); + services.AddFlowStep(); #pragma warning disable CS0618 // Type or member is obsolete services.AddRuleAction(); diff --git a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/AtlasTextIndex.cs b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/AtlasTextIndex.cs index a3345db63..36344e377 100644 --- a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/AtlasTextIndex.cs +++ b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/AtlasTextIndex.cs @@ -12,13 +12,15 @@ using Microsoft.Extensions.Options; using MongoDB.Bson; using MongoDB.Driver; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Events.Mongo; using Squidex.Infrastructure; using LuceneQueryAnalyzer = Lucene.Net.QueryParsers.Classic.QueryParser; namespace Squidex.Domain.Apps.Entities.Contents.Text; public sealed class AtlasTextIndex(IMongoDatabase database, IHttpClientFactory atlasClient, IOptions atlasOptions, string shardKey) - : MongoTextIndexBase>(database, shardKey, new CommandFactory>(BuildTexts)) + : MongoTextIndexBase>(database, shardKey, MongoDerivate.MongoDB, + new CommandFactory>(BuildTexts)) { private static readonly LuceneQueryVisitor QueryVisitor = new LuceneQueryVisitor(AtlasIndexDefinition.GetFieldPath); private static readonly LuceneQueryAnalyzer QueryParser = diff --git a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/DocumentDbTextIndex.cs b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/DocumentDbTextIndex.cs index 659419c4f..3f56e2c52 100644 --- a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/DocumentDbTextIndex.cs +++ b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/DocumentDbTextIndex.cs @@ -8,13 +8,14 @@ using MongoDB.Driver; using MongoDB.Driver.GeoJsonObjectModel; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Events.Mongo; using Squidex.Infrastructure; using Squidex.Infrastructure.ObjectPool; namespace Squidex.Domain.Apps.Entities.Contents.Text; public sealed class DocumentDbTextIndex(IMongoDatabase database, string shardKey) - : MongoTextIndexBase(database, shardKey, + : MongoTextIndexBase(database, shardKey, MongoDerivate.DocumentDB, new CommandFactory(BuildTexts)) { private record struct SearchOperation diff --git a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndex.cs b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndex.cs index 20cb93460..4314baec0 100644 --- a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndex.cs +++ b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndex.cs @@ -7,12 +7,13 @@ using MongoDB.Driver; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Events.Mongo; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.Text; -public sealed class MongoTextIndex(IMongoDatabase database, string shardKey) - : MongoTextIndexBase>(database, shardKey, +public sealed class MongoTextIndex(IMongoDatabase database, string shardKey, MongoDerivate derivate) + : MongoTextIndexBase>(database, shardKey, derivate, new CommandFactory>(BuildTexts)) { private record struct SearchOperation diff --git a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndexBase.cs b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndexBase.cs index 165b568a7..237d20752 100644 --- a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndexBase.cs +++ b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndexBase.cs @@ -10,11 +10,12 @@ using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Events.Mongo; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.Text; -public abstract class MongoTextIndexBase(IMongoDatabase database, string shardKey, CommandFactory factory) +public abstract class MongoTextIndexBase(IMongoDatabase database, string shardKey, MongoDerivate derivate, CommandFactory factory) : MongoRepositoryBase>(database), ITextIndex, IDeleter where T : class { protected sealed class MongoTextResult @@ -54,9 +55,7 @@ public abstract class MongoTextIndexBase(IMongoDatabase database, string shar protected override async Task SetupCollectionAsync(IMongoCollection> collection, CancellationToken ct) { - var isFerreDb = await Database.IsFerretDbAsync(ct); - - if (isFerreDb) + if (derivate == MongoDerivate.FerretDB) { await collection.Indexes.CreateManyAsync( [ @@ -77,7 +76,7 @@ public abstract class MongoTextIndexBase(IMongoDatabase database, string shar new CreateIndexModel>( Index - .Geo2DSphere(x => x.UserInfoApiKey), + .Ascending(x => x.UserInfoApiKey), new CreateIndexOptions { Sparse = true }), ], ct); } @@ -96,6 +95,11 @@ public abstract class MongoTextIndexBase(IMongoDatabase database, string shar .Ascending(x => x.SchemaId) .Ascending(x => x.GeoField) .Geo2DSphere(x => x.GeoObject)), + + new CreateIndexModel>( + Index + .Ascending(x => x.UserInfoApiKey), + new CreateIndexOptions { Sparse = true }), ], ct); } } diff --git a/backend/src/Squidex.Data.MongoDb/Infrastructure/MongoExtensions.cs b/backend/src/Squidex.Data.MongoDb/Infrastructure/MongoExtensions.cs index 8627e6a1a..7d929ddd8 100644 --- a/backend/src/Squidex.Data.MongoDb/Infrastructure/MongoExtensions.cs +++ b/backend/src/Squidex.Data.MongoDb/Infrastructure/MongoExtensions.cs @@ -221,21 +221,6 @@ public static class MongoExtensions return result; } - public static async Task IsFerretDbAsync(this IMongoDatabase database, - CancellationToken ct = default) - { - var command = - new BsonDocumentCommand(new BsonDocument - { - { "buildInfo", 1 }, - }); - - var document = await database.RunCommandAsync(command, cancellationToken: ct); - - var isFerretDB = document.Any(x => x.Name.Contains("ferret", StringComparison.OrdinalIgnoreCase)); - return isFerretDB; - } - public static async Task> ToListRandomAsync(this IFindFluent find, IMongoCollection collection, long take, CancellationToken ct = default) { diff --git a/backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs b/backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs index ed2147ddc..e41e89e89 100644 --- a/backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs +++ b/backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs @@ -227,7 +227,7 @@ public static class ServiceExtensions shardKey => ActivatorUtilities.CreateInstance(c, shardKey)); }).AsOptional().As(); } - else if (config.GetValue("store:mongoDb:derivate") == MongoDerivate.DocumentDB) + else if (derivate == MongoDerivate.DocumentDB) { services.AddSingletonAs(c => { @@ -240,7 +240,7 @@ public static class ServiceExtensions services.AddSingletonAs(c => { return new MongoShardedTextIndex>(GetSharding(config, "store:mongoDB:textShardCount"), - shardKey => ActivatorUtilities.CreateInstance(c, shardKey)); + shardKey => ActivatorUtilities.CreateInstance(c, shardKey, derivate)); }).AsOptional().As(); } diff --git a/backend/src/Squidex.Infrastructure/Http/SsrfExtensions.cs b/backend/src/Squidex.Infrastructure/Http/SsrfExtensions.cs new file mode 100644 index 000000000..7995dc527 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Http/SsrfExtensions.cs @@ -0,0 +1,72 @@ +// ========================================================================== +// 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.AddSingleton(); + + 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 new file mode 100644 index 000000000..653d50a1f --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Http/SsrfHelper.cs @@ -0,0 +1,66 @@ +// ========================================================================== +// 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 new file mode 100644 index 000000000..3dd223e5d --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Http/SsrfOptions.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// 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 new file mode 100644 index 000000000..9296bee13 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Http/SsrfProtectionHandler.cs @@ -0,0 +1,56 @@ +// ========================================================================== +// 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/Config/Domain/RuleServices.cs b/backend/src/Squidex/Config/Domain/RuleServices.cs index d18de4a9f..3c339448c 100644 --- a/backend/src/Squidex/Config/Domain/RuleServices.cs +++ b/backend/src/Squidex/Config/Domain/RuleServices.cs @@ -23,6 +23,7 @@ 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,6 +35,9 @@ public static class RuleServices services.Configure(config, "rules"); + services.Configure(config, + "ssrf"); + services.AddSingletonAs() .As(); diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index 5287c5752..a089b8f37 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -83,6 +83,24 @@ "Squidex.Extensions.dll" ], + // SSRF Options for outgoing http requests. + "ssrf": { + // Enable DNS rebinding protection (validates DNS twice, recommended for security). + "enableDnsRebindingProtection": true, + + // Allowed URI schemes for outbound requests. + "allowedSchemes": [ "http", "https" ], + + // Additional IP addresses to block (e.g., cloud metadata endpoints). + "blockedIpAddresses": [ "169.254.169.254" ], + + // Whitelisted (local) hosts. + "whiteListedHosts": [], + + // Allow HTTP redirects (disable to prevent redirect-based SSRF attacks). + "allowAutoRedirect": false + }, + "caching": { // Set to true, to use strong etags. "strongETag": false, diff --git a/backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/MongoTextIndexTests.cs b/backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/MongoTextIndexTests.cs index 9084fb7b2..5ad2eb884 100644 --- a/backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/MongoTextIndexTests.cs +++ b/backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/MongoTextIndexTests.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Domain.Apps.Entities.Contents.Text; +using Squidex.Events.Mongo; using Squidex.MongoDb.TestHelpers; namespace Squidex.MongoDb.Domain.Contents.Text; @@ -20,7 +21,7 @@ public class MongoTextIndexTests(MongoFixture fixture) : TextIndexerTests public override async Task CreateSutAsync() { - var sut = new MongoTextIndex(fixture.Database, string.Empty); + var sut = new MongoTextIndex(fixture.Database, string.Empty, MongoDerivate.MongoDB); await sut.InitializeAsync(default); return sut; diff --git a/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs new file mode 100644 index 000000000..44de7eb65 --- /dev/null +++ b/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs @@ -0,0 +1,193 @@ +// ========================================================================== +// 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 new file mode 100644 index 000000000..85cea16d0 --- /dev/null +++ b/backend/tests/Squidex.Infrastructure.Tests/Http/SsrfProtectionHandlerTests.cs @@ -0,0 +1,131 @@ +// ========================================================================== +// 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)); + } +} diff --git a/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs b/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs index 3cd59381c..b4bab2d47 100644 --- a/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs @@ -405,6 +405,7 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture