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 14, 2023
1 parent 88ae910 commit d1ba789
Show file tree
Hide file tree
Showing 6 changed files with 292 additions and 9 deletions.
2 changes: 2 additions & 0 deletions src/Raven.Client/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ private Headers()

public const string ContentLength = "Content-Length";

public const string Origin = "Origin";

public const string IncrementalTimeSeriesPrefix = "INC:";
}

Expand Down
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
79 changes: 72 additions & 7 deletions src/Raven.Server/Web/RequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ public abstract class RequestHandler

public const string PageSizeParameter = "pageSize";

internal static readonly HashSet<string> SafeCsrfMethods = new()
{
HttpMethod.Head.Method,
HttpMethod.Options.Method,
HttpMethod.Trace.Method
};

private RequestHandlerContext _context;

protected HttpContext HttpContext
Expand Down Expand Up @@ -687,6 +694,70 @@ 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 requestedOrigin = httpContext.Request.Headers[Constants.Headers.Origin];

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

// no origin at this point - it means it is safe request or non-browser

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

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

var origin = requestedOrigin[0];
var uriOrigin = new Uri(origin);
var originHost = uriOrigin.Host;
var originAuthority = uriOrigin.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>();
if (trustedOrigins.Length > 0)
{
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>();
if (additionalHeaders.Length > 0)
{
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 +778,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 +791,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
194 changes: 194 additions & 0 deletions test/SlowTests/Issues/RavenDB-21651.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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.Abstractions;
using Tests.Infrastructure;
using Xunit;

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 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";

[RavenFact(RavenTestCategory.Studio | RavenTestCategory.Security)]
public async Task CsrfProtectionForUnsecuredSingleNodeServerBaseCase()
{
// we are using default CSRF settings
var (_, leader) = await CreateRaftCluster(1, false);

var testUrl = leader.WebUrl + "/databases";
var leaderHost = new Uri(leader.WebUrl).Authority;
var sameHostAsLeaderButDifferentPort = "http://" + new Uri(leader.WebUrl).Host + ":21";

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

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

var testUrl = leader.WebUrl + "/databases";
var leaderHost = new Uri(leader.WebUrl).Authority;
var sameHostAsLeaderButDifferentPort = "http://" + new Uri(leader.WebUrl).Host + ":21";

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

[RavenFact(RavenTestCategory.Studio | RavenTestCategory.Security)]
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 testUrl = leader.WebUrl + "/databases";
var leaderHost = new Uri(leader.WebUrl).Authority;
var sameHostAsLeaderButDifferentPort = "http://" + new Uri(leader.WebUrl).Host + ":21";

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

[RavenFact(RavenTestCategory.Studio | RavenTestCategory.Security)]
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 testUrl = leader.WebUrl + "/databases";
var leaderHost = new Uri(leader.WebUrl).Authority;
var sameHostAsLeaderButDifferentPort = "http://" + new Uri(leader.WebUrl).Host + ":21";

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

private async Task Act(string testUrl, 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, testUrl, new Dictionary<string, string>(), true, certificate);
await ExecuteRequest(HttpMethod.Get, testUrl, new Dictionary<string, string> {{"Host", host}}, true, certificate);
await ExecuteRequest(HttpMethod.Get, testUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", EvilOrigin}}, !csrfEnabled, certificate);
await ExecuteRequest(HttpMethod.Get, testUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", sameHostAsLeaderButDifferentPort}}, !csrfEnabled,
certificate);
await ExecuteRequest(HttpMethod.Get, testUrl, new Dictionary<string, string> {{"Host", host}, {"Origin", ExternalTrustedOriginUrl}}, acceptAllowedOrigin,
certificate);
await ExecuteRequest(HttpMethod.Get, testUrl,
new Dictionary<string, string> {{"Host", host}, {"Origin", ProxyServerUrl}, {OriginHeader, ProxyServerHost}}, acceptProxy, certificate);
await ExecuteRequest(HttpMethod.Get, testUrl, 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, testUrl, 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 = (_, _, _, _) => true,
SslProtocols = TcpUtils.SupportedSslProtocols,
AllowAutoRedirect = true
};

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

using (var httpClient = new HttpClient(handler))
{
HttpRequestMessage request = new() {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, "Expected successful response but got: " + response.StatusCode);
}
else
{
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
}
}
}
3 changes: 2 additions & 1 deletion test/Tests.Infrastructure/RavenTestCategory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,6 @@ public enum RavenTestCategory : long
ClusterTransactions = 1L << 39,
Highlighting = 1L << 40,
Smuggler = 1L << 41,
Lucene = 1L << 42
Lucene = 1L << 42,
Security = 1L << 43,
}

0 comments on commit d1ba789

Please sign in to comment.