Skip to content

Commit

Permalink
RavenDB-21651 CSRF protection in Studio
Browse files Browse the repository at this point in the history
  • Loading branch information
ml054 committed Nov 3, 2023
1 parent 9479724 commit 67974ba
Show file tree
Hide file tree
Showing 4 changed files with 275 additions and 8 deletions.
15 changes: 15 additions & 0 deletions src/Raven.Server/Config/Categories/SecurityConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,21 @@ public class SecurityConfiguration : ConfigurationCategory
[DefaultValue(true)]
[ConfigurationEntry("Security.Certificate.Validation.KeyUsages", ConfigurationEntryScope.ServerWideOnly)]
public bool CertificateValidationKeyUsages { get; set; }

[Description("Indicates if CSRF filter is enabled or not. Default: true")]
[DefaultValue(true)]
[ConfigurationEntry("Security.Csrf.Enabled", ConfigurationEntryScope.ServerWideOnly)]
public bool EnableCsrfFilter { get; set; }

[Description("List of Trusted Origins for CSRF filter")]
[DefaultValue(null)]
[ConfigurationEntry("Security.Csrf.TrustedOrigins", ConfigurationEntryScope.ServerWideOnly)]
public string[] CsrfTrustedOrigins { get; set; }

[Description("List of Request Headers to look for Origin, ex. X-Forwarded-Host")]
[DefaultValue(null)]
[ConfigurationEntry("Security.Csrf.AdditionalOriginHeaders", ConfigurationEntryScope.ServerWideOnly)]
public string[] CsrfAdditionalOriginHeaders { get; set; }

internal bool? IsUnsecureAccessSetupValid { get; private set; }

Expand Down
8 changes: 7 additions & 1 deletion src/Raven.Server/Routing/RequestRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,13 @@ public async ValueTask HandlePath(RequestHandlerContext reqCtx)
skipAuthorization = context.Request.Method == "OPTIONS";
}

var status = RavenServer.AuthenticationStatus.ClusterAdmin;
if (RequestHandler.CheckCSRF(context, reqCtx.RavenServer.ServerStore) == false)
{
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
return;
}

