mirror of https://github.com/Squidex/squidex.git
22 changed files with 155 additions and 280 deletions
@ -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); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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("*"); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue