Browse Source

Forwarding improvements.

pull/593/head
Sebastian 5 years ago
parent
commit
5f32656677
  1. 34
      backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs
  2. 100
      backend/src/Squidex.Web/UrlsOptions.cs
  3. 45
      backend/src/Squidex/Config/Web/WebExtensions.cs
  4. 25
      backend/src/Squidex/Config/Web/WebServices.cs
  5. 27
      backend/src/Squidex/appsettings.json
  6. 49
      backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs
  7. 65
      backend/tests/Squidex.Web.Tests/UrlsOptionsTests.cs

34
backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs

@ -8,50 +8,38 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace Squidex.Web.Pipeline namespace Squidex.Web.Pipeline
{ {
public class CleanupHostMiddleware public class CleanupHostMiddleware
{ {
private readonly RequestDelegate next; private readonly RequestDelegate next;
private readonly HostString host;
private readonly string schema;
public CleanupHostMiddleware(RequestDelegate next, IOptions<UrlsOptions> options) public CleanupHostMiddleware(RequestDelegate next)
{ {
this.next = 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) public Task InvokeAsync(HttpContext context)
{ {
context.Request.Host = host; var request = context.Request;
context.Request.Scheme = schema;
if (request.Host.HasValue && (HasHttpsPort(request) || HasHttpPort(request)))
{
request.Host = new HostString(request.Host.Host);
}
return next(context); 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;
} }
} }
} }

100
backend/src/Squidex.Web/UrlsOptions.cs

@ -7,17 +7,27 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using Microsoft.AspNetCore.Http;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Web namespace Squidex.Web
{ {
public sealed class UrlsOptions public sealed class UrlsOptions
{ {
private readonly HashSet<string> allTrustedHosts = new HashSet<string>(StringComparer.OrdinalIgnoreCase); private readonly HashSet<HostString> allTrustedHosts = new HashSet<HostString>();
private string baseUrl; private string baseUrl;
private string[] trustedHosts; 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 public string BaseUrl
{ {
get get
@ -26,9 +36,9 @@ namespace Squidex.Web
} }
set 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; baseUrl = value;
@ -43,9 +53,15 @@ namespace Squidex.Web
} }
set 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; trustedHosts = value;
@ -54,12 +70,22 @@ namespace Squidex.Web
public bool IsAllowedHost(string? url) 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) 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) public string BuildUrl(string path, bool trailingSlash = true)
@ -71,5 +97,63 @@ namespace Squidex.Web
return BaseUrl.BuildFullUrl(path, trailingSlash); 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);
}
}
} }
} }

45
backend/src/Squidex/Config/Web/WebExtensions.cs

@ -8,13 +8,16 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
using Squidex.Pipeline.Robots; using Squidex.Pipeline.Robots;
@ -130,7 +133,49 @@ namespace Squidex.Config.Web
public static void UseSquidexForwardingRules(this IApplicationBuilder app, IConfiguration config) public static void UseSquidexForwardingRules(this IApplicationBuilder app, IConfiguration config)
{ {
var urlsOptions = app.ApplicationServices.GetRequiredService<IOptions<UrlsOptions>>().Value;
if (urlsOptions.EnableForwardHeaders)
{
var options = new ForwardedHeadersOptions
{
AllowedHosts = new List<string>
{
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<CleanupHostMiddleware>(); app.UseMiddleware<CleanupHostMiddleware>();
if (urlsOptions.EnforceHost)
{
app.UseHostFiltering();
}
if (urlsOptions.EnforceHTTPS)
{
app.UseHttpsRedirection();
}
} }
} }
} }

25
backend/src/Squidex/Config/Web/WebServices.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Infrastructure;
@ -100,6 +102,29 @@ namespace Squidex.Config.Web
.AddRazorRuntimeCompilation() .AddRazorRuntimeCompilation()
.AddSquidexPlugins(config) .AddSquidexPlugins(config)
.AddSquidexSerializers(); .AddSquidexSerializers();
var urlsOptions = config.GetSection("urls").Get<UrlsOptions>();
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;
});
}
} }
} }
} }

27
backend/src/Squidex/appsettings.json

@ -10,7 +10,32 @@
/* /*
* Set the base url of your application, to generate correct urls in background process. * 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": { "fullText": {

49
backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs

@ -5,10 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Xunit; using Xunit;
#pragma warning disable RECS0092 // Convert field to readonly #pragma warning disable RECS0092 // Convert field to readonly
@ -17,7 +15,7 @@ namespace Squidex.Web.Pipeline
{ {
public class CleanupHostMiddlewareTests public class CleanupHostMiddlewareTests
{ {
private readonly RequestDelegate next; private readonly CleanupHostMiddleware sut;
private bool isNextCalled; private bool isNextCalled;
public CleanupHostMiddlewareTests() public CleanupHostMiddlewareTests()
@ -29,31 +27,48 @@ namespace Squidex.Web.Pipeline
return Task.CompletedTask; return Task.CompletedTask;
} }
next = Next; sut = new CleanupHostMiddleware(Next);
} }
[Theory] [Fact]
[InlineData("https://cloud.squidex.io", "cloud.squidex.io")] public async Task Should_cleanup_host_if_https_schema_contains_default_port()
[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)
{ {
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(); var httpContext = new DefaultHttpContext();
httpContext.Request.Scheme = uri.Scheme; httpContext.Request.Scheme = "http";
httpContext.Request.Host = new HostString(uri.Host, uri.Port); httpContext.Request.Host = new HostString("host", 8080);
await sut.InvokeAsync(httpContext); await sut.InvokeAsync(httpContext);
Assert.Equal(expectedHost, httpContext.Request.Host.Value); Assert.Equal(8080, httpContext.Request.Host.Port);
Assert.Equal(uri.Scheme, httpContext.Request.Scheme);
Assert.True(isNextCalled); Assert.True(isNextCalled);
} }
} }

65
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"));
}
}
}
Loading…
Cancel
Save