mirror of https://github.com/Squidex/squidex.git
Browse Source
* 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
committed by
GitHub
19 changed files with 610 additions and 27 deletions
@ -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); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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("*"); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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)); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue