diff --git a/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs b/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs index 038504c38..3db47b4f3 100644 --- a/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs +++ b/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs @@ -8,50 +8,38 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; namespace Squidex.Web.Pipeline { public class CleanupHostMiddleware { private readonly RequestDelegate next; - private readonly HostString host; - private readonly string schema; - public CleanupHostMiddleware(RequestDelegate next, IOptions options) + public CleanupHostMiddleware(RequestDelegate next) { this.next = next; - - var uri = new Uri(options.Value.BaseUrl); - - if (HasHttpPort(uri) || HasHttpsPort(uri)) - { - host = new HostString(uri.Host); - } - else - { - host = new HostString(uri.Host, uri.Port); - } - - schema = uri.Scheme.ToLowerInvariant(); } public Task InvokeAsync(HttpContext context) { - context.Request.Host = host; - context.Request.Scheme = schema; + var request = context.Request; + + if (request.Host.HasValue && (HasHttpsPort(request) || HasHttpPort(request))) + { + request.Host = new HostString(request.Host.Host); + } return next(context); } - private static bool HasHttpPort(Uri uri) + private static bool HasHttpPort(HttpRequest request) { - return uri.Scheme == "http" && uri.Port == 80; + return request.Scheme == "http" && request.Host.Port == 80; } - private static bool HasHttpsPort(Uri uri) + private static bool HasHttpsPort(HttpRequest request) { - return uri.Scheme == "https" && uri.Port == 443; + return request.Scheme == "https" && request.Host.Port == 443; } } } \ No newline at end of file diff --git a/backend/src/Squidex.Web/UrlsOptions.cs b/backend/src/Squidex.Web/UrlsOptions.cs index 53503fa16..3ff9c11ad 100644 --- a/backend/src/Squidex.Web/UrlsOptions.cs +++ b/backend/src/Squidex.Web/UrlsOptions.cs @@ -7,17 +7,27 @@ using System; using System.Collections.Generic; -using System.Linq; +using Microsoft.AspNetCore.Http; using Squidex.Infrastructure; namespace Squidex.Web { public sealed class UrlsOptions { - private readonly HashSet allTrustedHosts = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly HashSet allTrustedHosts = new HashSet(); private string baseUrl; private string[] trustedHosts; + public string[] KnownProxies { get; set; } + + public bool EnableForwardHeaders { get; set; } = true; + + public bool EnforceHTTPS { get; set; } = false; + + public bool EnforceHost { get; set; } = true; + + public int? HttpsPort { get; set; } = 443; + public string BaseUrl { get @@ -26,9 +36,9 @@ namespace Squidex.Web } set { - if (Uri.TryCreate(value, UriKind.Absolute, out var uri)) + if (TryBuildHost(value, out var host)) { - allTrustedHosts.Add(uri.Host); + allTrustedHosts.Add(host); } baseUrl = value; @@ -43,9 +53,15 @@ namespace Squidex.Web } set { - foreach (var host in trustedHosts?.Where(x => !string.IsNullOrWhiteSpace(x)).OrEmpty()!) + if (trustedHosts != null) { - allTrustedHosts.Add(host); + foreach (var canidate in trustedHosts) + { + if (TryBuildHost(canidate, out var host)) + { + allTrustedHosts.Add(host); + } + } } trustedHosts = value; @@ -54,12 +70,22 @@ namespace Squidex.Web public bool IsAllowedHost(string? url) { - return Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri) && IsAllowedHost(uri); + if (!Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri)) + { + return false; + } + + return IsAllowedHost(uri); } public bool IsAllowedHost(Uri uri) { - return !uri.IsAbsoluteUri || allTrustedHosts.Contains(uri.Host); + if (!uri.IsAbsoluteUri) + { + return true; + } + + return allTrustedHosts.Contains(BuildHost(uri)); } public string BuildUrl(string path, bool trailingSlash = true) @@ -71,5 +97,63 @@ namespace Squidex.Web return BaseUrl.BuildFullUrl(path, trailingSlash); } + + public HostString BuildHost() + { + if (string.IsNullOrWhiteSpace(BaseUrl)) + { + throw new ConfigurationException("Configure BaseUrl with 'urls:baseUrl'."); + } + + if (!TryBuildHost(BaseUrl, out var host)) + { + throw new ConfigurationException("Configure BaseUrl with 'urls:baseUrl' host name."); + } + + return host; + } + + private static bool TryBuildHost(string urlOrHost, out HostString host) + { + host = default; + + if (string.IsNullOrWhiteSpace(urlOrHost)) + { + return false; + } + + if (Uri.TryCreate(urlOrHost, UriKind.Absolute, out var uri1)) + { + host = BuildHost(uri1); + + return true; + } + + if (Uri.TryCreate($"http://{urlOrHost}", UriKind.Absolute, out var uri2)) + { + host = BuildHost(uri2); + + return true; + } + + return false; + } + + private static HostString BuildHost(Uri uri) + { + return BuildHost(uri.Host, uri.Port); + } + + private static HostString BuildHost(string host, int port) + { + if (port == 443 || port == 80) + { + return new HostString(host.ToLowerInvariant()); + } + else + { + return new HostString(host.ToLowerInvariant(), port); + } + } } } diff --git a/backend/src/Squidex/Config/Web/WebExtensions.cs b/backend/src/Squidex/Config/Web/WebExtensions.cs index ba5ae8e1e..d924e6db8 100644 --- a/backend/src/Squidex/Config/Web/WebExtensions.cs +++ b/backend/src/Squidex/Config/Web/WebExtensions.cs @@ -8,13 +8,16 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using Squidex.Infrastructure.Json; using Squidex.Pipeline.Robots; @@ -130,7 +133,49 @@ namespace Squidex.Config.Web public static void UseSquidexForwardingRules(this IApplicationBuilder app, IConfiguration config) { + var urlsOptions = app.ApplicationServices.GetRequiredService>().Value; + + if (urlsOptions.EnableForwardHeaders) + { + var options = new ForwardedHeadersOptions + { + AllowedHosts = new List + { + new Uri(urlsOptions.BaseUrl).Host + }, + ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost, + ForwardLimit = null, + RequireHeaderSymmetry = false + }; + + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + + if (urlsOptions.KnownProxies != null) + { + foreach (var proxy in urlsOptions.KnownProxies) + { + if (IPAddress.TryParse(proxy, out var address)) + { + options.KnownProxies.Add(address); + } + } + } + + app.UseForwardedHeaders(options); + } + app.UseMiddleware(); + + if (urlsOptions.EnforceHost) + { + app.UseHostFiltering(); + } + + if (urlsOptions.EnforceHTTPS) + { + app.UseHttpsRedirection(); + } } } } diff --git a/backend/src/Squidex/Config/Web/WebServices.cs b/backend/src/Squidex/Config/Web/WebServices.cs index 809113739..3502c48a2 100644 --- a/backend/src/Squidex/Config/Web/WebServices.cs +++ b/backend/src/Squidex/Config/Web/WebServices.cs @@ -5,6 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -100,6 +102,29 @@ namespace Squidex.Config.Web .AddRazorRuntimeCompilation() .AddSquidexPlugins(config) .AddSquidexSerializers(); + + var urlsOptions = config.GetSection("urls").Get(); + + var host = urlsOptions.BuildHost(); + + if (urlsOptions.EnforceHost) + { + services.AddHostFiltering(options => + { + options.AllowEmptyHosts = true; + options.AllowedHosts.Add(host.Host); + + options.IncludeFailureMessage = false; + }); + } + + if (urlsOptions.EnforceHTTPS && !string.Equals(host.Host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + services.AddHttpsRedirection(options => + { + options.HttpsPort = urlsOptions.HttpsPort; + }); + } } } } diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index f02ee54c5..c80fe3da8 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -10,7 +10,32 @@ /* * Set the base url of your application, to generate correct urls in background process. */ - "baseUrl": "https://localhost:5001" + "baseUrl": "https://localhost:5001", + + /* + * Set it to true to redirect the user from http to https permanently. + */ + "enforceHttps": false, + + /* + * Set it to true to return a 400 if the host does not match. + */ + "enforceHost": true, + + /* + * A list of known proxies to make forward headers safer. + */ + "knwonProxies": [], + + /* + * Set it to true to use the X-Forwarded- headers for host name and scheme. + */ + "enableForwardHeaders": true, + + /* + * A list of trusted hosts for redirects. + */ + "trustedHosted": [] }, "fullText": { diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs index 706d49a3b..8bb6f7005 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs @@ -5,10 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; using Xunit; #pragma warning disable RECS0092 // Convert field to readonly @@ -17,7 +15,7 @@ namespace Squidex.Web.Pipeline { public class CleanupHostMiddlewareTests { - private readonly RequestDelegate next; + private readonly CleanupHostMiddleware sut; private bool isNextCalled; public CleanupHostMiddlewareTests() @@ -29,31 +27,48 @@ namespace Squidex.Web.Pipeline return Task.CompletedTask; } - next = Next; + sut = new CleanupHostMiddleware(Next); } - [Theory] - [InlineData("https://cloud.squidex.io", "cloud.squidex.io")] - [InlineData("https://cloud.squidex.io:5000", "cloud.squidex.io:5000")] - [InlineData("http://cloud.squidex.io", "cloud.squidex.io")] - [InlineData("http://cloud.squidex.io:5000", "cloud.squidex.io:5000")] - public async Task Should_override_host_from_urls_options(string baseUrl, string expectedHost) + [Fact] + public async Task Should_cleanup_host_if_https_schema_contains_default_port() { - var uri = new Uri(baseUrl); + var httpContext = new DefaultHttpContext(); + + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("host", 443); + + await sut.InvokeAsync(httpContext); - var options = Options.Create(new UrlsOptions { BaseUrl = baseUrl }); + Assert.Null(httpContext.Request.Host.Port); + Assert.True(isNextCalled); + } - var sut = new CleanupHostMiddleware(next, options); + [Fact] + public async Task Should_cleanup_host_if_http_schema_contains_default_port() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Scheme = "http"; + httpContext.Request.Host = new HostString("host", 80); + + await sut.InvokeAsync(httpContext); + + Assert.Null(httpContext.Request.Host.Port); + Assert.True(isNextCalled); + } + + [Fact] + public async Task Should_not_cleanup_host_if_http_schema_contains_other_port() + { var httpContext = new DefaultHttpContext(); - httpContext.Request.Scheme = uri.Scheme; - httpContext.Request.Host = new HostString(uri.Host, uri.Port); + httpContext.Request.Scheme = "http"; + httpContext.Request.Host = new HostString("host", 8080); await sut.InvokeAsync(httpContext); - Assert.Equal(expectedHost, httpContext.Request.Host.Value); - Assert.Equal(uri.Scheme, httpContext.Request.Scheme); + Assert.Equal(8080, httpContext.Request.Host.Port); Assert.True(isNextCalled); } } diff --git a/backend/tests/Squidex.Web.Tests/UrlsOptionsTests.cs b/backend/tests/Squidex.Web.Tests/UrlsOptionsTests.cs new file mode 100644 index 000000000..8051c202b --- /dev/null +++ b/backend/tests/Squidex.Web.Tests/UrlsOptionsTests.cs @@ -0,0 +1,65 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Xunit; + +namespace Squidex.Web +{ + public sealed class UrlsOptionsTests + { + private readonly UrlsOptions sut = new UrlsOptions + { + BaseUrl = "http://localhost" + }; + + [Theory] + [InlineData("/url")] + [InlineData("/url/")] + [InlineData("url")] + public void Should_build_url_with_leading_slash(string path) + { + var url = sut.BuildUrl(path); + + Assert.Equal("http://localhost/url/", url); + } + + [Theory] + [InlineData("/url")] + [InlineData("/url/")] + [InlineData("url")] + public void Should_build_url_without_leading_slash(string path) + { + var url = sut.BuildUrl(path, false); + + Assert.Equal("http://localhost/url", url); + } + + [Fact] + public void Should_allow_same_host() + { + Assert.True(sut.IsAllowedHost("http://localhost")); + } + + [Fact] + public void Should_allow_https_port() + { + Assert.True(sut.IsAllowedHost("https://localhost")); + } + + [Fact] + public void Should_not_allow_other_host() + { + Assert.False(sut.IsAllowedHost("https://other:5000")); + } + + [Fact] + public void Should_not_allow_same_host_with_other_port() + { + Assert.False(sut.IsAllowedHost("https://localhost:3000")); + } + } +}