diff --git a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
index b211b71250..55cae14785 100644
--- a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
+++ b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
@@ -177,6 +177,7 @@ public abstract class NiFiProperties {
public static final String WEB_WORKING_DIR = "nifi.web.jetty.working.directory";
public static final String WEB_THREADS = "nifi.web.jetty.threads";
public static final String WEB_MAX_HEADER_SIZE = "nifi.web.max.header.size";
+ public static final String WEB_PROXY_CONTEXT_PATH = "nifi.web.proxy.context.path";
// ui properties
public static final String UI_BANNER_TEXT = "nifi.ui.banner.text";
@@ -256,8 +257,8 @@ public abstract class NiFiProperties {
public static final String DEFAULT_ZOOKEEPER_SESSION_TIMEOUT = "3 secs";
public static final String DEFAULT_ZOOKEEPER_ROOT_NODE = "/nifi";
public static final String DEFAULT_ZOOKEEPER_AUTH_TYPE = "default";
- public static final String DEFAULT_ZOOKEEPER_KERBEROS_REMOVE_HOST_FROM_PRINCIPAL = "true";
- public static final String DEFAULT_ZOOKEEPER_KERBEROS_REMOVE_REALM_FROM_PRINCIPAL = "true";
+ public static final String DEFAULT_ZOOKEEPER_KERBEROS_REMOVE_HOST_FROM_PRINCIPAL = "true";
+ public static final String DEFAULT_ZOOKEEPER_KERBEROS_REMOVE_REALM_FROM_PRINCIPAL = "true";
public static final String DEFAULT_SITE_TO_SITE_HTTP_TRANSACTION_TTL = "30 secs";
public static final String DEFAULT_FLOW_CONFIGURATION_ARCHIVE_ENABLED = "true";
public static final String DEFAULT_FLOW_CONFIGURATION_ARCHIVE_MAX_TIME = "30 days";
@@ -1084,7 +1085,7 @@ public abstract class NiFiProperties {
* Returns the number of claims to keep open for writing. Ideally, this will be at
* least as large as the number of threads that will be updating the repository simultaneously but we don't want
* to get too large because it will hold open up to this many FileOutputStreams.
- *
+ *
* Default is {@link #DEFAULT_MAX_FLOWFILES_PER_CLAIM}
*
* @return the maximum number of flow files per claim
@@ -1100,7 +1101,7 @@ public abstract class NiFiProperties {
/**
* Returns the maximum size, in bytes, that claims should grow before writing a new file. This means that we won't continually write to one
* file that keeps growing but gives us a chance to bunch together many small files.
- *
+ *
* Default is {@link #DEFAULT_MAX_APPENDABLE_CLAIM_SIZE}
*
* @return the maximum appendable claim size
@@ -1285,6 +1286,42 @@ public abstract class NiFiProperties {
return keys;
}
+ /**
+ * Returns the whitelisted proxy context paths as a comma-delimited string. The paths have been normalized to the form {@code /some/context/path}.
+ *
+ * Note: Calling {@code NiFiProperties.getProperty(NiFiProperties.WEB_PROXY_CONTEXT_PATH)} will not normalize the paths.
+ *
+ * @return the path(s)
+ */
+ public String getWhitelistedContextPaths() {
+ return StringUtils.join(getWhitelistedContextPathsAsList(), ",");
+ }
+
+ /**
+ * Returns the whitelisted proxy context paths as a list of paths. The paths have been normalized to the form {@code /some/context/path}.
+ *
+ * @return the path(s)
+ */
+ public List getWhitelistedContextPathsAsList() {
+ String rawProperty = getProperty(WEB_PROXY_CONTEXT_PATH, "");
+ List contextPaths = Arrays.asList(rawProperty.split(","));
+ return contextPaths.stream()
+ .map(this::normalizeContextPath).collect(Collectors.toList());
+ }
+
+ private String normalizeContextPath(String cp) {
+ if (cp == null || cp.equalsIgnoreCase("")) {
+ return "";
+ } else {
+ String trimmedCP = cp.trim();
+ // Ensure it starts with a leading slash and does not end in a trailing slash
+ // There's a potential for the path to be something like bad/path/// but this is semi-trusted data from an admin-accessible file and there are way worse possibilities here
+ trimmedCP = trimmedCP.startsWith("/") ? trimmedCP : "/" + trimmedCP;
+ trimmedCP = trimmedCP.endsWith("/") ? trimmedCP.substring(0, trimmedCP.length() - 1) : trimmedCP;
+ return trimmedCP;
+ }
+ }
+
private List getProvenanceRepositoryEncryptionKeyProperties() {
// Filter all the property keys that define a key
return getPropertyKeys().stream().filter(k ->
diff --git a/nifi-commons/nifi-web-utils/src/main/java/org/apache/nifi/web/util/WebUtils.java b/nifi-commons/nifi-web-utils/src/main/java/org/apache/nifi/web/util/WebUtils.java
index 358cbea9f0..351d7f957e 100644
--- a/nifi-commons/nifi-web-utils/src/main/java/org/apache/nifi/web/util/WebUtils.java
+++ b/nifi-commons/nifi-web-utils/src/main/java/org/apache/nifi/web/util/WebUtils.java
@@ -16,28 +16,31 @@
*/
package org.apache.nifi.web.util;
+import java.net.URI;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.ClientBuilder;
+import javax.ws.rs.core.UriBuilderException;
+import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.security.util.CertificateUtils;
import org.glassfish.jersey.client.ClientConfig;
import org.glassfish.jersey.jackson.internal.jackson.jaxrs.json.JacksonJaxbJsonProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import javax.net.ssl.HostnameVerifier;
-import javax.net.ssl.SSLContext;
-import javax.net.ssl.SSLPeerUnverifiedException;
-import javax.net.ssl.SSLSession;
-import javax.ws.rs.client.Client;
-import javax.ws.rs.client.ClientBuilder;
-import java.security.cert.Certificate;
-import java.security.cert.CertificateParsingException;
-import java.security.cert.X509Certificate;
-import java.util.List;
-import java.util.concurrent.locks.ReadWriteLock;
-import java.util.concurrent.locks.ReentrantReadWriteLock;
-
/**
* Common utilities related to web development.
- *
*/
public final class WebUtils {
@@ -45,6 +48,9 @@ public final class WebUtils {
final static ReadWriteLock lock = new ReentrantReadWriteLock();
+ private static final String PROXY_CONTEXT_PATH_HTTP_HEADER = "X-ProxyContextPath";
+ private static final String FORWARDED_CONTEXT_HTTP_HEADER = "X-Forwarded-Context";
+
private WebUtils() {
}
@@ -54,7 +60,6 @@ public final class WebUtils {
* automatically configured for JSON serialization/deserialization.
*
* @param config client configuration
- *
* @return a Client instance
*/
public static Client createClient(final ClientConfig config) {
@@ -67,8 +72,7 @@ public final class WebUtils {
* will be automatically configured for JSON serialization/deserialization.
*
* @param config client configuration
- * @param ctx security context
- *
+ * @param ctx security context
* @return a Client instance
*/
public static Client createClient(final ClientConfig config, final SSLContext ctx) {
@@ -81,9 +85,8 @@ public final class WebUtils {
* will be automatically configured for JSON serialization/deserialization.
*
* @param config client configuration
- * @param ctx security context, which may be null for non-secure client
- * creation
- *
+ * @param ctx security context, which may be null for non-secure client
+ * creation
* @return a Client instance
*/
private static Client createClientHelper(final ClientConfig config, final SSLContext ctx) {
@@ -128,4 +131,119 @@ public final class WebUtils {
}
+ /**
+ * This method will check the provided context path headers against a whitelist (provided in nifi.properties) and throw an exception if the requested context path is not registered.
+ *
+ * @param uri the request URI
+ * @param request the HTTP request
+ * @param whitelistedContextPaths comma-separated list of valid context paths
+ * @return the resource path
+ * @throws UriBuilderException if the requested context path is not registered (header poisoning)
+ */
+ public static String getResourcePath(URI uri, HttpServletRequest request, String whitelistedContextPaths) throws UriBuilderException {
+ String resourcePath = uri.getPath();
+
+ // Determine and normalize the context path
+ String determinedContextPath = determineContextPath(request);
+ determinedContextPath = normalizeContextPath(determinedContextPath);
+
+ // If present, check it and prepend to the resource path
+ if (StringUtils.isNotBlank(determinedContextPath)) {
+ verifyContextPath(whitelistedContextPaths, determinedContextPath);
+
+ // Determine the complete resource path
+ resourcePath = determinedContextPath + resourcePath;
+ }
+
+ return resourcePath;
+ }
+
+ /**
+ * Throws an exception if the provided context path is not in the whitelisted context paths list.
+ *
+ * @param whitelistedContextPaths a comma-delimited list of valid context paths
+ * @param determinedContextPath the normalized context path from a header
+ * @throws UriBuilderException if the context path is not safe
+ */
+ public static void verifyContextPath(String whitelistedContextPaths, String determinedContextPath) throws UriBuilderException {
+ // If blank, ignore
+ if (StringUtils.isBlank(determinedContextPath)) {
+ return;
+ }
+
+ // Check it against the whitelist
+ List individualContextPaths = Arrays.asList(StringUtils.split(whitelistedContextPaths, ","));
+ if (!individualContextPaths.contains(determinedContextPath)) {
+ final String msg = "The provided context path [" + determinedContextPath + "] was not whitelisted [" + whitelistedContextPaths + "]";
+ logger.error(msg);
+ throw new UriBuilderException(msg);
+ }
+ }
+
+ /**
+ * Returns a normalized context path (leading /, no trailing /). If the parameter is blank, an empty string will be returned.
+ *
+ * @param determinedContextPath the raw context path
+ * @return the normalized context path
+ */
+ public static String normalizeContextPath(String determinedContextPath) {
+ if (StringUtils.isNotBlank(determinedContextPath)) {
+ // normalize context path
+ if (!determinedContextPath.startsWith("/")) {
+ determinedContextPath = "/" + determinedContextPath;
+ }
+
+ if (determinedContextPath.endsWith("/")) {
+ determinedContextPath = determinedContextPath.substring(0, determinedContextPath.length() - 1);
+ }
+
+ return determinedContextPath;
+ } else {
+ return "";
+ }
+ }
+
+ /**
+ * Determines the context path if populated in {@code X-ProxyContextPath} or {@code X-ForwardContext} headers. If not populated, returns an empty string.
+ *
+ * @param request the HTTP request
+ * @return the provided context path or an empty string
+ */
+ public static String determineContextPath(HttpServletRequest request) {
+ String contextPath = request.getContextPath();
+ String proxyContextPath = request.getHeader(PROXY_CONTEXT_PATH_HTTP_HEADER);
+ String forwardedContext = request.getHeader(FORWARDED_CONTEXT_HTTP_HEADER);
+
+ logger.debug("Context path: " + contextPath);
+ String determinedContextPath = "";
+
+ // If either header is set, log both
+ if (anyNotBlank(proxyContextPath, forwardedContext)) {
+ logger.debug(String.format("On the request, the following context paths were parsed" +
+ " from headers:\n\t X-ProxyContextPath: %s\n\tX-Forwarded-Context: %s",
+ proxyContextPath, forwardedContext));
+
+ // Implementing preferred order here: PCP, FCP
+ determinedContextPath = StringUtils.isNotBlank(proxyContextPath) ? proxyContextPath : forwardedContext;
+ }
+
+ logger.debug("Determined context path: " + determinedContextPath);
+ return determinedContextPath;
+ }
+
+ /**
+ * Returns true if any of the provided arguments are not blank.
+ *
+ * @param strings a variable number of strings
+ * @return true if any string has content (not empty or all whitespace)
+ */
+ private static boolean anyNotBlank(String... strings) {
+ for (String s : strings) {
+ if (StringUtils.isNotBlank(s)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
}
diff --git a/nifi-commons/nifi-web-utils/src/test/groovy/org/apache/nifi/web/util/WebUtilsTest.groovy b/nifi-commons/nifi-web-utils/src/test/groovy/org/apache/nifi/web/util/WebUtilsTest.groovy
new file mode 100644
index 0000000000..62755a1e64
--- /dev/null
+++ b/nifi-commons/nifi-web-utils/src/test/groovy/org/apache/nifi/web/util/WebUtilsTest.groovy
@@ -0,0 +1,275 @@
+/*
+ * 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.util
+
+import org.junit.After
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+import javax.servlet.http.HttpServletRequest
+import javax.ws.rs.core.UriBuilderException
+
+@RunWith(JUnit4.class)
+class WebUtilsTest extends GroovyTestCase {
+ private static final Logger logger = LoggerFactory.getLogger(WebUtilsTest.class)
+
+ static final String PCP_HEADER = "X-ProxyContextPath"
+ static final String FC_HEADER = "X-Forwarded-Context"
+
+ static final String WHITELISTED_PATH = "/some/context/path"
+
+ @BeforeClass
+ static void setUpOnce() throws Exception {
+ logger.metaClass.methodMissing = { String name, args ->
+ logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}")
+ }
+ }
+
+ @Before
+ void setUp() throws Exception {
+ }
+
+ @After
+ void tearDown() throws Exception {
+ }
+
+ HttpServletRequest mockRequest(Map keys) {
+ HttpServletRequest mockRequest = [
+ getContextPath: { ->
+ logger.mock("Request.getContextPath() -> default/path")
+ "default/path"
+ },
+ getHeader : { String k ->
+ logger.mock("Request.getHeader($k) -> ${keys}")
+ switch (k) {
+ case PCP_HEADER:
+ return keys["proxy"]
+ break
+ case FC_HEADER:
+ return keys["forward"]
+ break
+ default:
+ return ""
+ }
+ }] as HttpServletRequest
+ mockRequest
+ }
+
+ @Test
+ void testShouldDetermineCorrectContextPathWhenPresent() throws Exception {
+ // Arrange
+ final String CORRECT_CONTEXT_PATH = WHITELISTED_PATH
+ final String WRONG_CONTEXT_PATH = "this/is/a/bad/path"
+
+ // Variety of requests with different ordering of context paths (the correct one is always "some/context/path"
+ HttpServletRequest proxyRequest = mockRequest([proxy: CORRECT_CONTEXT_PATH])
+ HttpServletRequest forwardedRequest = mockRequest([forward: CORRECT_CONTEXT_PATH])
+ HttpServletRequest proxyBeforeForwardedRequest = mockRequest([proxy: CORRECT_CONTEXT_PATH, forward: WRONG_CONTEXT_PATH])
+ List requests = [proxyRequest, forwardedRequest, proxyBeforeForwardedRequest]
+
+ // Act
+ requests.each { HttpServletRequest request ->
+ String determinedContextPath = WebUtils.determineContextPath(request)
+ logger.info("Determined context path: ${determinedContextPath}")
+
+ // Assert
+ assert determinedContextPath == CORRECT_CONTEXT_PATH
+ }
+ }
+
+ @Test
+ void testShouldDetermineCorrectContextPathWhenAbsent() throws Exception {
+ // Arrange
+ final String CORRECT_CONTEXT_PATH = ""
+
+ // Variety of requests with different ordering of non-existent context paths (the correct one is always ""
+ HttpServletRequest proxyRequest = mockRequest([proxy: ""])
+ HttpServletRequest proxySpacesRequest = mockRequest([proxy: " "])
+ HttpServletRequest forwardedRequest = mockRequest([forward: ""])
+ HttpServletRequest forwardedSpacesRequest = mockRequest([forward: " "])
+ HttpServletRequest proxyBeforeForwardedRequest = mockRequest([proxy: "", forward: ""])
+ List requests = [proxyRequest, proxySpacesRequest, forwardedRequest, forwardedSpacesRequest, proxyBeforeForwardedRequest]
+
+ // Act
+ requests.each { HttpServletRequest request ->
+ String determinedContextPath = WebUtils.determineContextPath(request)
+ logger.info("Determined context path: ${determinedContextPath}")
+
+ // Assert
+ assert determinedContextPath == CORRECT_CONTEXT_PATH
+ }
+ }
+
+ @Test
+ void testShouldNormalizeContextPath() throws Exception {
+ // Arrange
+ final String CORRECT_CONTEXT_PATH = WHITELISTED_PATH
+ final String TRIMMED_PATH = WHITELISTED_PATH[1..-1] // Trims leading /
+
+ // Variety of different context paths (the correct one is always "/some/context/path")
+ List contextPaths = ["/$TRIMMED_PATH", "/" + TRIMMED_PATH, TRIMMED_PATH, TRIMMED_PATH + "/"]
+
+ // Act
+ contextPaths.each { String contextPath ->
+ String normalizedContextPath = WebUtils.normalizeContextPath(contextPath)
+ logger.info("Normalized context path: ${normalizedContextPath} <- ${contextPath}")
+
+ // Assert
+ assert normalizedContextPath == CORRECT_CONTEXT_PATH
+ }
+ }
+
+ @Test
+ void testGetResourcePathShouldBlockContextPathHeaderIfNotInWhitelist() throws Exception {
+ // Arrange
+ logger.info("Whitelisted path(s): ")
+
+ HttpServletRequest requestWithProxyHeader = mockRequest([proxy: "any/context/path"])
+ HttpServletRequest requestWithProxyAndForwardHeader = mockRequest([proxy: "any/context/path", forward: "any/other/context/path"])
+ List requests = [requestWithProxyHeader, requestWithProxyAndForwardHeader]
+
+ // Act
+ requests.each { HttpServletRequest request ->
+ def msg = shouldFail(UriBuilderException) {
+ String generatedResourcePath = WebUtils.getResourcePath(new URI('https://nifi.apache.org/actualResource'), request, "")
+ logger.unexpected("Generated Resource Path: ${generatedResourcePath}")
+ }
+
+ // Assert
+ logger.expected(msg)
+ assert msg =~ "The provided context path \\[.*\\] was not whitelisted \\[\\]"
+ }
+ }
+
+ @Test
+ void testGetResourcePathShouldAllowContextPathHeaderIfInWhitelist() throws Exception {
+ // Arrange
+ logger.info("Whitelisted path(s): ${WHITELISTED_PATH}")
+
+ HttpServletRequest requestWithProxyHeader = mockRequest([proxy: "some/context/path"])
+ HttpServletRequest requestWithForwardHeader = mockRequest([forward: "some/context/path"])
+ HttpServletRequest requestWithProxyAndForwardHeader = mockRequest([proxy: "some/context/path", forward: "any/other/context/path"])
+ List requests = [requestWithProxyHeader, requestWithForwardHeader, requestWithProxyAndForwardHeader]
+
+ // Act
+ requests.each { HttpServletRequest request ->
+ String generatedResourcePath = WebUtils.getResourcePath(new URI('https://nifi.apache.org/actualResource'), request, WHITELISTED_PATH)
+ logger.info("Generated Resource Path: ${generatedResourcePath}")
+
+ // Assert
+ assert generatedResourcePath == "${WHITELISTED_PATH}/actualResource"
+ }
+ }
+
+ @Test
+ void testGetResourcePathShouldAllowContextPathHeaderIfElementInMultipleWhitelist() throws Exception {
+ // Arrange
+ String multipleWhitelistedPaths = [WHITELISTED_PATH, "/another/path", "/a/third/path"].join(",")
+ logger.info("Whitelisted path(s): ${multipleWhitelistedPaths}")
+
+ final List VALID_RESOURCE_PATHS = multipleWhitelistedPaths.split(",").collect { "$it/actualResource" }
+
+ HttpServletRequest requestWithProxyHeader = mockRequest([proxy: "some/context/path"])
+ HttpServletRequest requestWithForwardHeader = mockRequest([forward: "another/path"])
+ HttpServletRequest requestWithProxyAndForwardHeader = mockRequest([proxy: "a/third/path", forward: "any/other/context/path"])
+ List requests = [requestWithProxyHeader, requestWithForwardHeader, requestWithProxyAndForwardHeader]
+
+ // Act
+ requests.each { HttpServletRequest request ->
+ String generatedResourcePath = WebUtils.getResourcePath(new URI('https://nifi.apache.org/actualResource'), request, multipleWhitelistedPaths)
+ logger.info("Generated Resource Path: ${generatedResourcePath}")
+
+ // Assert
+ assert VALID_RESOURCE_PATHS.any { it == generatedResourcePath }
+ }
+ }
+
+ @Test
+ void testVerifyContextPathShouldAllowContextPathHeaderIfInWhitelist() throws Exception {
+ // Arrange
+ logger.info("Whitelisted path(s): ${WHITELISTED_PATH}")
+ String contextPath = WHITELISTED_PATH
+
+ // Act
+ logger.info("Testing [${contextPath}] against ${WHITELISTED_PATH}")
+ WebUtils.verifyContextPath(WHITELISTED_PATH, contextPath)
+ logger.info("Verified [${contextPath}]")
+
+ // Assert
+ // Would throw exception if invalid
+ }
+
+ @Test
+ void testVerifyContextPathShouldAllowContextPathHeaderIfInMultipleWhitelist() throws Exception {
+ // Arrange
+ String multipleWhitelist = [WHITELISTED_PATH, WebUtils.normalizeContextPath(WHITELISTED_PATH.reverse())].join(",")
+ logger.info("Whitelisted path(s): ${multipleWhitelist}")
+ String contextPath = WHITELISTED_PATH
+
+ // Act
+ logger.info("Testing [${contextPath}] against ${multipleWhitelist}")
+ WebUtils.verifyContextPath(multipleWhitelist, contextPath)
+ logger.info("Verified [${contextPath}]")
+
+ // Assert
+ // Would throw exception if invalid
+ }
+
+ @Test
+ void testVerifyContextPathShouldAllowContextPathHeaderIfBlank() throws Exception {
+ // Arrange
+ logger.info("Whitelisted path(s): ${WHITELISTED_PATH}")
+
+ def emptyContextPaths = ["", " ", "\t", null]
+
+ // Act
+ emptyContextPaths.each { String contextPath ->
+ logger.info("Testing [${contextPath}] against ${WHITELISTED_PATH}")
+ WebUtils.verifyContextPath(WHITELISTED_PATH, contextPath)
+ logger.info("Verified [${contextPath}]")
+
+ // Assert
+ // Would throw exception if invalid
+ }
+ }
+
+ @Test
+ void testVerifyContextPathShouldBlockContextPathHeaderIfNotAllowed() throws Exception {
+ // Arrange
+ logger.info("Whitelisted path(s): ${WHITELISTED_PATH}")
+
+ def invalidContextPaths = ["/other/path", "somesite.com", "/../trying/to/escape"]
+
+ // Act
+ invalidContextPaths.each { String contextPath ->
+ logger.info("Testing [${contextPath}] against ${WHITELISTED_PATH}")
+ def msg = shouldFail(UriBuilderException) {
+ WebUtils.verifyContextPath(WHITELISTED_PATH, contextPath)
+ logger.info("Verified [${contextPath}]")
+ }
+
+ // Assert
+ logger.expected(msg)
+ assert msg =~ " was not whitelisted "
+ }
+ }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-properties-loader/src/test/groovy/org/apache/nifi/properties/StandardNiFiPropertiesGroovyTest.groovy b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-properties-loader/src/test/groovy/org/apache/nifi/properties/StandardNiFiPropertiesGroovyTest.groovy
index ae43a3d6df..64c4f0f80b 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-properties-loader/src/test/groovy/org/apache/nifi/properties/StandardNiFiPropertiesGroovyTest.groovy
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-properties-loader/src/test/groovy/org/apache/nifi/properties/StandardNiFiPropertiesGroovyTest.groovy
@@ -322,4 +322,115 @@ class StandardNiFiPropertiesGroovyTest extends GroovyTestCase {
assert key == KEY_HEX
assert keys == [(KEY_ID): KEY_HEX, (KEY_ID_2): KEY_HEX_2, (KEY_ID_3): KEY_HEX_3]
}
+
+
+ @Test
+ void testShouldNormalizeContextPathProperty() {
+ // Arrange
+ String noLeadingSlash = "some/context/path"
+ Properties rawProps = new Properties(["nifi.web.proxy.context.path": noLeadingSlash])
+ NiFiProperties props = new StandardNiFiProperties(rawProps)
+ logger.info("Created a NiFiProperties instance with raw context path property [${noLeadingSlash}]")
+
+ // Act
+ String normalizedContextPath = props.getWhitelistedContextPaths()
+ logger.info("Read from NiFiProperties instance: ${normalizedContextPath}")
+
+ // Assert
+ assert normalizedContextPath == "/" + noLeadingSlash
+ }
+
+ @Test
+ void testShouldHandleNormalizedContextPathProperty() {
+ // Arrange
+ String leadingSlash = "/some/context/path"
+ Properties rawProps = new Properties(["nifi.web.proxy.context.path": leadingSlash])
+ NiFiProperties props = new StandardNiFiProperties(rawProps)
+ logger.info("Created a NiFiProperties instance with raw context path property [${leadingSlash}]")
+
+ // Act
+ String normalizedContextPath = props.getWhitelistedContextPaths()
+ logger.info("Read from NiFiProperties instance: ${normalizedContextPath}")
+
+ // Assert
+ assert normalizedContextPath == leadingSlash
+ }
+
+ @Test
+ void testShouldNormalizeMultipleContextPathsInProperty() {
+ // Arrange
+ String noLeadingSlash = "some/context/path"
+ String leadingSlash = "some/other/path"
+ String leadingAndTrailingSlash = "/a/third/path/"
+ List paths = [noLeadingSlash, leadingSlash, leadingAndTrailingSlash]
+ String combinedPaths = paths.join(",")
+ Properties rawProps = new Properties(["nifi.web.proxy.context.path": combinedPaths])
+ NiFiProperties props = new StandardNiFiProperties(rawProps)
+ logger.info("Created a NiFiProperties instance with raw context path property [${noLeadingSlash}]")
+
+ // Act
+ String normalizedContextPath = props.getWhitelistedContextPaths()
+ logger.info("Read from NiFiProperties instance: ${normalizedContextPath}")
+
+ // Assert
+ def splitPaths = normalizedContextPath.split(",")
+ splitPaths.every {
+ assert it.startsWith("/")
+ assert !it.endsWith("/")
+ }
+ }
+
+ @Test
+ void testShouldHandleNormalizedContextPathPropertyAsList() {
+ // Arrange
+ String leadingSlash = "/some/context/path"
+ Properties rawProps = new Properties(["nifi.web.proxy.context.path": leadingSlash])
+ NiFiProperties props = new StandardNiFiProperties(rawProps)
+ logger.info("Created a NiFiProperties instance with raw context path property [${leadingSlash}]")
+
+ // Act
+ def normalizedContextPaths = props.getWhitelistedContextPathsAsList()
+ logger.info("Read from NiFiProperties instance: ${normalizedContextPaths}")
+
+ // Assert
+ assert normalizedContextPaths.size() == 1
+ assert normalizedContextPaths.contains(leadingSlash)
+ }
+
+ @Test
+ void testShouldNormalizeMultipleContextPathsInPropertyAsList() {
+ // Arrange
+ String noLeadingSlash = "some/context/path"
+ String leadingSlash = "/some/other/path"
+ String leadingAndTrailingSlash = "/a/third/path/"
+ List paths = [noLeadingSlash, leadingSlash, leadingAndTrailingSlash]
+ String combinedPaths = paths.join(",")
+ Properties rawProps = new Properties(["nifi.web.proxy.context.path": combinedPaths])
+ NiFiProperties props = new StandardNiFiProperties(rawProps)
+ logger.info("Created a NiFiProperties instance with raw context path property [${noLeadingSlash}]")
+
+ // Act
+ def normalizedContextPaths = props.getWhitelistedContextPathsAsList()
+ logger.info("Read from NiFiProperties instance: ${normalizedContextPaths}")
+
+ // Assert
+ assert normalizedContextPaths.size() == 3
+ assert normalizedContextPaths.containsAll([leadingSlash, "/" + noLeadingSlash, leadingAndTrailingSlash[0..-2]])
+ }
+
+ @Test
+ void testShouldHandleNormalizingEmptyContextPathProperty() {
+ // Arrange
+ String empty = ""
+ Properties rawProps = new Properties(["nifi.web.proxy.context.path": empty])
+ NiFiProperties props = new StandardNiFiProperties(rawProps)
+ logger.info("Created a NiFiProperties instance with raw context path property [${empty}]")
+
+ // Act
+ String normalizedContextPath = props.getWhitelistedContextPaths()
+ logger.info("Read from NiFiProperties instance: ${normalizedContextPath}")
+
+ // Assert
+ assert normalizedContextPath == empty
+ }
}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
index 3e6a65da85..35ec7dc329 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
@@ -132,6 +132,7 @@
./work/jetty20016 KB
+
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
index 49f08ada2a..19358f63bf 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
@@ -137,6 +137,7 @@ nifi.web.https.network.interface.default=${nifi.web.https.network.interface.defa
nifi.web.jetty.working.directory=${nifi.jetty.work.dir}
nifi.web.jetty.threads=${nifi.web.jetty.threads}
nifi.web.max.header.size=${nifi.web.max.header.size}
+nifi.web.proxy.context.path=${nifi.web.proxy.context.path}
# security properties #
nifi.sensitive.props.key=
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/HostHeaderHandler.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/HostHeaderHandler.java
new file mode 100644
index 0000000000..989d8d7fec
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/HostHeaderHandler.java
@@ -0,0 +1,114 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.commons.lang3.StringEscapeUtils;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.handler.ScopedHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class HostHeaderHandler extends ScopedHandler {
+ private static final Logger logger = LoggerFactory.getLogger(HostHeaderHandler.class);
+
+ private final String serverName;
+ private final int serverPort;
+ private final List validHosts;
+
+ /**
+ * @param serverName the {@code serverName} to set on the request (the {@code serverPort} will not be set)
+ */
+ public HostHeaderHandler(String serverName) {
+ this(serverName, 0);
+ }
+
+ /**
+ * @param serverName the {@code serverName} to set on the request
+ * @param serverPort the {@code serverPort} to set on the request
+ */
+ public HostHeaderHandler(String serverName, int serverPort) {
+ this.serverName = Objects.requireNonNull(serverName);
+ this.serverPort = serverPort;
+
+ validHosts = new ArrayList<>();
+ validHosts.add(serverName.toLowerCase());
+ validHosts.add(serverName.toLowerCase() + ":" + serverPort);
+ // Sometimes the hostname is left empty but the port is always populated
+ validHosts.add("localhost");
+ validHosts.add("localhost:" + serverPort);
+ // Different from customizer -- empty is ok here
+ validHosts.add("");
+
+ logger.info("Created " + this.toString());
+ }
+
+ @Override
+ public void doScope(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
+ throws IOException, ServletException {
+ logger.debug("HostHeaderHandler#doScope on " + request.getRequestURI());
+ nextScope(target, baseRequest, request, response);
+ }
+
+ private boolean hostHeaderIsValid(String hostHeader) {
+ return validHosts.contains(hostHeader.toLowerCase().trim());
+ }
+
+ @Override
+ public String toString() {
+ return "HostHeaderHandler for " + serverName + ":" + serverPort;
+ }
+
+ /**
+ * Returns an error message to the response and marks the request as handled if the host header is not valid.
+ * Otherwise passes the request on to the next scoped handler.
+ *
+ * @param target the target (not relevant here)
+ * @param baseRequest the original request object
+ * @param request the request as an HttpServletRequest
+ * @param response the current response
+ */
+ @Override
+ public void doHandle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
+ final String hostHeader = request.getHeader("Host");
+ logger.debug("Received request [" + request.getRequestURI() + "] with host header: " + hostHeader);
+ if (!hostHeaderIsValid(hostHeader)) {
+ logger.warn("Request host header [" + hostHeader + "] different from web hostname [" +
+ serverName + "(:" + serverPort + ")]. Overriding to [" + serverName + ":" +
+ serverPort + request.getRequestURI() + "]");
+
+ response.setContentType("text/html; charset=utf-8");
+ response.setStatus(HttpServletResponse.SC_OK);
+
+ PrintWriter out = response.getWriter();
+
+ out.println("
System Error
");
+ out.println("
The request contained an invalid host header [" + StringEscapeUtils.escapeHtml4(hostHeader) +
+ "] in the request [" + StringEscapeUtils.escapeHtml4(request.getRequestURI()) +
+ "]. Check for request manipulation or third-party intercept.
");
+
+ baseRequest.setHandled(true);
+ }
+ }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/HostHeaderSanitizationCustomizer.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/HostHeaderSanitizationCustomizer.java
new file mode 100644
index 0000000000..9d55d71816
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/HostHeaderSanitizationCustomizer.java
@@ -0,0 +1,80 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.Request;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class HostHeaderSanitizationCustomizer implements HttpConfiguration.Customizer {
+ private static final Logger logger = LoggerFactory.getLogger(HostHeaderSanitizationCustomizer.class);
+
+ private final String serverName;
+ private final int serverPort;
+ private final List validHosts;
+
+ /**
+ * @param serverName the {@code serverName} to set on the request (the {@code serverPort} will not be set)
+ */
+ public HostHeaderSanitizationCustomizer(String serverName) {
+ this(serverName, 0);
+ }
+
+ /**
+ * @param serverName the {@code serverName} to set on the request
+ * @param serverPort the {@code serverPort} to set on the request
+ */
+ public HostHeaderSanitizationCustomizer(String serverName, int serverPort) {
+ this.serverName = Objects.requireNonNull(serverName);
+ this.serverPort = serverPort;
+
+ validHosts = new ArrayList<>();
+ validHosts.add(serverName.toLowerCase());
+ validHosts.add(serverName.toLowerCase() + ":" + serverPort);
+ // Sometimes the hostname is left empty but the port is always populated
+ validHosts.add("localhost");
+ validHosts.add("localhost:" + serverPort);
+
+ logger.info("Created " + this.toString());
+ }
+
+ @Override
+ public void customize(Connector connector, HttpConfiguration channelConfig, Request request) {
+ final String hostHeader = request.getHeader("Host");
+ logger.debug("Received request [" + request.getRequestURI() + "] with host header: " + hostHeader);
+ if (!hostHeaderIsValid(hostHeader)) {
+ logger.warn("Request host header [" + hostHeader + "] different from web hostname [" +
+ serverName + "(:" + serverPort + ")]. Overriding to [" + serverName + ":" +
+ serverPort + request.getRequestURI() + "]");
+ request.setAuthority(serverName, serverPort);
+ }
+ }
+
+ private boolean hostHeaderIsValid(String hostHeader) {
+ return validHosts.contains(hostHeader.toLowerCase().trim());
+ }
+
+ @Override
+ public String toString() {
+ return "HostHeaderSanitizationCustomizer for " + serverName + ":" + serverPort;
+ }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/JettyServer.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/JettyServer.java
index bd72c81998..744c1277a1 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/JettyServer.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/JettyServer.java
@@ -18,6 +18,39 @@ package org.apache.nifi.web.server;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.stream.Collectors;
+import javax.servlet.DispatcherType;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletResponse;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.NiFiServer;
@@ -51,6 +84,7 @@ import org.eclipse.jetty.server.SslConnectionFactory;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.server.handler.ContextHandlerCollection;
import org.eclipse.jetty.server.handler.HandlerCollection;
+import org.eclipse.jetty.server.handler.HandlerList;
import org.eclipse.jetty.server.handler.ResourceHandler;
import org.eclipse.jetty.server.handler.gzip.GzipHandler;
import org.eclipse.jetty.servlet.FilterHolder;
@@ -69,40 +103,6 @@ import org.springframework.context.ApplicationContext;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
-import javax.servlet.DispatcherType;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletContext;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServletResponse;
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileFilter;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.net.InetAddress;
-import java.net.NetworkInterface;
-import java.net.SocketException;
-import java.nio.file.Paths;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.Enumeration;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.jar.JarEntry;
-import java.util.jar.JarFile;
-import java.util.stream.Collectors;
-
/**
* Encapsulates the Jetty instance.
*/
@@ -153,10 +153,33 @@ public class JettyServer implements NiFiServer {
configureConnectors(server);
// load wars from the bundle
- loadWars(bundles);
+ Handler warHandlers = loadWars(bundles);
+
+ // Create a handler for the host header and add it to the server
+ String serverName = determineServerHostname();
+ int serverPort = determineServerPort();
+ HostHeaderHandler hostHeaderHandler = new HostHeaderHandler(serverName, serverPort);
+ logger.info("Created HostHeaderHandler [" + hostHeaderHandler.toString() + "]");
+
+ HandlerList allHandlers = new HandlerList();
+ allHandlers.addHandler(hostHeaderHandler);
+ allHandlers.addHandler(warHandlers);
+ server.setHandler(allHandlers);
}
- private void loadWars(final Set bundles) {
+ private int determineServerPort() {
+ return props.getSslPort() != null ? props.getSslPort() : props.getPort();
+ }
+
+ private String determineServerHostname() {
+ if (props.getSslPort() != null) {
+ return props.getProperty(NiFiProperties.WEB_HTTPS_HOST, "localhost");
+ } else {
+ return props.getProperty(NiFiProperties.WEB_HTTP_HOST, "localhost");
+ }
+ }
+
+ private Handler loadWars(final Set bundles) {
// load WARs
final Map warToBundleLookup = findWars(bundles);
@@ -316,17 +339,19 @@ public class JettyServer implements NiFiServer {
handlers.addHandler(documentationHandlers);
// load the web error app
- handlers.addHandler(loadWar(webErrorWar, "/", frameworkClassLoader));
+ final WebAppContext webErrorContext = loadWar(webErrorWar, "/", frameworkClassLoader);
+ webErrorContext.getInitParams().put("whitelistedContextPaths", props.getWhitelistedContextPaths());
+ handlers.addHandler(webErrorContext);
// deploy the web apps
- server.setHandler(gzip(handlers));
+ return gzip(handlers);
}
/**
* Returns whether or not the specified ui extensions already contains an extension of the specified type.
*
- * @param componentUiExtensionsForType ui extensions for the type
- * @param extensionType type of ui extension
+ * @param componentUiExtensionsForType ui extensions for the type
+ * @param extensionType type of ui extension
* @return whether or not the specified ui extensions already contains an extension of the specified type
*/
private boolean containsUiExtensionType(final List componentUiExtensionsForType, final UiExtensionType extensionType) {
@@ -409,7 +434,7 @@ public class JettyServer implements NiFiServer {
* Identifies all known UI extensions and stores them in the specified map.
*
* @param uiExtensions extensions
- * @param warFile war
+ * @param warFile war
*/
private void identifyUiExtensionsForComponents(final Map> uiExtensions, final File warFile) {
try (final JarFile jarFile = new JarFile(warFile)) {
@@ -445,7 +470,7 @@ public class JettyServer implements NiFiServer {
webappContext.setDisplayName(contextPath);
// instruction jetty to examine these jars for tlds, web-fragments, etc
- webappContext.setAttribute("org.eclipse.jetty.server.webapp.ContainerIncludeJarPattern", ".*/[^/]*servlet-api-[^/]*\\.jar$|.*/javax.servlet.jsp.jstl-.*\\\\.jar$|.*/[^/]*taglibs.*\\.jar$" );
+ webappContext.setAttribute("org.eclipse.jetty.server.webapp.ContainerIncludeJarPattern", ".*/[^/]*servlet-api-[^/]*\\.jar$|.*/javax.servlet.jsp.jstl-.*\\\\.jar$|.*/[^/]*taglibs.*\\.jar$");
// remove slf4j server class to allow WAR files to have slf4j dependencies in WEB-INF/lib
List serverClasses = new ArrayList<>(Arrays.asList(webappContext.getServerClasses()));
@@ -522,7 +547,7 @@ public class JettyServer implements NiFiServer {
/**
* Returns a File object for the directory containing NIFI documentation.
- *
+ *
* Formerly, if the docsDirectory did not exist NIFI would fail to start
* with an IllegalStateException and a rather unhelpful log message.
* NIFI-2184 updates the process such that if the docsDirectory does not
@@ -582,6 +607,8 @@ public class JettyServer implements NiFiServer {
httpConfiguration.setRequestHeaderSize(headerSize);
httpConfiguration.setResponseHeaderSize(headerSize);
+ addHostHeaderSanitizationCustomizer(httpConfiguration);
+
if (props.getPort() != null) {
final Integer port = props.getPort();
if (port < 0 || (int) Math.pow(2, 16) <= port) {
@@ -683,6 +710,7 @@ public class JettyServer implements NiFiServer {
httpsConfiguration.setSecureScheme("https");
httpsConfiguration.setSecurePort(props.getSslPort());
httpsConfiguration.addCustomizer(new SecureRequestCustomizer());
+ addHostHeaderSanitizationCustomizer(httpsConfiguration);
// build the connector
return new ServerConnector(server,
@@ -690,6 +718,18 @@ public class JettyServer implements NiFiServer {
new HttpConnectionFactory(httpsConfiguration));
}
+ private void addHostHeaderSanitizationCustomizer(HttpConfiguration httpConfiguration) {
+ // Add the HostHeaderCustomizer to the configuration
+ HttpConfiguration.Customizer hostHeaderCustomizer;
+ if (props.getSslPort() != null) {
+ hostHeaderCustomizer = new HostHeaderSanitizationCustomizer(props.getProperty(NiFiProperties.WEB_HTTPS_HOST), props.getSslPort());
+ } else {
+ hostHeaderCustomizer = new HostHeaderSanitizationCustomizer(props.getProperty(NiFiProperties.WEB_HTTP_HOST), props.getPort());
+ }
+ httpConfiguration.addCustomizer(hostHeaderCustomizer);
+ logger.info("Added HostHeaderSanitizationCustomizer to HttpConfiguration: " + hostHeaderCustomizer);
+ }
+
private SslContextFactory createSslContextFactory() {
final SslContextFactory contextFactory = new SslContextFactory();
configureSslContextFactory(contextFactory, props);
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java
index c1f2226f16..7118e01eef 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java
@@ -16,8 +16,40 @@
*/
package org.apache.nifi.web.api;
+import static javax.ws.rs.core.Response.Status.NOT_FOUND;
+import static org.apache.commons.lang3.StringUtils.isEmpty;
+import static org.apache.nifi.remote.protocol.http.HttpHeaders.LOCATION_URI_INTENT_NAME;
+import static org.apache.nifi.remote.protocol.http.HttpHeaders.LOCATION_URI_INTENT_VALUE;
+
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.core.CacheControl;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.MultivaluedHashMap;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.ResponseBuilder;
+import javax.ws.rs.core.UriBuilder;
+import javax.ws.rs.core.UriBuilderException;
+import javax.ws.rs.core.UriInfo;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.authorization.AuthorizableLookup;
import org.apache.nifi.authorization.AuthorizeAccess;
@@ -54,42 +86,10 @@ import org.apache.nifi.web.api.entity.Entity;
import org.apache.nifi.web.api.entity.TransactionResultEntity;
import org.apache.nifi.web.security.ProxiedEntitiesUtils;
import org.apache.nifi.web.security.util.CacheKey;
+import org.apache.nifi.web.util.WebUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.ws.rs.core.CacheControl;
-import javax.ws.rs.core.Context;
-import javax.ws.rs.core.MediaType;
-import javax.ws.rs.core.MultivaluedHashMap;
-import javax.ws.rs.core.MultivaluedMap;
-import javax.ws.rs.core.Response;
-import javax.ws.rs.core.Response.ResponseBuilder;
-import javax.ws.rs.core.UriBuilder;
-import javax.ws.rs.core.UriBuilderException;
-import javax.ws.rs.core.UriInfo;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.nio.charset.StandardCharsets;
-import java.util.Collections;
-import java.util.Enumeration;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.TreeMap;
-import java.util.UUID;
-import java.util.concurrent.TimeUnit;
-import java.util.function.BiFunction;
-import java.util.function.Consumer;
-import java.util.function.Function;
-
-import static javax.ws.rs.core.Response.Status.NOT_FOUND;
-import static org.apache.commons.lang3.StringUtils.isEmpty;
-import static org.apache.nifi.remote.protocol.http.HttpHeaders.LOCATION_URI_INTENT_NAME;
-import static org.apache.nifi.remote.protocol.http.HttpHeaders.LOCATION_URI_INTENT_VALUE;
-
/**
* Base class for controllers.
*/
@@ -115,10 +115,10 @@ public abstract class ApplicationResource {
public static final String NODEWISE = "false";
@Context
- private HttpServletRequest httpServletRequest;
+ protected HttpServletRequest httpServletRequest;
@Context
- private UriInfo uriInfo;
+ protected UriInfo uriInfo;
protected NiFiProperties properties;
private RequestReplicator requestReplicator;
@@ -141,26 +141,14 @@ public abstract class ApplicationResource {
try {
// check for proxy settings
+
final String scheme = getFirstHeaderValue(PROXY_SCHEME_HTTP_HEADER, FORWARDED_PROTO_HTTP_HEADER);
final String host = getFirstHeaderValue(PROXY_HOST_HTTP_HEADER, FORWARDED_HOST_HTTP_HEADER);
final String port = getFirstHeaderValue(PROXY_PORT_HTTP_HEADER, FORWARDED_PORT_HTTP_HEADER);
- String baseContextPath = getFirstHeaderValue(PROXY_CONTEXT_PATH_HTTP_HEADER, FORWARDED_CONTEXT_HTTP_HEADER);
- // if necessary, prepend the context path
- String resourcePath = uri.getPath();
- if (baseContextPath != null) {
- // normalize context path
- if (!baseContextPath.startsWith("/")) {
- baseContextPath = "/" + baseContextPath;
- }
-
- if (baseContextPath.endsWith("/")) {
- baseContextPath = StringUtils.substringBeforeLast(baseContextPath, "/");
- }
-
- // determine the complete resource path
- resourcePath = baseContextPath + resourcePath;
- }
+ // Catch header poisoning
+ String whitelistedContextPaths = properties.getWhitelistedContextPaths();
+ String resourcePath = WebUtils.getResourcePath(uri, httpServletRequest, whitelistedContextPaths);
// determine the port uri
int uriPort = uri.getPort();
@@ -462,13 +450,13 @@ public abstract class ApplicationResource {
/**
* Authorizes the specified process group.
*
- * @param processGroupAuthorizable process group
- * @param authorizer authorizer
- * @param lookup lookup
- * @param action action
- * @param authorizeReferencedServices whether to authorize referenced services
- * @param authorizeTemplates whether to authorize templates
- * @param authorizeControllerServices whether to authorize controller services
+ * @param processGroupAuthorizable process group
+ * @param authorizer authorizer
+ * @param lookup lookup
+ * @param action action
+ * @param authorizeReferencedServices whether to authorize referenced services
+ * @param authorizeTemplates whether to authorize templates
+ * @param authorizeControllerServices whether to authorize controller services
*/
protected void authorizeProcessGroup(final ProcessGroupAuthorizable processGroupAuthorizable, final Authorizer authorizer, final AuthorizableLookup lookup, final RequestAction action,
final boolean authorizeReferencedServices, final boolean authorizeTemplates,
@@ -857,8 +845,8 @@ public abstract class ApplicationResource {
/**
* Replicates the request to the given node
*
- * @param method the HTTP method
- * @param entity the Entity to replicate
+ * @param method the HTTP method
+ * @param entity the Entity to replicate
* @param nodeUuid the UUID of the node to replicate the request to
* @return the response from the node
* @throws UnknownNodeException if the nodeUuid given does not map to any node in the cluster
@@ -963,7 +951,7 @@ public abstract class ApplicationResource {
* @throws InterruptedException if interrupted while replicating the request
*/
protected NodeResponse replicateNodeResponse(final String method) throws InterruptedException {
- return replicateNodeResponse(method, getRequestParameters(), (Map) null);
+ return replicateNodeResponse(method, getRequestParameters(), null);
}
/**
@@ -1084,8 +1072,8 @@ public abstract class ApplicationResource {
return properties;
}
- public static enum ReplicationTarget {
- CLUSTER_NODES, CLUSTER_COORDINATOR;
+ public enum ReplicationTarget {
+ CLUSTER_NODES, CLUSTER_COORDINATOR
}
// -----------------
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/groovy/org/apache/nifi/web/api/ApplicationResourceTest.groovy b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/groovy/org/apache/nifi/web/api/ApplicationResourceTest.groovy
new file mode 100644
index 0000000000..7e773c19db
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/groovy/org/apache/nifi/web/api/ApplicationResourceTest.groovy
@@ -0,0 +1,181 @@
+/*
+ * 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.api
+
+import org.apache.nifi.properties.StandardNiFiProperties
+import org.apache.nifi.util.NiFiProperties
+import org.glassfish.jersey.uri.internal.JerseyUriBuilder
+import org.junit.After
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+import javax.servlet.http.HttpServletRequest
+import javax.ws.rs.core.UriBuilderException
+import javax.ws.rs.core.UriInfo
+
+@RunWith(JUnit4.class)
+class ApplicationResourceTest extends GroovyTestCase {
+ private static final Logger logger = LoggerFactory.getLogger(ApplicationResourceTest.class)
+
+ static final String PROXY_SCHEME_HTTP_HEADER = "X-ProxyScheme"
+ static final String PROXY_PORT_HTTP_HEADER = "X-ProxyPort"
+ static final String PROXY_CONTEXT_PATH_HTTP_HEADER = "X-ProxyContextPath"
+
+ static final String FORWARDED_PROTO_HTTP_HEADER = "X-Forwarded-Proto"
+ static final String FORWARDED_PORT_HTTP_HEADER = "X-Forwarded-Port"
+ static final String FORWARDED_CONTEXT_HTTP_HEADER = "X-Forwarded-Context"
+
+ static final String PROXY_CONTEXT_PATH_PROP = NiFiProperties.WEB_PROXY_CONTEXT_PATH
+ static final String WHITELISTED_PATH = "/some/context/path"
+
+ @BeforeClass
+ static void setUpOnce() throws Exception {
+ logger.metaClass.methodMissing = { String name, args ->
+ logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}")
+ }
+ }
+
+ @Before
+ void setUp() throws Exception {
+ }
+
+ @After
+ void tearDown() throws Exception {
+ }
+
+ class MockApplicationResource extends ApplicationResource {
+ void setHttpServletRequest(HttpServletRequest request) {
+ super.httpServletRequest = request
+ }
+
+ void setUriInfo(UriInfo uriInfo) {
+ super.uriInfo = uriInfo
+ }
+ }
+
+ private ApplicationResource buildApplicationResource() {
+ ApplicationResource resource = new MockApplicationResource()
+ HttpServletRequest mockRequest = [getHeader: { String k ->
+ logger.mock("Request.getHeader($k)")
+ if ([FORWARDED_CONTEXT_HTTP_HEADER, PROXY_CONTEXT_PATH_HTTP_HEADER].contains(k)) {
+ WHITELISTED_PATH
+ } else if ([FORWARDED_PORT_HTTP_HEADER, PROXY_PORT_HTTP_HEADER].contains(k)) {
+ "8081"
+ } else if ([FORWARDED_PROTO_HTTP_HEADER, PROXY_SCHEME_HTTP_HEADER].contains(k)) {
+ "https"
+ } else {
+ "nifi.apache.org"
+ }
+ }, getContextPath: { ->
+ logger.mock("Request.getContextPath()")
+ ""
+ }] as HttpServletRequest
+
+ UriInfo mockUriInfo = [getBaseUriBuilder: { ->
+ logger.mock("Returning mock UriBuilder")
+ new JerseyUriBuilder().uri(new URI('https://nifi.apache.org/'))
+ }] as UriInfo
+
+ resource.setHttpServletRequest(mockRequest)
+ resource.setUriInfo(mockUriInfo)
+ resource.properties = new StandardNiFiProperties()
+
+ resource
+ }
+
+ @Test
+ void testGenerateUriShouldBlockProxyContextPathHeaderIfNotInWhitelist() throws Exception {
+ // Arrange
+ ApplicationResource resource = buildApplicationResource()
+ logger.info("Whitelisted path(s): ")
+
+ // Act
+ def msg = shouldFail(UriBuilderException) {
+ String generatedUri = resource.generateResourceUri('actualResource')
+ logger.unexpected("Generated URI: ${generatedUri}")
+ }
+
+ // Assert
+ logger.expected(msg)
+ assert msg =~ "The provided context path \\[.*\\] was not whitelisted \\[\\]"
+ }
+
+ @Test
+ void testGenerateUriShouldAllowProxyContextPathHeaderIfInWhitelist() throws Exception {
+ // Arrange
+ ApplicationResource resource = buildApplicationResource()
+ logger.info("Whitelisted path(s): ${WHITELISTED_PATH}")
+ NiFiProperties niFiProperties = new StandardNiFiProperties([(PROXY_CONTEXT_PATH_PROP): WHITELISTED_PATH] as Properties)
+ resource.properties = niFiProperties
+
+ // Act
+ String generatedUri = resource.generateResourceUri('actualResource')
+ logger.info("Generated URI: ${generatedUri}")
+
+ // Assert
+ assert generatedUri == "https://nifi.apache.org:8081${WHITELISTED_PATH}/actualResource"
+ }
+
+ @Test
+ void testGenerateUriShouldAllowProxyContextPathHeaderIfElementInMultipleWhitelist() throws Exception {
+ // Arrange
+ ApplicationResource resource = buildApplicationResource()
+ String multipleWhitelistedPaths = [WHITELISTED_PATH, "another/path", "a/third/path"].join(",")
+ logger.info("Whitelisted path(s): ${multipleWhitelistedPaths}")
+ NiFiProperties niFiProperties = new StandardNiFiProperties([(PROXY_CONTEXT_PATH_PROP): multipleWhitelistedPaths] as Properties)
+ resource.properties = niFiProperties
+
+ // Act
+ String generatedUri = resource.generateResourceUri('actualResource')
+ logger.info("Generated URI: ${generatedUri}")
+
+ // Assert
+ assert generatedUri == "https://nifi.apache.org:8081${WHITELISTED_PATH}/actualResource"
+ }
+
+ @Test
+ void testGenerateUriShouldBlockForwardedContextHeaderIfNotInWhitelist() throws Exception {
+ // Arrange
+
+ // Act
+
+ // Assert
+ }
+
+ @Test
+ void testGenerateUriShouldAllowForwardedContextHeaderIfInWhitelist() throws Exception {
+ // Arrange
+
+ // Act
+
+ // Assert
+ }
+
+ @Test
+ void testGenerateUriShouldAllowForwardedContextHeaderIfElementInMultipleWhitelist() throws Exception {
+ // Arrange
+
+ // Act
+
+ // Assert
+ }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/resources/logback-test.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/resources/logback-test.xml
index 6bbe80025e..3f875e48b7 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/resources/logback-test.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/resources/logback-test.xml
@@ -30,6 +30,7 @@
+
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/pom.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/pom.xml
index 7b102a8022..9437a3a510 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/pom.xml
@@ -31,5 +31,10 @@
javax.servletjavax.servlet-api
+
+ org.apache.nifi
+ nifi-web-utils
+ provided
+
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/src/main/java/org/apache/nifi/web/filter/CatchAllFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/src/main/java/org/apache/nifi/web/filter/CatchAllFilter.java
index eeb3c1f3f0..71d1a2b46c 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/src/main/java/org/apache/nifi/web/filter/CatchAllFilter.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/src/main/java/org/apache/nifi/web/filter/CatchAllFilter.java
@@ -23,18 +23,35 @@ import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.UriBuilderException;
+import org.apache.nifi.web.util.WebUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* Filter for forward all requests to index.jsp.
*/
public class CatchAllFilter implements Filter {
+ private static final Logger logger = LoggerFactory.getLogger(CatchAllFilter.class);
+
+ private static String whitelistedContextPaths = "";
@Override
public void init(FilterConfig filterConfig) throws ServletException {
+ String providedWhitelist = filterConfig.getServletContext().getInitParameter("whitelistedContextPaths");
+ logger.debug("CatchAllFilter received provided whitelisted context paths from NiFi properties: " + providedWhitelist);
+ if (providedWhitelist != null) {
+ whitelistedContextPaths = providedWhitelist;
+ }
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
+ // Capture the provided context path headers and sanitize them before using in the response
+ String contextPath = getSanitizedContextPath(request);
+ request.setAttribute("contextPath", contextPath);
+
// for all requests to index.jsp
request.getRequestDispatcher("/index.jsp").forward(request, response);
}
@@ -42,4 +59,23 @@ public class CatchAllFilter implements Filter {
@Override
public void destroy() {
}
+
+ /**
+ * Returns a "safe" context path value from the request headers to use in a proxy environment.
+ * This is used on the JSP to build the resource paths for the external resources (CSS, JS, etc.).
+ * If no headers are present specifying this value, it is an empty string.
+ *
+ * @param request the HTTP request
+ * @return the context path safe to be printed to the page
+ */
+ private String getSanitizedContextPath(ServletRequest request) {
+ String contextPath = WebUtils.normalizeContextPath(WebUtils.determineContextPath((HttpServletRequest) request));
+ try {
+ WebUtils.verifyContextPath(whitelistedContextPaths, contextPath);
+ return contextPath;
+ } catch (UriBuilderException e) {
+ logger.error("Error determining context path on index.jsp: " + e.getMessage());
+ return "";
+ }
+ }
}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/src/main/webapp/index.jsp b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/src/main/webapp/index.jsp
index e029a80d6c..37ea6423a8 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/src/main/webapp/index.jsp
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-error/src/main/webapp/index.jsp
@@ -17,36 +17,29 @@
<%@ page contentType="text/html" pageEncoding="UTF-8" session="false" %>
- <%
- String contextPath = request.getHeader("X-ProxyContextPath");
- if (contextPath == null) {
- contextPath = request.getHeader("X-Forwarded-Context");
- }
- if (contextPath == null) {
- contextPath = "";
- }
- if (contextPath.endsWith("/")) {
- contextPath = contextPath.substring(0, contextPath.length() - 1);
- }
- %>
-
-
-
- NiFi
-
-
-
-
-
-
-
+<%
+ // Sanitize the contextPath to ensure it is on this server
+ // rather than getting it from the header directly
+ String contextPath = request.getAttribute("contextPath").toString();
+%>
+
+
+
+ NiFi
+
+
+
+
+
+
+
-
-