diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc b/nifi-docs/src/main/asciidoc/administration-guide.adoc index 0ef958ee6126..d5e4234bc39f 100644 --- a/nifi-docs/src/main/asciidoc/administration-guide.adoc +++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc @@ -2758,10 +2758,16 @@ documentation of the proxy for guidance for your deployment environment and use * Additional NiFi proxy configuration must be updated to allow expected Host and context paths HTTP headers. -** By default, if NiFi is running securely it will only accept HTTP requests with a Host header matching the host[:port] that it is bound to. If NiFi is to accept requests directed to a different -host[:port] the expected values need to be configured. This may be required when running behind a proxy or in a containerized environment. This is configured in a comma -separated list in _nifi.properties_ using the `nifi.web.proxy.host` property (e.g. `localhost:18443, proxyhost:443`). IPv6 addresses are accepted. Please refer to -RFC 5952 Sections link:https://tools.ietf.org/html/rfc5952#section-4[4] and link:https://tools.ietf.org/html/rfc5952#section-6[6] for additional details. +** When configured with HTTPS enabled, the name in the `Host` header must match one of the DNS Subject Alternative Names +included on the configured server X.509 certificate. The framework server also validates the name provided in the Server +Name Indication extension during the TLS handshake against the configured server certificate. The server returns an HTTP +400 Bad Request status when the requested `Host` header does not meet these requirements. + +** The application supports providing alternative host addresses through either the `X-ProxyHost` or `X-Forwarded-Host` +headers. The host address provided in one of these headers must be configured in the `nifi.web.proxy.host` property. +The application uses the provided alternative host address to construct URLs that match the public address of the +reverse proxy. The server returns an HTTP 421 Misdirected Request status when the requested proxy header value is not +listed in the proxy host address property. ** NiFi will only accept HTTP requests with a X-ProxyContextPath, X-Forwarded-Context, or X-Forwarded-Prefix header if the value is allowed in the `nifi.web.proxy.context.path` property in _nifi.properties_. This property accepts a comma separated list of expected values. In the event an incoming request has an X-ProxyContextPath, X-Forwarded-Context, or X-Forwarded-Prefix header value that is not @@ -3520,10 +3526,16 @@ The value can be set to `h2` to require HTTP/2 and disable HTTP/1.1. |`nifi.web.jetty.working.directory`|The location of the Jetty working directory. The default value is `./work/jetty`. |`nifi.web.jetty.threads`|The number of Jetty threads. The default value is `200`. |`nifi.web.max.header.size`|The maximum size allowed for request and response headers. The default value is `16 KB`. -|`nifi.web.proxy.host`|A comma separated list of allowed HTTP Host header values to consider when NiFi is running securely and will be receiving requests to a different host[:port] than it is bound to. -For example, when running in a Docker container or behind a proxy (e.g. localhost:18443, proxyhost:443). By default, this value is blank meaning NiFi should only allow requests sent to the -host[:port] that NiFi is bound to. -Requests containing an invalid port in the Host or authority header return an HTTP 421 Misdirected Request status. +|`nifi.web.proxy.host`|A comma-separated list of allowed header values to consider when the application is running with +HTTPS enabled and receives requests through a reverse proxy. Each value may include a domain name such as +`nifi.apache.org` or a domain name with a port number such as `nifi.apache.org:8443`. + +Requests containing an invalid port in the `Host` or authority header return an HTTP 421 Misdirected Request status. +Requests containing an `X-ProxyHost` or `X-Forwarded-Host` header with a value not listed in this property return an HTTP +421 Misdirected Request status. + +Reverse proxy servers are responsible for filtering input request headers and providing allowed proxy host values to the +application. Proxy host header values must be limited to including the domain name, without the port number. |`nifi.web.proxy.context.path`|A comma separated list of allowed HTTP X-ProxyContextPath, X-Forwarded-Context, or X-Forwarded-Prefix header values to consider. By default, this value is blank meaning all requests containing a proxy context path are rejected. Configuring this property would allow requests where the proxy path is contained in this listing. |`nifi.web.max.content.size`|The maximum size (HTTP `Content-Length`) for PUT and POST requests. No default value is set for backward compatibility. Providing a value for this property enables the `Content-Length` filter on all incoming API requests (except Site-to-Site and cluster communications). A suggested value is `20 MB`. diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/connector/FrameworkServerConnectorFactory.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/connector/FrameworkServerConnectorFactory.java index 24ce43a51fd6..e085cc0faced 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/connector/FrameworkServerConnectorFactory.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/connector/FrameworkServerConnectorFactory.java @@ -44,6 +44,8 @@ public class FrameworkServerConnectorFactory extends StandardServerConnectorFact private static final String CIPHER_SUITE_SEPARATOR_PATTERN = ",\\s*"; + private static final Pattern HOST_PORT_SEPARATOR = Pattern.compile(":"); + private static final Pattern HOST_PORT_PATTERN = Pattern.compile(".+?:(\\d+)$"); private static final int PORT_GROUP = 1; @@ -54,6 +56,8 @@ public class FrameworkServerConnectorFactory extends StandardServerConnectorFact private final String excludeCipherSuites; + private final Set validProxyHosts; + private final Set validPorts; private SslContextFactory.Server sslContextFactory; @@ -70,6 +74,7 @@ public FrameworkServerConnectorFactory(final Server server, final NiFiProperties includeCipherSuites = properties.getProperty(NiFiProperties.WEB_HTTPS_CIPHERSUITES_INCLUDE); excludeCipherSuites = properties.getProperty(NiFiProperties.WEB_HTTPS_CIPHERSUITES_EXCLUDE); headerSize = DataUnit.parseDataSize(properties.getWebMaxHeaderSize(), DataUnit.B).intValue(); + validProxyHosts = getValidProxyHosts(properties); validPorts = getValidPorts(properties); if (properties.isHTTPSConfigured()) { @@ -99,9 +104,12 @@ protected HttpConfiguration getHttpConfiguration() { httpConfiguration.setResponseHeaderSize(headerSize); httpConfiguration.setIdleTimeout(IDLE_TIMEOUT); - // Add HostHeaderCustomizer to set Host Header for HTTP/2 and HostHeaderHandler + // Add HostHeaderCustomizer to set Host Header for HTTP/2 httpConfiguration.addCustomizer(new HostHeaderCustomizer()); + final ProxyHeaderValidatorCustomizer proxyHeaderValidatorCustomizer = new ProxyHeaderValidatorCustomizer(validProxyHosts); + httpConfiguration.addCustomizer(proxyHeaderValidatorCustomizer); + final HostPortValidatorCustomizer hostPortValidatorCustomizer = new HostPortValidatorCustomizer(validPorts); httpConfiguration.addCustomizer(hostPortValidatorCustomizer); @@ -159,6 +167,19 @@ private static int getPort(final NiFiProperties properties) { return ObjectUtils.getIfNull(httpsPort, httpPort); } + private static Set getValidProxyHosts(final NiFiProperties properties) { + final Set validProxyHosts = new HashSet<>(); + + final List allowedHosts = properties.getAllowedHostsAsList(); + for (final String allowedHost : allowedHosts) { + final String[] hostPort = HOST_PORT_SEPARATOR.split(allowedHost); + final String host = hostPort[0]; + validProxyHosts.add(host); + } + + return validProxyHosts; + } + private static Set getValidPorts(final NiFiProperties properties) { final Set validPorts = new HashSet<>(); diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/connector/ProxyHeaderValidatorCustomizer.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/connector/ProxyHeaderValidatorCustomizer.java new file mode 100644 index 000000000000..4ed71ea78772 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/connector/ProxyHeaderValidatorCustomizer.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.nifi.web.server.connector; + +import org.apache.nifi.web.servlet.shared.ProxyHeader; +import org.eclipse.jetty.http.HttpException; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpURI; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.Request; + +import java.util.Objects; +import java.util.Set; + +/** + * Jetty Request Customizer implementing validation for supported Proxy Request Headers + */ +public class ProxyHeaderValidatorCustomizer implements HttpConfiguration.Customizer { + private static final String MISDIRECTED_REQUEST_REASON = "Invalid Proxy Host Requested"; + + private static final Set SUPPORTED_PROXY_HOST_HEADERS = Set.of( + ProxyHeader.PROXY_HOST.getHeader(), + ProxyHeader.FORWARDED_HOST.getHeader() + ); + + private final Set validProxyHosts; + + public ProxyHeaderValidatorCustomizer(final Set validProxyHosts) { + this.validProxyHosts = Objects.requireNonNull(validProxyHosts, "Valid Proxy Hosts required"); + } + + /** + * Validate requested proxy host header values against allowed proxy hosts and throw HTTP 421 on invalid values + * + * @param request HTTP Request to be evaluated + * @param responseHeaders HTTP Response headers + * @return Valid HTTP Request + */ + @Override + public Request customize(final Request request, final HttpFields.Mutable responseHeaders) { + final Request customized; + + if (request.isSecure()) { + customized = customizeSecureRequest(request); + } else { + customized = request; + } + + return customized; + } + + private Request customizeSecureRequest(final Request request) { + final HttpURI requestUri = request.getHttpURI(); + final String requestHost = requestUri.getHost(); + + final HttpFields requestHeaders = request.getHeaders(); + for (final String proxyHostHeader : SUPPORTED_PROXY_HOST_HEADERS) { + final String hostHeader = requestHeaders.get(proxyHostHeader); + // Include empty and blank values for enforced validation of request headers + if (hostHeader == null) { + continue; + } + // Allow proxy host header matching request host header based on TLS SNI and DNS SAN requirements + if (requestHost.equals(hostHeader)) { + continue; + } + if (validProxyHosts.contains(hostHeader)) { + continue; + } + + throw new HttpException.RuntimeException(HttpStatus.MISDIRECTED_REQUEST_421, MISDIRECTED_REQUEST_REASON); + } + + return request; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/test/java/org/apache/nifi/web/server/StandardServerProviderTest.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/test/java/org/apache/nifi/web/server/StandardServerProviderTest.java index 8b18bfeb5070..825c527b649d 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/test/java/org/apache/nifi/web/server/StandardServerProviderTest.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/test/java/org/apache/nifi/web/server/StandardServerProviderTest.java @@ -22,6 +22,7 @@ import org.apache.nifi.security.ssl.StandardSslContextBuilder; import org.apache.nifi.util.NiFiProperties; import org.apache.nifi.web.server.handler.HeaderWriterHandler; +import org.apache.nifi.web.servlet.shared.ProxyHeader; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.rewrite.handler.RewriteHandler; @@ -83,7 +84,11 @@ class StandardServerProviderTest { private static final String PUBLIC_HOST = "nifi.apache.org"; - private static final String PUBLIC_UNKNOWN_HOST = "nifi.staged.apache.org"; + private static final String PUBLIC_STAGED_HOST = "nifi.staged.apache.org"; + + private static final String PROXY_HOST_PROPERTY = "nifi.apache.org,nifi.staged.apache.org:8443"; + + private static final String PUBLIC_UNKNOWN_HOST = "nifi.unknown.apache.org"; private static final String ALLOW_RESTRICTED_HEADERS_PROPERTY = "jdk.httpclient.allowRestrictedHeaders"; @@ -174,7 +179,7 @@ void testGetServerStart() throws Exception { void testGetServerHttpsRequestsCompleted() throws Exception { final Properties applicationProperties = new Properties(); applicationProperties.setProperty(NiFiProperties.WEB_HTTPS_PORT, RANDOM_PORT); - applicationProperties.setProperty(NiFiProperties.WEB_PROXY_HOST, PUBLIC_HOST); + applicationProperties.setProperty(NiFiProperties.WEB_PROXY_HOST, PROXY_HOST_PROPERTY); final NiFiProperties properties = NiFiProperties.createBasicNiFiProperties((String) null, applicationProperties); final StandardServerProvider provider = new StandardServerProvider(sslContext); @@ -254,6 +259,24 @@ void assertRedirectRequestsCompleted(final HttpClient httpClient, final URI loca .header(HOST_HEADER, PUBLIC_HOST) .build(); assertResponseStatusCode(httpClient, alternativeNameRequest, HttpStatus.MOVED_TEMPORARILY_302); + + final HttpRequest proxyHostRequest = HttpRequest.newBuilder(localhostUri) + .version(HttpClient.Version.HTTP_1_1) + .header(ProxyHeader.PROXY_HOST.getHeader(), localhostUri.getHost()) + .build(); + assertResponseStatusCode(httpClient, proxyHostRequest, HttpStatus.MOVED_TEMPORARILY_302); + + final HttpRequest proxyHostPublicHostRequest = HttpRequest.newBuilder(localhostUri) + .version(HttpClient.Version.HTTP_1_1) + .header(ProxyHeader.PROXY_HOST.getHeader(), PUBLIC_HOST) + .build(); + assertResponseStatusCode(httpClient, proxyHostPublicHostRequest, HttpStatus.MOVED_TEMPORARILY_302); + + final HttpRequest forwardedHostRequest = HttpRequest.newBuilder(localhostUri) + .version(HttpClient.Version.HTTP_1_1) + .header(ProxyHeader.FORWARDED_HOST.getHeader(), PUBLIC_STAGED_HOST) + .build(); + assertResponseStatusCode(httpClient, forwardedHostRequest, HttpStatus.MOVED_TEMPORARILY_302); } void assertBadRequestsCompleted(final HttpClient httpClient, final URI localhostUri) throws IOException, InterruptedException { @@ -276,6 +299,18 @@ void assertMisdirectedRequestsCompleted(final HttpClient httpClient, final URI l .header(HOST_HEADER, LOCALHOST_HTTP_PORT) .build(); assertResponseStatusCode(httpClient, localhostPortRequest, HttpStatus.MISDIRECTED_REQUEST_421); + + final HttpRequest publicUnknownProxyHostRequest = HttpRequest.newBuilder(localhostUri) + .version(HttpClient.Version.HTTP_1_1) + .header(ProxyHeader.PROXY_HOST.getHeader(), PUBLIC_UNKNOWN_HOST) + .build(); + assertResponseStatusCode(httpClient, publicUnknownProxyHostRequest, HttpStatus.MISDIRECTED_REQUEST_421); + + final HttpRequest publicUnknownForwardedHostRequest = HttpRequest.newBuilder(localhostUri) + .version(HttpClient.Version.HTTP_1_1) + .header(ProxyHeader.FORWARDED_HOST.getHeader(), PUBLIC_UNKNOWN_HOST) + .build(); + assertResponseStatusCode(httpClient, publicUnknownForwardedHostRequest, HttpStatus.MISDIRECTED_REQUEST_421); } void assertStandardResponseHeadersFound(final HttpResponse response) {