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