diff --git a/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs b/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs index dc9f88095e..c70edc8751 100644 --- a/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs +++ b/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs @@ -59,8 +59,7 @@ public abstract class DataManagementControllerBase DeletePipeline; protected Lazy> GetByIdPipeline; @@ -107,7 +106,7 @@ protected DataManagementControllerBase( SchoolYearContextProvider = schoolYearContextProvider; _restErrorProvider = restErrorProvider; _defaultPageLimitSize = defaultPageSizeLimitProvider.GetDefaultPageSizeLimit(); - _useProxyHeaders = apiSettings.UseReverseProxyHeaders.HasValue && apiSettings.UseReverseProxyHeaders.Value; + _reverseProxySettings = apiSettings.GetReverseProxySettings(); GetByIdPipeline = new Lazy> (pipelineFactory.CreateGetPipeline); @@ -369,9 +368,9 @@ protected string GetResourceUrl() try { var uriBuilder = new UriBuilder( - Request.Scheme(_useProxyHeaders), - Request.Host(_useProxyHeaders), - Request.Port(_useProxyHeaders), + Request.Scheme(this._reverseProxySettings), + Request.Host(this._reverseProxySettings), + Request.Port(this._reverseProxySettings), Request.Path); return uriBuilder.Uri.ToString().TrimEnd('/'); diff --git a/Application/EdFi.Ods.Api/Controllers/VersionController.cs b/Application/EdFi.Ods.Api/Controllers/VersionController.cs index f1899d8573..8187cc1e90 100644 --- a/Application/EdFi.Ods.Api/Controllers/VersionController.cs +++ b/Application/EdFi.Ods.Api/Controllers/VersionController.cs @@ -82,13 +82,13 @@ Dictionary GetUrlsByName() bool isYearSpecific = _apiSettings.GetApiMode().Equals(ApiMode.YearSpecific) || isInstanceYearSpecific; - bool useReverseProxyHeaders = _apiSettings.UseReverseProxyHeaders ?? false; - var urlsByName = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + var rootUrl = Request.RootUrl(_apiSettings.GetReverseProxySettings()); + if (_apiSettings.IsFeatureEnabled(ApiFeature.AggregateDependencies.GetConfigKeyName())) { - urlsByName["dependencies"] = Request.RootUrl(useReverseProxyHeaders) + + urlsByName["dependencies"] = rootUrl + (isInstanceYearSpecific ? $"/metadata/data/v{ApiVersionConstants.Ods}/" + $"{instance}/" + currentYear + "/dependencies" @@ -100,7 +100,7 @@ Dictionary GetUrlsByName() if (_apiSettings.IsFeatureEnabled(ApiFeature.OpenApiMetadata.GetConfigKeyName())) { - urlsByName["openApiMetadata"] = Request.RootUrl(useReverseProxyHeaders) + "/metadata/" + + urlsByName["openApiMetadata"] = rootUrl + "/metadata/" + (isInstanceYearSpecific ? $"{instance}/" : string.Empty) + @@ -109,13 +109,13 @@ Dictionary GetUrlsByName() : string.Empty); } - urlsByName["oauth"] = Request.RootUrl(useReverseProxyHeaders) + + urlsByName["oauth"] = rootUrl + (isInstanceYearSpecific ? $"/{instance}" : string.Empty) + "/oauth/token"; - urlsByName["dataManagementApi"] = Request.RootUrl(useReverseProxyHeaders) + + urlsByName["dataManagementApi"] = rootUrl + $"/data/v{ApiVersionConstants.Ods}/" + (isInstanceYearSpecific ? $"{instance}/" @@ -126,7 +126,7 @@ Dictionary GetUrlsByName() if (_apiSettings.IsFeatureEnabled(ApiFeature.XsdMetadata.GetConfigKeyName())) { - urlsByName["xsdMetadata"] = Request.RootUrl(useReverseProxyHeaders) + "/metadata/" + + urlsByName["xsdMetadata"] = rootUrl + "/metadata/" + (isInstanceYearSpecific ? $"{instance}/" : string.Empty) + diff --git a/Application/EdFi.Ods.Api/Extensions/HttpRequestExtensions.cs b/Application/EdFi.Ods.Api/Extensions/HttpRequestExtensions.cs index d00e6181c6..b04a679994 100644 --- a/Application/EdFi.Ods.Api/Extensions/HttpRequestExtensions.cs +++ b/Application/EdFi.Ods.Api/Extensions/HttpRequestExtensions.cs @@ -7,7 +7,7 @@ using System.Linq; using EdFi.Common.Extensions; using EdFi.Ods.Api.Constants; -using EdFi.Ods.Common.Extensions; +using EdFi.Ods.Common.Configuration; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -15,22 +15,22 @@ namespace EdFi.Ods.Api.Extensions { public static class HttpRequestExtensions { - public static string RootUrl(this HttpRequest request, bool useProxyHeaders = false) + public static string RootUrl(this HttpRequest request, ReverseProxySettings reverseProxySettings) { var uriBuilder = new UriBuilder( - request.Scheme(useProxyHeaders), - request.Host(useProxyHeaders), - request.Port(useProxyHeaders), + request.Scheme(reverseProxySettings), + request.Host(reverseProxySettings), + request.Port(reverseProxySettings), request.PathBase); return uriBuilder.Uri.AbsoluteUri.TrimEnd('/'); } - public static string Scheme(this HttpRequest request, bool useProxyHeaders = false) + public static string Scheme(this HttpRequest request, ReverseProxySettings reverseProxySettings) { string scheme = request.Scheme; - if (!useProxyHeaders) + if (!reverseProxySettings.UseReverseProxyHeaders) { return scheme; } @@ -43,45 +43,73 @@ public static string Scheme(this HttpRequest request, bool useProxyHeaders = fal scheme = proxyHeaderValue; } - return scheme; + // The x-forwarded-proto header can contain a single originating protocol or, in some + // cases, multiple protocols. See ODS-5481 for more information. We care about the + // _first_ protocol listed. + if (scheme == null) { return "http"; } + + return scheme.Split(',')[0]; } - public static string Host(this HttpRequest request, bool useProxyHeaders = false) + public static string Host(this HttpRequest request, ReverseProxySettings reverseProxySettings) { - string host = request.Host.Host; - - if (!useProxyHeaders) + // Use actual request host when not configured for use behind a reverse proxy + if (!reverseProxySettings.UseReverseProxyHeaders) { - return host; + return request.Host.Host; } - request.TryGetRequestHeader(HeaderConstants.XForwardedHost, out string proxyHeaderValue); + // Use override value if available + if (!string.IsNullOrWhiteSpace(reverseProxySettings.OverrideForForwardingHostServer)) + { + return reverseProxySettings.OverrideForForwardingHostServer; + } - // Pass through for any value provided, null means header wasn't found so default to request - if (proxyHeaderValue != null) + // Try to extract a X-Forwarded-Host value + if (request.TryGetRequestHeader(HeaderConstants.XForwardedHost, out string proxyHeaderValue) && + !string.IsNullOrWhiteSpace(proxyHeaderValue)) { - host = proxyHeaderValue; + // Return the forwarding host + return proxyHeaderValue; } - return host; + return request.Host.Host; } - public static int Port(this HttpRequest request, bool useProxyHeaders = false) + public static int Port(this HttpRequest request, ReverseProxySettings reverseProxySettings) { - var defaultPortForScheme = Scheme(request, useProxyHeaders) == "https" - ? 443 - : 80; + // User actual request host when not configured for use behind a reverse proxy + if (!reverseProxySettings.UseReverseProxyHeaders) + { + return request.Host.Port ?? getDefaultPort(); + } - if (!useProxyHeaders) + // Use override value if available + if (reverseProxySettings.OverrideForForwardingHostPort.HasValue) { - return request.Host.Port ?? defaultPortForScheme; + return reverseProxySettings.OverrideForForwardingHostPort.Value; } - request.TryGetRequestHeader(HeaderConstants.XForwardedPort, out string proxyHeaderValue); + // Try to extract a X-Forwarded-Port value + if (request.TryGetRequestHeader(HeaderConstants.XForwardedPort, out string proxyHeaderValue) && + int.TryParse(proxyHeaderValue, out int port)) + { + // Return the forwarding port + return port; + } + + // Try to send the requested port + if (request.Host.Port.HasValue) + { + return request.Host.Port.Value; + } + + return getDefaultPort(); - return !int.TryParse(proxyHeaderValue, out int port) - ? request.Host.Port ?? defaultPortForScheme - : port; + int getDefaultPort() + { + return Scheme(request, reverseProxySettings) == "https" ? 443 : 80; + } } public static string VirtualPath(this HttpRequest request) @@ -103,4 +131,4 @@ public static bool TryGetRequestHeader(this HttpRequest request, string headerNa return !string.IsNullOrEmpty(value); } } -} \ No newline at end of file +} diff --git a/Application/EdFi.Ods.Common/Configuration/ApiSettings.cs b/Application/EdFi.Ods.Common/Configuration/ApiSettings.cs index 820cf57e9c..57e16bf725 100644 --- a/Application/EdFi.Ods.Common/Configuration/ApiSettings.cs +++ b/Application/EdFi.Ods.Common/Configuration/ApiSettings.cs @@ -48,6 +48,15 @@ public ApiSettings() public bool? UseReverseProxyHeaders { get; set; } + public string OverrideForForwardingHostServer { get; set; } + + public int? OverrideForForwardingHostPort { get; set; } + + public ReverseProxySettings GetReverseProxySettings() + { + return new ReverseProxySettings(this.UseReverseProxyHeaders, this.OverrideForForwardingHostServer, this.OverrideForForwardingHostPort); + } + public string PathBase { get; set; } public DatabaseEngine GetDatabaseEngine() => _databaseEngine.Value; diff --git a/Application/EdFi.Ods.Common/Configuration/ReverseProxySettings.cs b/Application/EdFi.Ods.Common/Configuration/ReverseProxySettings.cs new file mode 100644 index 0000000000..860cddf16d --- /dev/null +++ b/Application/EdFi.Ods.Common/Configuration/ReverseProxySettings.cs @@ -0,0 +1,19 @@ + +namespace EdFi.Ods.Common.Configuration +{ + public class ReverseProxySettings + { + public bool UseReverseProxyHeaders { get; private set; } + + public string OverrideForForwardingHostServer { get; private set; } + + public int? OverrideForForwardingHostPort { get; private set; } + + public ReverseProxySettings(bool? useReverseProxyHeaders, string overrideForForwardingHostServer, int? overrideForForwardingHostPort) + { + this.UseReverseProxyHeaders = useReverseProxyHeaders ?? false; + this.OverrideForForwardingHostPort = overrideForForwardingHostPort; + this.OverrideForForwardingHostServer = overrideForForwardingHostServer; + } + } +} diff --git a/Application/EdFi.Ods.Features/Controllers/OpenApiMetadataController.cs b/Application/EdFi.Ods.Features/Controllers/OpenApiMetadataController.cs index ff18bd2f2e..adfcc8c3db 100644 --- a/Application/EdFi.Ods.Features/Controllers/OpenApiMetadataController.cs +++ b/Application/EdFi.Ods.Features/Controllers/OpenApiMetadataController.cs @@ -34,14 +34,14 @@ public class OpenApiMetadataController : ControllerBase private readonly ILog _logger = LogManager.GetLogger(typeof(OpenApiMetadataController)); private readonly IOpenApiMetadataCacheProvider _openApiMetadataCacheProvider; - private readonly bool _useProxyHeaders; + private readonly ReverseProxySettings _reverseProxySettings; public OpenApiMetadataController( IOpenApiMetadataCacheProvider openApiMetadataCacheProvider, ApiSettings apiSettings) { _openApiMetadataCacheProvider = openApiMetadataCacheProvider; - _useProxyHeaders = apiSettings.UseReverseProxyHeaders.HasValue && apiSettings.UseReverseProxyHeaders.Value; + _reverseProxySettings = apiSettings.GetReverseProxySettings(); _isEnabled = apiSettings.IsFeatureEnabled(ApiFeature.OpenApiMetadata.GetConfigKeyName()); } @@ -86,11 +86,13 @@ public IActionResult Get([FromRoute] OpenApiMetadataSectionRequest request) OpenApiMetadataSectionDetails GetSwaggerSectionDetailsForCacheItem(OpenApiContent apiContent) { + var rootUrl = Request.RootUrl(this._reverseProxySettings); + // Construct fully qualified metadata url var url = new Uri( new Uri( - new Uri(Request.RootUrl(_useProxyHeaders).EnsureSuffixApplied("/")), + new Uri(rootUrl.EnsureSuffixApplied("/")), "metadata/"), GetMetadataUrlSegmentForCacheItem(apiContent, request.SchoolYearFromRoute, request.InstanceIdFromRoute)); diff --git a/Application/EdFi.Ods.Features/OpenApiMetadata/Providers/EnabledOpenApiMetadataDocumentProvider.cs b/Application/EdFi.Ods.Features/OpenApiMetadata/Providers/EnabledOpenApiMetadataDocumentProvider.cs index d26b2db1a0..d96422c48b 100644 --- a/Application/EdFi.Ods.Features/OpenApiMetadata/Providers/EnabledOpenApiMetadataDocumentProvider.cs +++ b/Application/EdFi.Ods.Features/OpenApiMetadata/Providers/EnabledOpenApiMetadataDocumentProvider.cs @@ -27,7 +27,7 @@ public class EnabledOpenApiMetadataDocumentProvider : IOpenApiMetadataDocumentPr private readonly IOpenApiMetadataCacheProvider _openApiMetadataCacheProvider; private readonly IList _routeInformations; - private readonly bool _useReverseProxyHeaders; + private readonly ReverseProxySettings _reverseProxySettings; private readonly Lazy> _schemaNameMaps; public EnabledOpenApiMetadataDocumentProvider( @@ -38,9 +38,10 @@ public EnabledOpenApiMetadataDocumentProvider( { _openApiMetadataCacheProvider = openApiMetadataCacheProvider; _routeInformations = routeInformations; - _useReverseProxyHeaders = apiSettings.UseReverseProxyHeaders.HasValue && apiSettings.UseReverseProxyHeaders.Value; + this._reverseProxySettings = apiSettings.GetReverseProxySettings(); _schemaNameMaps = new Lazy>(schemaNameMapProvider.GetSchemaNameMaps); + } public bool TryGetSwaggerDocument(HttpRequest request, out string document) @@ -76,11 +77,14 @@ private string GetMetadataForContent(OpenApiContent content, HttpRequest request .Replace("%HOST%", Host()) .Replace("%TOKEN_URL%", TokenUrl()) .Replace("%BASE_PATH%", basePath) - .Replace("%SCHEME%", request.Scheme(_useReverseProxyHeaders)); + .Replace("%SCHEME%", request.Scheme(this._reverseProxySettings)); - string TokenUrl() => $"{request.RootUrl(_useReverseProxyHeaders)}/{instanceId}oauth/token"; + string TokenUrl() { + var rootUrl = request.RootUrl(this._reverseProxySettings); + return $"{rootUrl}/{instanceId}oauth/token"; + } - string Host() => $"{request.Host(_useReverseProxyHeaders)}:{request.Port(_useReverseProxyHeaders)}"; + string Host() => $"{request.Host(this._reverseProxySettings)}:{request.Port(this._reverseProxySettings)}"; } private OpenApiMetadataRequest CreateOpenApiMetadataRequest(string path) diff --git a/Application/EdFi.Ods.Features/XsdMetadata/XsdMetadataController.cs b/Application/EdFi.Ods.Features/XsdMetadata/XsdMetadataController.cs index 4fc27dad16..6d259733f1 100644 --- a/Application/EdFi.Ods.Features/XsdMetadata/XsdMetadataController.cs +++ b/Application/EdFi.Ods.Features/XsdMetadata/XsdMetadataController.cs @@ -113,9 +113,8 @@ private string GetMetadataAbsoluteUrl(string schemaFile, string uriSegment) bool isYearSpecific = _apiSettings.GetApiMode().Equals(ApiMode.YearSpecific) || isInstanceYearSpecific; - bool useReverseProxyHeaders = _apiSettings.UseReverseProxyHeaders ?? false; - - string basicPath = Request.RootUrl(useReverseProxyHeaders) + "/metadata/" + + string basicPath = Request.RootUrl(_apiSettings.GetReverseProxySettings()) + + "/metadata/" + (isInstanceYearSpecific ? $"{_instanceIdContextProvider.GetInstanceId()}/" : string.Empty) + diff --git a/Application/EdFi.Ods.Tests/EdFi.Ods.Api/Extensions/HttpsRequestExtensionsTests.cs b/Application/EdFi.Ods.Tests/EdFi.Ods.Api/Extensions/HttpsRequestExtensionsTests.cs new file mode 100644 index 0000000000..9596a99f40 --- /dev/null +++ b/Application/EdFi.Ods.Tests/EdFi.Ods.Api/Extensions/HttpsRequestExtensionsTests.cs @@ -0,0 +1,326 @@ +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using FakeItEasy; +using EdFi.Ods.Api.Extensions; +using Shouldly; +using System.Collections.Generic; +using Microsoft.Extensions.Primitives; +using EdFi.Ods.Common.Configuration; + +namespace EdFi.Ods.Tests.EdFi.Ods.Api.Extensions +{ + public class HttpsRequestExtensionsTests + { + [TestFixture] + public class When_extracting_proxy_forwarded_scheme_from_an_http_request + { + + [TestFixture] + public class Given_proxy_headers_are_not_being_enforced + { + [TestCase("https")] + [TestCase("http")] + public void Then_it_returns_the_request_scheme(string requestProtocol) + { + // Arrange + var httpRequest = A.Fake(); + A.CallTo(() => httpRequest.Scheme).Returns(requestProtocol); + + // Act + var result = httpRequest.Scheme(new ReverseProxySettings(false, null, null)); + + // Assert + result.ShouldBe(requestProtocol); + } + + } + + + [TestFixture] + public class Given_proxy_headers_are_being_enforced + { + [TestCase("https", "http", "http")] + [TestCase("http", "http", "http")] + [TestCase("https", "https", "https")] + [TestCase("http", "https", "https")] + [TestCase("https", "https, http", "https")] + [TestCase("http", "https, https", "https")] + public void Then_it_returns_the_originating_xforwardedprotovalue_regardless_of_request_scheme_value( + string requestProtocol, + string forwardedProtocol, + string expectedProtocol + ) + { + // Arrange + var httpRequest = A.Fake(); + A.CallTo(() => httpRequest.Scheme).Returns(requestProtocol); + A.CallTo(() => httpRequest.Headers).Returns(new HeaderDictionary(new Dictionary + { + { "X-Forwarded-Proto", new StringValues(forwardedProtocol) } + } + )); + + // Act + var result = httpRequest.Scheme(new ReverseProxySettings(true, "localhost", 80)); + + // Assert + result.ShouldBe(expectedProtocol); + } + } + } + + [TestFixture] + public class When_extracting_proxy_forwarded_host_from_an_http_request + { + + [TestFixture] + public class Given_proxy_headers_are_not_being_enforced + { + [Test] + public void Then_it_returns_the_request_host() + { + const string requestHost = "myserver"; + + // Arrange + var httpRequest = A.Fake(); + A.CallTo(() => httpRequest.Host).Returns(new HostString(requestHost)); + + // Act + var result = httpRequest.Host(new ReverseProxySettings(false, null, null)); + + // Assert + result.ShouldBe(requestHost); + } + + } + + [TestFixture] + public class Given_proxy_headers_are_being_enforced + { + [TestFixture] + public class And_given_override_value_exists + { + [TestCase("forwardedHost")] + [TestCase("")] + [TestCase(null)] + public void Then_always_use_the_override(string xForwardedHostValue) + { + const string requestHost = "myserver"; + const string overrideForHost = "workstation"; + + // Arrange + var httpRequest = A.Fake(); + A.CallTo(() => httpRequest.Host).Returns(new HostString(requestHost)); + + if (xForwardedHostValue != null) + { + A.CallTo(() => httpRequest.Headers).Returns(new HeaderDictionary(new Dictionary + { + { "X-Forwarded-Host", new StringValues(xForwardedHostValue) } + } + )); + } + + // Act + var result = httpRequest.Host(new ReverseProxySettings(true, overrideForHost, null)); + + // Assert + result.ShouldBe(overrideForHost); + } + } + + [TestFixture] + public class And_given_override_was_not_set + { + [TestFixture] + public class And_given_forwarding_host_was_sent + { + [Test] + public void Then_use_the_forwarding_host() + { + const string requestHost = "myserver"; + const string xForwardedHostValue = "forwardedServer"; + const string overrideForHost = null; + + // Arrange + var httpRequest = A.Fake(); + A.CallTo(() => httpRequest.Host).Returns(new HostString(requestHost)); + A.CallTo(() => httpRequest.Headers).Returns(new HeaderDictionary(new Dictionary + { + { "X-Forwarded-Host", new StringValues(xForwardedHostValue) } + } + )); + + // Act + var result = httpRequest.Host(new ReverseProxySettings(true, overrideForHost, null)); + + // Assert + result.ShouldBe(xForwardedHostValue); + + } + } + + [TestFixture] + public class And_given_forwarding_host_is_not_sent + { + [TestCase("")] + [TestCase(null)] + public void Then_use_the_request_host(string xForwardedHostValue) + { + const string requestHost = "myserver"; + const string overrideForHost = null; + + // Arrange + var httpRequest = A.Fake(); + A.CallTo(() => httpRequest.Host).Returns(new HostString(requestHost)); + + if (xForwardedHostValue != null) + { + A.CallTo(() => httpRequest.Headers).Returns(new HeaderDictionary(new Dictionary + { + { "X-Forwarded-Host", new StringValues(xForwardedHostValue) } + } + )); + } + + // Act + var result = httpRequest.Host(new ReverseProxySettings(true, overrideForHost, null)); + + // Assert + result.ShouldBe(requestHost); + + } + } + } + } + } + + [TestFixture] + public class When_extracting_proxy_forwarded_port_from_an_http_request + { + + [TestFixture] + public class Given_proxy_headers_are_not_being_enforced + { + [Test] + public void Then_it_returns_the_request_host() + { + const string requestHost = "myserver"; + const int requestPort = 554; + + // Arrange + var httpRequest = A.Fake(); + A.CallTo(() => httpRequest.Host).Returns(new HostString(requestHost, requestPort)); + + // Act + var result = httpRequest.Port(new ReverseProxySettings(false, null, null)); + + // Assert + result.ShouldBe(requestPort); + } + + } + + [TestFixture] + public class Given_proxy_headers_are_being_enforced + { + [TestFixture] + public class And_given_override_value_exists + { + [TestCase("999")] + [TestCase("")] + [TestCase(null)] + public void Then_always_use_the_override(string xForwardedPortValue) + { + const string requestHost = "myserver"; + const int overrideForPort = 8983; + + // Arrange + var httpRequest = A.Fake(); + A.CallTo(() => httpRequest.Host).Returns(new HostString(requestHost)); + + if (xForwardedPortValue != null) + { + A.CallTo(() => httpRequest.Headers).Returns(new HeaderDictionary(new Dictionary + { + { "X-Forwarded-Port", new StringValues(xForwardedPortValue) } + } + )); + } + + // Act + var result = httpRequest.Port(new ReverseProxySettings(true, null, overrideForPort)); + + // Assert + result.ShouldBe(overrideForPort); + } + } + + [TestFixture] + public class And_given_override_was_not_set + { + [TestFixture] + public class And_given_forwarding_port_was_sent + { + [Test] + public void Then_use_the_forwarding_port() + { + const string requestHost = "myserver"; + const int xForwardedPortValue = 9876; + + // Arrange + var httpRequest = A.Fake(); + A.CallTo(() => httpRequest.Host).Returns(new HostString(requestHost)); + A.CallTo(() => httpRequest.Headers).Returns(new HeaderDictionary(new Dictionary + { + { "X-Forwarded-Port", new StringValues(xForwardedPortValue.ToString()) } + } + )); + + // Act + var result = httpRequest.Port(new ReverseProxySettings(true, null, null)); + + // Assert + result.ShouldBe(xForwardedPortValue); + + } + } + + [TestFixture] + public class And_given_forwarding_port_is_not_sent + { + [TestCase(true, "https", 443)] + [TestCase(true, "http", 80)] + [TestCase(false, "https", 443)] + [TestCase(false, "http", 80)] + public void Then_use_the_scheme_to_determine_port(bool blankHeaderWasSent, string scheme, int expectedPort) + { + const string requestHost = "myserver"; + + // Arrange + var httpRequest = A.Fake(); + A.CallTo(() => httpRequest.Scheme).Returns(scheme); + A.CallTo(() => httpRequest.Host).Returns(new HostString(requestHost)); + + if (blankHeaderWasSent) + { + A.CallTo(() => httpRequest.Headers).Returns(new HeaderDictionary(new Dictionary + { + { "X-Forwarded-Port", new StringValues(" ") } + } + )); + } + + // Act + var result = httpRequest.Port(new ReverseProxySettings(true, null, null)); + + // Assert + result.ShouldBe(expectedPort); + + } + } + } + } + } + } +} diff --git a/Application/EdFi.Ods.Tests/EdFi.Ods.Features/Controllers/OpenApiMetadataControllerTests.cs b/Application/EdFi.Ods.Tests/EdFi.Ods.Features/Controllers/OpenApiMetadataControllerTests.cs index acdaea44ba..87339065f4 100644 --- a/Application/EdFi.Ods.Tests/EdFi.Ods.Features/Controllers/OpenApiMetadataControllerTests.cs +++ b/Application/EdFi.Ods.Tests/EdFi.Ods.Features/Controllers/OpenApiMetadataControllerTests.cs @@ -117,6 +117,9 @@ protected override void Arrange() { var configValueProvider = new ApiSettings(); configValueProvider.UseReverseProxyHeaders = true; + configValueProvider.OverrideForForwardingHostPort = 80; + configValueProvider.OverrideForForwardingHostServer = "localhost"; + Feature item = new Feature(); item.IsEnabled = true; item.Name = "openApiMetadata"; @@ -189,7 +192,7 @@ public void Should_return_valid_http_response_message() Assert.IsNotNull(response); Assert.IsTrue(openapisectionlist.Count > 0); Assert.AreEqual("Identity", openapisectionlist[0].Name); - Assert.IsTrue(openapisectionlist[0].EndpointUri.Contains("api.com")); + Assert.IsTrue(openapisectionlist[0].EndpointUri.Contains("localhost")); Assert.IsTrue(openapisectionlist[0].EndpointUri.Contains("https")); Assert.IsTrue(openapisectionlist[0].EndpointUri.Contains("metadata")); Assert.IsTrue(openapisectionlist[0].EndpointUri.Contains("2020")); @@ -205,8 +208,13 @@ public class When_calling_the_metadata_controller_with_use_reverse_proxy_and_no_ protected override void Arrange() { - var configValueProvider = new ApiSettings(); - configValueProvider.UseReverseProxyHeaders = true; + var configValueProvider = new ApiSettings + { + UseReverseProxyHeaders = true, + OverrideForForwardingHostPort = 80, + OverrideForForwardingHostServer = "localhost" + }; + Feature item = new Feature(); item.IsEnabled = true; item.Name = "openApiMetadata"; diff --git a/Postman Test Suite/ManualTest/Ed-Fi ODS-API Integration Test Proxy Header Tests.postman_collection.json b/Postman Test Suite/ManualTest/Ed-Fi ODS-API Integration Test Proxy Header Tests.postman_collection.json new file mode 100644 index 0000000000..ccbb16041b --- /dev/null +++ b/Postman Test Suite/ManualTest/Ed-Fi ODS-API Integration Test Proxy Header Tests.postman_collection.json @@ -0,0 +1,389 @@ +{ + "info": { + "_postman_id": "a9ff6762-df35-4191-a9ce-98934a86be0d", + "name": "Ed-Fi ODS/API Integration Test Suite Proxy Header Tests", + "description": "This set of tests validates aspects of reverse proxy forwarding. Before running them, ensure that the following value is set in the appsettings file:\n\n```\n\"ApiSettings\": {\n \"UseReverseProxyHeaders\": true\n }\n```", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "X-Forwarded-Proto", + "item": [ + { + "name": "Accepts \"https, http\"", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "X-Forwarded-Proto", + "value": "https, http", + "type": "default" + }, + { + "key": "X-Forwarded-Host", + "value": "whatever", + "type": "default" + }, + { + "key": "X-Forwarded-Port", + "value": "443", + "type": "default" + }, + { + "key": "X-Forwarded-For", + "value": "localhost", + "type": "default" + } + ], + "url": { + "raw": "{{ApiBaseUrl}}", + "host": [ + "{{ApiBaseUrl}}" + ] + } + }, + "response": [] + }, + { + "name": "Falls back to actual protocol", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{ApiBaseUrl}}", + "host": [ + "{{ApiBaseUrl}}" + ] + } + }, + "response": [] + }, + { + "name": "Accepts \"https\"", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", () => {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "X-Forwarded-Proto", + "value": "https", + "type": "default" + }, + { + "key": "X-Forwarded-Host", + "value": "whatever", + "type": "default" + }, + { + "key": "X-Forwarded-Port", + "value": "443", + "type": "default" + }, + { + "key": "X-Forwarded-For", + "value": "localhost", + "type": "default" + } + ], + "url": { + "raw": "{{ApiBaseUrl}}", + "host": [ + "{{ApiBaseUrl}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "X-Forwarded-Host", + "item": [ + { + "name": "Uses Forward Host in URLs", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", () => {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "pm.test(\"Uses the X-Forwarded-Host name in the URLs\", () => {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.urls.dependencies).to.eql(\"https://whatever/metadata/data/v3/dependencies\");\r", + "})" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "X-Forwarded-Proto", + "value": "https, http", + "type": "default" + }, + { + "key": "X-Forwarded-Host", + "value": "whatever", + "type": "default" + }, + { + "key": "X-Forwarded-Port", + "value": "443", + "type": "default" + }, + { + "key": "X-Forwarded-For", + "value": "localhost", + "type": "default" + } + ], + "url": { + "raw": "{{ApiBaseUrl}}", + "host": [ + "{{ApiBaseUrl}}" + ] + } + }, + "response": [] + }, + { + "name": "Falls back to configured host in URLs", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", () => {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "pm.test(\"Uses the X-Forwarded-Host name in the URLs\", () => {\r", + " let jsonData = pm.response.json();\r", + "\r", + " const baseUrl = pm.environment.get(\"ApiSettingsHost\");\r", + " pm.expect(jsonData.urls.dependencies).to.eql(`https://${baseUrl}/metadata/data/v3/dependencies`);\r", + "})" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "X-Forwarded-Proto", + "value": "https", + "type": "default" + }, + { + "key": "X-Forwarded-Port", + "value": "{{ApiPort}}", + "type": "default" + }, + { + "key": "X-Forwarded-For", + "value": "{{ApiServer}}", + "type": "default" + } + ], + "url": { + "raw": "{{ApiBaseUrl}}", + "host": [ + "{{ApiBaseUrl}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "X-Forwarded-Port", + "item": [ + { + "name": "Uses Forward Port in URLs", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", () => {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "pm.test(\"Uses the X-Forwarded-Host name in the URLs\", () => {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.urls.dependencies).to.eql(\"http://whatever:589/metadata/data/v3/dependencies\");\r", + "})" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "X-Forwarded-Port", + "value": "589", + "type": "default" + }, + { + "key": "X-Forwarded-Host", + "value": "whatever", + "type": "default" + } + ], + "url": { + "raw": "{{ApiBaseUrl}}", + "host": [ + "{{ApiBaseUrl}}" + ] + } + }, + "response": [] + }, + { + "name": "Falls Back to configured port in URls", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", () => {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "pm.test(\"Uses the X-Forwarded-Host name in the URLs\", () => {\r", + " var jsonData = pm.response.json();\r", + "\r", + " const apiPort = pm.environment.get(\"ApiPort\");\r", + " pm.expect(jsonData.urls.dependencies).to.eql(`http://whatever:${apiPort}/metadata/data/v3/dependencies`);\r", + "})" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "X-Forwarded-Host", + "value": "whatever", + "type": "default" + }, + { + "key": "X-Forwarded-For", + "value": "localhost", + "type": "default" + } + ], + "url": { + "raw": "{{ApiBaseUrl}}", + "host": [ + "{{ApiBaseUrl}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Uses X-Forwarded-XYZ headers to build URLs", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const baseUrl = \"bogus://myserver:9876\";\r", + "\r", + "pm.test(\"Status code is 200\", () => {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "pm.test(\"Uses the X-Forwarded-Host name in the URLs\", () => {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.urls.dependencies).to.eql(`${baseUrl}/metadata/data/v3/dependencies`);\r", + "})" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "X-Forwarded-Proto", + "value": "bogus", + "type": "text" + }, + { + "key": "X-Forwarded-Host", + "value": "myserver", + "type": "text" + }, + { + "key": "X-Forwarded-Port", + "value": "9876", + "type": "text" + }, + { + "key": "X-Forwarded-For", + "value": "localhost", + "type": "text" + } + ], + "url": { + "raw": "{{ApiBaseUrl}}", + "host": [ + "{{ApiBaseUrl}}" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/Postman Test Suite/ManualTest/RunningProxyHeaderTests.md b/Postman Test Suite/ManualTest/RunningProxyHeaderTests.md new file mode 100644 index 0000000000..0901b2f88c --- /dev/null +++ b/Postman Test Suite/ManualTest/RunningProxyHeaderTests.md @@ -0,0 +1,23 @@ +# Running the Proxy Header Tests + +Before starting the ODS/API, set these values in `appsettings.development.json`: + +```json + "ApiSettings": { + "UseReverseProxyHeaders": true, + "DefaultForwardingHostServer": "chooseYourOwnAdventure", + "DefaultForwardingHostPort": "54746" + }, +``` + +In your Postman environment, be sure setup the following variables (or change to +test in an environment other than localhost): + +```json +ApiServer = localhost +ApiPort = 54746 +ApiScheme = http +ApiBaseUrl = http://localhost:54746 +ApiSettingsHost = chooseYourOwnAdventure:54746 +``` +