var status = AuthenticationStatus.ClusterAdmin;
try
{
if (_ravenServer.Configuration.Security.AuthenticationEnabled && skipAuthorization == false)
Expand Down
65 changes: 58 additions & 7 deletions src/Raven.Server/Web/RequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public abstract class RequestHandler

public const string PageSizeParameter = "pageSize";

public static readonly List<string> SafeCsrfMethods = new(new[] { "HEAD", "OPTIONS", "TRACE" });

private RequestHandlerContext _context;

protected HttpContext HttpContext
Expand Down Expand Up @@ -687,6 +689,61 @@ private static void ThrowInvalidAuthStatus(RavenServer.AuthenticationStatus? sta
throw new ArgumentOutOfRangeException("Unknown authentication status: " + status);
}

public static bool CheckCSRF(HttpContext httpContext, ServerStore serverStore)
{
if (serverStore.Configuration.Security.EnableCsrfFilter == false)
return true;

var host = httpContext.Request.Host;
if (string.IsNullOrWhiteSpace(host.ToString()))
return false;

var requestedOrigin = httpContext.Request.Headers["Origin"];

if (requestedOrigin.Count == 0 || requestedOrigin[0] == null)
return true;

if (SafeCsrfMethods.Contains(httpContext.Request.Method))
return true;

var origin = requestedOrigin[0];
var originHost = new Uri(origin).Host;
var originAuthority = new Uri(origin).Authority;

// for hostname matching we validate both hostname and port
var hostMatches = host.ToString() == originAuthority;
if (hostMatches)
return true;

// for requests with-in cluster we value both hostname and port
var requestWithinCluster = IsOriginAllowed(origin, serverStore);
if (requestWithinCluster)
return true;

// for trusted origins we match hostname only, port is ignored
var trustedOrigins = serverStore.Configuration.Security.CsrfTrustedOrigins ?? Array.Empty<string>();
foreach (var o in trustedOrigins)
{
if (originHost == o)
return true;
}

// for additional origin headers we match hostname only, port is ignored
var additionalHeaders = serverStore.Configuration.Security.CsrfAdditionalOriginHeaders ?? Array.Empty<string>();
foreach (string additionalHeader in additionalHeaders)
{
if (httpContext.Request.Headers.TryGetValue(additionalHeader, out var headerValue) == false)
continue;

var stringHeader = headerValue.ToString();

if (stringHeader == originAuthority)
return true;
}

return false;
}

public static void SetupCORSHeaders(HttpContext httpContext, ServerStore serverStore, CorsMode corsMode)
{
httpContext.Response.Headers.Add("Vary", "Origin");
Expand All @@ -707,7 +764,7 @@ public static void SetupCORSHeaders(HttpContext httpContext, ServerStore serverS
allowedOrigin = requestedOrigin;
break;
case CorsMode.Cluster:
if (IsOriginAllowed(requestedOrigin, serverStore))
if (serverStore.Server.Certificate.Certificate == null || IsOriginAllowed(requestedOrigin, serverStore))
allowedOrigin = requestedOrigin;
break;
}
Expand All @@ -720,12 +777,6 @@ public static void SetupCORSHeaders(HttpContext httpContext, ServerStore serverS

private static bool IsOriginAllowed(string origin, ServerStore serverStore)
{
if (serverStore.Server.Certificate.Certificate == null)
{
// running in unsafe mode - since server can be access via multiple urls/aliases accept them
return true;
}

var topology = serverStore.GetClusterTopology();

// check explicitly each topology type to avoid allocations in topology.AllNodes
Expand Down
195 changes: 195 additions & 0 deletions test/SlowTests/Issues/RavenDB-21651.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using FastTests.Server.Replication;
using Raven.Client.ServerWide.Operations.Certificates;
using Raven.Client.Util;
using Raven.Server;
using Xunit;
using Xunit.Abstractions;

namespace SlowTests.Issues;

public class RavenDB_21651 : ReplicationTestBase
{
public RavenDB_21651(ITestOutputHelper output) : base(output)
{
}

public const string ExternalTrustedOriginHostname = "external-trusted-origin";
public const string ExternalTrustedOriginUrl = "http://external-trusted-origin:8080";
public const string ExternalTrustedOriginInHeader = "external-trusted-origin-passed-via-header:8084";
public const string OriginHeader = "X-Forwarded-Host";

public const string ProxyServerHost = "proxy:5656";
public const string ProxyServerUrl = "http://proxy:5656";

public const string EvilOrigin = "http://hacked-server:8080";

[Fact]
public async Task CsrfProtectionForUnsecuredSingleNodeServerBaseCase()
{
// we are using default CSRF settings
var (_, leader) = await CreateRaftCluster(1, false);

var studioUrl = leader.WebUrl + "/studio/index.html";
var leaderHost = new Uri(leader.WebUrl).Authority;
var sameHostAsLeaderButDifferentPort = "http://" + new Uri(leader.WebUrl).Host + ":21";

await Act(studioUrl, leaderHost, sameHostAsLeaderButDifferentPort, leader);
}

[Fact]
public async Task CsrfProtectionForUnsecuredSingleNodeServer_WithoutCsrf()
{
var settings = new Dictionary<string, string>
{
{"Security.Csrf.Enabled", "false"}
};
var (_, leader) = await CreateRaftCluster(1, false, customSettings: settings);

var studioUrl = leader.WebUrl + "/studio/index.html";
var leaderHost = new Uri(leader.WebUrl).Authority;
var sameHostAsLeaderButDifferentPort = "http://" + new Uri(leader.WebUrl).Host + ":21";

await Act(studioUrl, leaderHost, sameHostAsLeaderButDifferentPort, leader);
}

[Fact]
public async Task CsrfProtectionForUnsecuredSingleNodeServer()
{
var settings = new Dictionary<string, string>
{
{"Security.Csrf.TrustedOrigins", ExternalTrustedOriginHostname}, {"Security.Csrf.AdditionalOriginHeaders", OriginHeader}
};

var (_, leader) = await CreateRaftCluster(1, false, customSettings: settings);

var studioUrl = leader.WebUrl + "/studio/index.html";
var leaderHost = new Uri(leader.WebUrl).Authority;
var sameHostAsLeaderButDifferentPort = "http://" + new Uri(leader.WebUrl).Host + ":21";

await Act(studioUrl, leaderHost, sameHostAsLeaderButDifferentPort, leader);
}

[Fact]
public async Task CsrfProtectionForSecuredCluster()
{
var clusterSize = 3;
var databaseName = GetDatabaseName();
var (_, leader, certificates) = await CreateRaftClusterWithSsl(clusterSize, false);

X509Certificate2 adminCertificate = Certificates.RegisterClientCertificate(certificates, new Dictionary<string, DatabaseAccess>(), SecurityClearance.ClusterAdmin, server: leader);

var members = leader.ServerStore.GetClusterTopology().Members.Values.ToList();
var nonLeaderUrl = members.First(x => x != leader.WebUrl);
var leaderUrl = leader.WebUrl;

var studioUrl = leader.WebUrl + "/studio/index.html";
var leaderHost = new Uri(leader.WebUrl).Authority;
var sameHostAsLeaderButDifferentPort = "http://" + new Uri(leader.WebUrl).Host + ":21";

await Act(studioUrl, leaderHost, sameHostAsLeaderButDifferentPort, leader, adminCertificate);
}

private async Task Act(string studioUrl, string host, string sameHostAsLeaderButDifferentPort, RavenServer server, X509Certificate2 certificate = null)
{
bool csrfEnabled = server.Configuration.Security.EnableCsrfFilter;
bool acceptProxy = !csrfEnabled || server.Configuration.Security.CsrfAdditionalOriginHeaders?.Length > 0;
bool acceptAllowedOrigin = !csrfEnabled || server.Configuration.Security.CsrfTrustedOrigins?.Length > 0;

var nodes = server.ServerStore.GetClusterTopology().AllNodes.Values.ToList();
var differentNode = nodes.FirstOrDefault(x => !x.Contains(host));

// no CSRF for OPTIONS
var clusterObserverDecisionsUrl = server.WebUrl + "/admin/cluster/observer/decisions";

await ExecuteRequest(HttpMethod.Options, clusterObserverDecisionsUrl, new Dictionary<string, string>(), true, certificate);
await ExecuteRequest(HttpMethod.Options, clusterObserverDecisionsUrl, new Dictionary<string, string> {{"Host", host}}, true, certificate);
await ExecuteRequest(HttpMethod.Options, clusterObserverDecisionsUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", EvilOrigin}}, true, certificate);
await ExecuteRequest(HttpMethod.Options, clusterObserverDecisionsUrl,
new Dictionary<string, string> {{"Host", host}, {"Origin", sameHostAsLeaderButDifferentPort}}, true, certificate);
await ExecuteRequest(HttpMethod.Options, clusterObserverDecisionsUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", ExternalTrustedOriginUrl}},
true, certificate);
await ExecuteRequest(HttpMethod.Options, clusterObserverDecisionsUrl,
new Dictionary<string, string> {{"Host", host}, {"Origin", ProxyServerUrl}, {OriginHeader, ProxyServerHost}}, true, certificate);
await ExecuteRequest(HttpMethod.Options, clusterObserverDecisionsUrl,
new Dictionary<string, string> {{"Host", ProxyServerHost}, {"Origin", EvilOrigin}, {OriginHeader, server.WebUrl}}, true, certificate);

await ExecuteRequest(HttpMethod.Get, studioUrl, new Dictionary<string, string>(), true, certificate);
await ExecuteRequest(HttpMethod.Get, studioUrl, new Dictionary<string, string> {{"Host", host}}, true, certificate);
await ExecuteRequest(HttpMethod.Get, studioUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", EvilOrigin}}, !csrfEnabled, certificate);
await ExecuteRequest(HttpMethod.Get, studioUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", sameHostAsLeaderButDifferentPort}}, !csrfEnabled,
certificate);
await ExecuteRequest(HttpMethod.Get, studioUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", ExternalTrustedOriginUrl}}, acceptAllowedOrigin,
certificate);
await ExecuteRequest(HttpMethod.Get, studioUrl,
new Dictionary<string, string> {{"Host", host}, {"Origin", ProxyServerUrl}, {OriginHeader, ProxyServerHost}}, acceptProxy, certificate);
await ExecuteRequest(HttpMethod.Get, studioUrl, new Dictionary<string, string> {{"Host", ProxyServerHost}, {"Origin", EvilOrigin}, {OriginHeader, server.WebUrl}},
!csrfEnabled, certificate);

var eulaUrl = server.WebUrl + "/admin/license/eula/accept";
await ExecuteRequest(HttpMethod.Post, eulaUrl, new Dictionary<string, string>(), true, certificate);
await ExecuteRequest(HttpMethod.Post, eulaUrl, new Dictionary<string, string> {{"Host", host}}, true, certificate);
await ExecuteRequest(HttpMethod.Post, eulaUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", EvilOrigin}}, !csrfEnabled, certificate);
await ExecuteRequest(HttpMethod.Post, eulaUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", sameHostAsLeaderButDifferentPort}}, !csrfEnabled,
certificate);
await ExecuteRequest(HttpMethod.Post, eulaUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", ExternalTrustedOriginUrl}}, acceptAllowedOrigin,
certificate);
await ExecuteRequest(HttpMethod.Post, eulaUrl,
new Dictionary<string, string> {{"Host", host}, {"Origin", ProxyServerUrl}, {OriginHeader, ProxyServerHost}}, acceptProxy, certificate);
await ExecuteRequest(HttpMethod.Post, eulaUrl, new Dictionary<string, string> {{"Host", ProxyServerHost}, {"Origin", EvilOrigin}, {OriginHeader, server.WebUrl}},
!csrfEnabled, certificate);

if (differentNode != null)
{
// cross-cluster
await ExecuteRequest(HttpMethod.Options, clusterObserverDecisionsUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", differentNode}}, true, certificate);
await ExecuteRequest(HttpMethod.Get, studioUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", differentNode}}, true, certificate);
await ExecuteRequest(HttpMethod.Post, eulaUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", differentNode}}, true, certificate);
}
}


private async Task ExecuteRequest(HttpMethod method, string uri, Dictionary<string, string> headers, bool allowed, X509Certificate2 certificate = null)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (message, certificate2, arg3, arg4) => true, SslProtocols = TcpUtils.SupportedSslProtocols
};

if (certificate != null)
{
handler.ClientCertificates.Add(certificate);
}

using (var httpClient = new HttpClient(handler))
{
HttpRequestMessage request = new HttpRequestMessage {Method = method, RequestUri = new Uri(uri)};

if (headers != null)
{
foreach ((string key, string value) in headers)
{
request.Headers.Add(key, value);
}
}

using (var response = await httpClient.SendAsync(request))
{
if (allowed)
{
Assert.True(response.IsSuccessStatusCode);
}
else
{
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
}
}
}

0 comments on commit 67974ba

Please sign in to comment.