Browse Source

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.
pull/1283/head
Sebastian Stehle 4 months ago
committed by GitHub
parent
commit
1f15e493ba
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs
  2. 4
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/AtlasTextIndex.cs
  3. 3
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/DocumentDbTextIndex.cs
  4. 5
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndex.cs
  5. 14
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndexBase.cs
  6. 15
      backend/src/Squidex.Data.MongoDb/Infrastructure/MongoExtensions.cs
  7. 4
      backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs
  8. 72
      backend/src/Squidex.Infrastructure/Http/SsrfExtensions.cs
  9. 66
      backend/src/Squidex.Infrastructure/Http/SsrfHelper.cs
  10. 37
      backend/src/Squidex.Infrastructure/Http/SsrfOptions.cs
  11. 56
      backend/src/Squidex.Infrastructure/Http/SsrfProtectionHandler.cs
  12. 4
      backend/src/Squidex/Config/Domain/RuleServices.cs
  13. 18
      backend/src/Squidex/appsettings.json
  14. 3
      backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/MongoTextIndexTests.cs
  15. 193
      backend/tests/Squidex.Infrastructure.Tests/Http/SsrfHelperTests.cs
  16. 131
      backend/tests/Squidex.Infrastructure.Tests/Http/SsrfProtectionHandlerTests.cs
  17. 6
      tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs
  18. 1
      tools/TestSuite/TestSuite.ApiTests/ContentUserTests.cs
  19. 1
      tools/TestSuite/docker-compose-base.yml

4
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<WebhookFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<WebhookAction>();

4
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> atlasOptions, string shardKey)
: MongoTextIndexBase<Dictionary<string, string>>(database, shardKey, new CommandFactory<Dictionary<string, string>>(BuildTexts))
: MongoTextIndexBase<Dictionary<string, string>>(database, shardKey, MongoDerivate.MongoDB,
new CommandFactory<Dictionary<string, string>>(BuildTexts))
{
private static readonly LuceneQueryVisitor QueryVisitor = new LuceneQueryVisitor(AtlasIndexDefinition.GetFieldPath);
private static readonly LuceneQueryAnalyzer QueryParser =

3
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<string>(database, shardKey,
: MongoTextIndexBase<string>(database, shardKey, MongoDerivate.DocumentDB,
new CommandFactory<string>(BuildTexts))
{
private record struct SearchOperation

5
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<List<MongoTextIndexEntityText>>(database, shardKey,
public sealed class MongoTextIndex(IMongoDatabase database, string shardKey, MongoDerivate derivate)
: MongoTextIndexBase<List<MongoTextIndexEntityText>>(database, shardKey, derivate,
new CommandFactory<List<MongoTextIndexEntityText>>(BuildTexts))
{
private record struct SearchOperation

14
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<T>(IMongoDatabase database, string shardKey, CommandFactory<T> factory)
public abstract class MongoTextIndexBase<T>(IMongoDatabase database, string shardKey, MongoDerivate derivate, CommandFactory<T> factory)
: MongoRepositoryBase<MongoTextIndexEntity<T>>(database), ITextIndex, IDeleter where T : class
{
protected sealed class MongoTextResult
@ -54,9 +55,7 @@ public abstract class MongoTextIndexBase<T>(IMongoDatabase database, string shar
protected override async Task SetupCollectionAsync(IMongoCollection<MongoTextIndexEntity<T>> 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<T>(IMongoDatabase database, string shar
new CreateIndexModel<MongoTextIndexEntity<T>>(
Index
.Geo2DSphere(x => x.UserInfoApiKey),
.Ascending(x => x.UserInfoApiKey),
new CreateIndexOptions { Sparse = true }),
], ct);
}
@ -96,6 +95,11 @@ public abstract class MongoTextIndexBase<T>(IMongoDatabase database, string shar
.Ascending(x => x.SchemaId)
.Ascending(x => x.GeoField)
.Geo2DSphere(x => x.GeoObject)),
new CreateIndexModel<MongoTextIndexEntity<T>>(
Index
.Ascending(x => x.UserInfoApiKey),
new CreateIndexOptions { Sparse = true }),
], ct);
}
}

15
backend/src/Squidex.Data.MongoDb/Infrastructure/MongoExtensions.cs

@ -221,21 +221,6 @@ public static class MongoExtensions
return result;
}
public static async Task<bool> IsFerretDbAsync(this IMongoDatabase database,
CancellationToken ct = default)
{
var command =
new BsonDocumentCommand<BsonDocument>(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<List<T>> ToListRandomAsync<T>(this IFindFluent<T, T> find, IMongoCollection<T> collection, long take,
CancellationToken ct = default)
{

4
backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs

@ -227,7 +227,7 @@ public static class ServiceExtensions
shardKey => ActivatorUtilities.CreateInstance<AtlasTextIndex>(c, shardKey));
}).AsOptional<ITextIndex>().As<IDeleter>();
}
else if (config.GetValue<MongoDerivate>("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<List<MongoTextIndexEntityText>>(GetSharding(config, "store:mongoDB:textShardCount"),
shardKey => ActivatorUtilities.CreateInstance<MongoTextIndex>(c, shardKey));
shardKey => ActivatorUtilities.CreateInstance<MongoTextIndex>(c, shardKey, derivate));
}).AsOptional<ITextIndex>().As<IDeleter>();
}

