Skip to content

Commit 9efe4a6

Browse files
Add SSRF protection.
1 parent 2febd20 commit 9efe4a6

File tree

9 files changed

+525
-0
lines changed

9 files changed

+525
-0
lines changed

backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
using Microsoft.Extensions.Configuration;
99
using Microsoft.Extensions.DependencyInjection;
10+
using Squidex.Infrastructure.Http;
1011
using Squidex.Infrastructure.Plugins;
1112

1213
namespace Squidex.Extensions.Actions.Webhook;
@@ -15,6 +16,9 @@ public sealed class WebhookPlugin : IPlugin
1516
{
1617
public void ConfigureServices(IServiceCollection services, IConfiguration config)
1718
{
19+
services.AddHttpClient("FlowClient")
20+
.EnableSsrfProtection();
21+
1822
services.AddFlowStep<WebhookFlowStep>();
1923
#pragma warning disable CS0618 // Type or member is obsolete
2024
services.AddRuleAction<WebhookAction>();
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// ==========================================================================
2+
// Squidex Headless CMS
3+
// ==========================================================================
4+
// Copyright (c) Squidex UG (haftungsbeschraenkt)
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
using System.Net;
9+
using System.Net.Sockets;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.Extensions.Options;
12+
13+
namespace Squidex.Infrastructure.Http;
14+
15+
public static class SsrfExtensions
16+
{
17+
public static IHttpClientBuilder EnableSsrfProtection(this IHttpClientBuilder builder )
18+
{
19+
builder.AddHttpMessageHandler<SsrfProtectionHandler>();
20+
builder.ConfigurePrimaryHttpMessageHandler(services =>
21+
{
22+
var options = services.GetService<IOptions<SsrfOptions>>()?.Value ?? new ();
23+
24+
return new SocketsHttpHandler
25+
{
26+
ConnectCallback = options.EnableDnsRebindingProtection
27+
? CreateSecureConnectCallback(options)
28+
: null,
29+
AllowAutoRedirect = options.AllowAutoRedirect,
30+
};
31+
});
32+
33+
return builder;
34+
}
35+
36+
private static Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>> CreateSecureConnectCallback(SsrfOptions options)
37+
{
38+
return async (context, cancellationToken) =>
39+
{
40+
var host = context.DnsEndPoint.Host;
41+
42+
// Re-validate DNS to prevent DNS rebinding attacks
43+
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
44+
45+
foreach (var address in addresses)
46+
{
47+
if (SsrfHelper.IsPrivateOrReservedIp(address, options.BlockedIpAddresses))
48+
{
49+
throw new HttpRequestException($"Connection to private IP blocked: {address}");
50+
}
51+
}
52+
53+
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
54+
await socket.ConnectAsync(context.DnsEndPoint, cancellationToken);
55+
56+
return new NetworkStream(socket, ownsSocket: true);
57+
};
58+
}
59+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// ==========================================================================
2+
// Squidex Headless CMS
3+
// ==========================================================================
4+
// Copyright (c) Squidex UG (haftungsbeschraenkt)
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
using System.Net;
9+
using System.Net.Sockets;
10+
11+
#pragma warning disable SA1025 // Code should not contain multiple whitespace in a row
12+
13+
namespace Squidex.Infrastructure.Http;
14+
15+
public static class SsrfHelper
16+
{
17+
public static bool IsPrivateOrReservedIp(IPAddress ip, HashSet<IPAddress>? blackList)
18+
{
19+
if (IPAddress.IsLoopback(ip))
20+
{
21+
return true;
22+
}
23+
24+
if (ip.AddressFamily == AddressFamily.InterNetwork)
25+
{
26+
var bytes = ip.GetAddressBytes();
27+
28+
var isBlocked =
29+
(bytes[0] == 10) || // 10.0.0.0/8
30+
(bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) || // 172.16.0.0/12
31+
(bytes[0] == 192 && bytes[1] == 168) || // 192.168.0.0/16
32+
(bytes[0] == 169 && bytes[1] == 254) || // link-local
33+
(bytes[0] == 0) || // 0.0.0.0/8
34+
(bytes[0] >= 224 && bytes[0] <= 239) || // 224.0.0.0/4 multicast
35+
(bytes[0] >= 240); // 240.0.0.0/4 reserved
36+
37+
if (isBlocked)
38+
{
39+
return true;
40+
}
41+
}
42+
43+
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
44+
{
45+
var bytes = ip.GetAddressBytes();
46+
47+
var isBlocked =
48+
ip.IsIPv6LinkLocal || // fe80::/10
49+
ip.IsIPv6SiteLocal || // fec0::/10 (deprecated)
50+
ip.IsIPv6Multicast || // ff00::/8
51+
((bytes[0] & 0xfe) == 0xfc); // fc00::/7 - Unique local
52+
53+
if (isBlocked)
54+
{
55+
return true;
56+
}
57+
}
58+
59+
if (blackList is { Count: > 0 })
60+
{
61+
return blackList.Contains(ip);
62+
}
63+
64+
return false;
65+
}
66+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// ==========================================================================
2+
// Squidex Headless CMS
3+
// ==========================================================================
4+
// Copyright (c) Squidex UG (haftungsbeschraenkt)
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
using System.Net;
9+
10+
namespace Squidex.Infrastructure.Http;
11+
12+
public class SsrfOptions
13+
{
14+
public HashSet<string> AllowedSchemes { get; set; } =
15+
new HashSet<string>(
16+
["http", "https"],
17+
StringComparer.OrdinalIgnoreCase);
18+
19+
public HashSet<IPAddress> BlockedIpAddresses { get; set; } =
20+
new HashSet<IPAddress>(
21+
[IPAddress.Parse("169.254.169.254")]);
22+
23+
public bool AllowAutoRedirect { get; set; }
24+
25+
public bool EnableDnsRebindingProtection { get; set; } = true;
26+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// ==========================================================================
2+
// Squidex Headless CMS
3+
// ==========================================================================
4+
// Copyright (c) Squidex UG (haftungsbeschraenkt)
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
using System.Net;
9+
using System.Net.Sockets;
10+
using Microsoft.Extensions.Options;
11+
12+
namespace Squidex.Infrastructure.Http;
13+
14+
public class SsrfProtectionHandler(IOptions<SsrfOptions> options) : DelegatingHandler
15+
{
16+
protected override async Task<HttpResponseMessage> SendAsync(
17+
HttpRequestMessage request,
18+
CancellationToken cancellationToken)
19+
{
20+
if (request.RequestUri == null)
21+
{
22+
throw new HttpRequestException("Request URI is null");
23+
}
24+
25+
if (!options.Value.AllowedSchemes.Contains(request.RequestUri.Scheme))
26+
{
27+
throw new HttpRequestException($"Scheme '{request.RequestUri.Scheme}' is not allowed");
28+
}
29+
30+
var host = request.RequestUri.Host;
31+
try
32+
{
33+
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
34+
35+
foreach (var address in addresses)
36+
{
37+
if (SsrfHelper.IsPrivateOrReservedIp(address, options.Value.BlockedIpAddresses))
38+
{
39+
throw new HttpRequestException($"Request blocked: '{host}' resolves to private IP {address}");
40+
}
41+
}
42+
}
43+
catch (SocketException ex)
44+
{
45+
throw new HttpRequestException($"DNS resolution failed for '{host}'", ex);
46+
}
47+
48+
return await base.SendAsync(request, cancellationToken);
49+
}
50+
}

backend/src/Squidex/Config/Domain/RuleServices.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
using Squidex.Domain.Apps.Entities.Schemas;
2424
using Squidex.Flows.Internal.Execution;
2525
using Squidex.Infrastructure.EventSourcing;
26+
using Squidex.Infrastructure.Http;
2627
using Squidex.Infrastructure.Reflection;
2728

2829
namespace Squidex.Config.Domain;
@@ -34,6 +35,9 @@ public static void AddSquidexRules(this IServiceCollection services, IConfigurat
3435
services.Configure<RulesOptions>(config,
3536
"rules");
3637

38+
services.Configure<SsrfOptions>(config,
39+
"ssrf");
40+
3741
services.AddSingletonAs<EventEnricher>()
3842
.As<IEventEnricher>();
3943

backend/src/Squidex/appsettings.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,18 @@
8383
"Squidex.Extensions.dll"
8484
],
8585

86+
// SSRF Options for outgoing http requests.
87+
"ssrf": {
88+
// Enable DNS rebinding protection (validates DNS twice, recommended for security).
89+
"enableDnsRebindingProtection": true,
90+
91+
// Allowed URI schemes for outbound requests.
92+
"allowedSchemes": [ "http", "https" ],
93+
94+
// Additional IP addresses to block (e.g., cloud metadata endpoints).
95+
"blockedIpAddresses": [ "169.254.169.254" ]
96+
},
97+
8698
"caching": {
8799
// Set to true, to use strong etags.
88100
"strongETag": false,

0 commit comments

Comments
 (0)