72
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<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

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

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

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

4
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<RulesOptions>(config,
"rules");
services.Configure<SsrfOptions>(config,
"ssrf");
services.AddSingletonAs<EventEnricher>()
.As<IEventEnricher>();

18
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,

3
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<ITextIndex> 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;

193
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> { 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

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

6
tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs

@ -405,6 +405,7 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "FerretExcluded")]
public async Task Should_query_by_full_text_with_odata()
{
var q = new ContentQuery { Search = "text2" };
@ -415,6 +416,7 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "FerretExcluded")]
public async Task Should_query_by_full_text_with_json()
{
var q = new ContentQuery
@ -431,6 +433,7 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "FerretExcluded")]
public async Task Should_query_by_near_location_with_odata()
{
var q = new ContentQuery { Filter = "geo.distance(data/geo/iv, geography'POINT(103 3)') lt 1000" };
@ -441,6 +444,7 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "FerretExcluded")]
public async Task Should_query_by_near_location_with_json()
{
var q = new ContentQuery
@ -467,6 +471,7 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "FerretExcluded")]
public async Task Should_query_by_near_geoson_location_with_odata()
{
var q = new ContentQuery { Filter = "geo.distance(data/geo/iv, geography'POINT(104 4)') lt 1000" };
@ -528,6 +533,7 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "FerretExcluded")]
public async Task Should_query_by_near_geoson_location_with_json()
{
var q = new ContentQuery

1
tools/TestSuite/TestSuite.ApiTests/ContentUserTests.cs

@ -21,6 +21,7 @@ public sealed class ContentUserTests(CreatedAppFixture fixture) : IClassFixture<
public CreatedAppFixture _ { get; } = fixture;
[Fact]
[Trait("Category", "FerretExcluded")]
public async Task Should_login_with_user_credentials()
{
var apiKey = Guid.NewGuid().ToString();

1
tools/TestSuite/docker-compose-base.yml

@ -17,6 +17,7 @@ services:
- RULES__RULESCACHEDURATION=00:00:00
- SCRIPTING__TIMEOUTEXECUTION=00:00:10
- SCRIPTING__TIMEOUTSCRIPT=00:00:10
- SSRF__WHITELISTEDHOSTS__0=*
- STORE__TYPE=MongoDb
- STORE__MONGODB__CONFIGURATION=mongodb://db_mongo
- TEMPLATES__LOCALURL=http://localhost:5000

Loading…
Cancel
Save