This is an automated email from the ASF dual-hosted git repository.
alopresto pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new dbee774 NIFI-7304 Removed default value for
nifi.web.max.content.size. Added Bundle#toString() method. Refactored
implementation of filter addition logic. Added logging. Added unit tests to
check for filter enablement. Introduced content-length exception handling in
StandardPublicPort. Added filter bypass functionality for framework requests in
ContentLengthFilter. Updated property documentation in Admin Guide. Renamed
methods & added Javadoc to clarify purpose of filters in Jet [...]
dbee774 is described below
commit dbee774c5b95121b2d89621fc66f8a215c15ad7c
Author: Andy LoPresto <[email protected]>
AuthorDate: Wed Apr 1 20:20:38 2020 -0700
NIFI-7304 Removed default value for nifi.web.max.content.size.
Added Bundle#toString() method.
Refactored implementation of filter addition logic.
Added logging.
Added unit tests to check for filter enablement.
Introduced content-length exception handling in StandardPublicPort.
Added filter bypass functionality for framework requests in
ContentLengthFilter.
Updated property documentation in Admin Guide.
Renamed methods & added Javadoc to clarify purpose of filters in
JettyServer.
Cleaned up conditional logic in StandardPublicPort.
Moved ContentLengthFilterTest to correct module.
Refactored unit tests for accuracy and clarity.
Fixed remaining merge conflict due to method renaming.
Signed-off-by: Joe Witt <[email protected]>
---
.../java/org/apache/nifi/util/NiFiProperties.java | 9 +-
.../org/apache/nifi/util/NiFiPropertiesTest.java | 5 +-
.../src/main/asciidoc/administration-guide.adoc | 2 +-
.../main/java/org/apache/nifi/bundle/Bundle.java | 5 +
.../StandardNiFiPropertiesGroovyTest.groovy | 16 ++
.../nifi-framework/nifi-resources/pom.xml | 2 +-
.../org/apache/nifi/remote/StandardPublicPort.java | 61 +++--
.../remote/StandardPublicPortGroovyTest.groovy | 116 ++++++++
.../org/apache/nifi/web/server/JettyServer.java | 98 +++++--
.../nifi/web/server/JettyServerGroovyTest.groovy | 81 ++++++
.../nifi/web/filter/ContentLengthFilterTest.java | 185 -------------
.../src/test/resources/logback-test.xml | 2 +
.../web/security/requests/ContentLengthFilter.java | 36 +++
.../requests/ContentLengthFilterTest.groovy | 297 +++++++++++++++++++++
.../src/test/resources/logback-test.xml | 6 +-
15 files changed, 685 insertions(+), 236 deletions(-)
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 662f5d0..5a74f73 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
@@ -653,8 +653,15 @@ public abstract class NiFiProperties {
return getProperty(WEB_MAX_HEADER_SIZE, DEFAULT_WEB_MAX_HEADER_SIZE);
}
+ /**
+ * Returns the {@code nifi.web.max.content.size} value from {@code
nifi.properties}.
+ * Does not provide a default value because the presence of any value here
enables the
+ * {@code ContentLengthFilter}.
+ *
+ * @return the specified max content-length and units for an incoming HTTP
request
+ */
public String getWebMaxContentSize() {
- return getProperty(WEB_MAX_CONTENT_SIZE, DEFAULT_WEB_MAX_CONTENT_SIZE);
+ return getProperty(WEB_MAX_CONTENT_SIZE);
}
public String getMaxWebRequestsPerSecond() {
diff --git
a/nifi-commons/nifi-properties/src/test/java/org/apache/nifi/util/NiFiPropertiesTest.java
b/nifi-commons/nifi-properties/src/test/java/org/apache/nifi/util/NiFiPropertiesTest.java
index da5e8da..27ced62 100644
---
a/nifi-commons/nifi-properties/src/test/java/org/apache/nifi/util/NiFiPropertiesTest.java
+++
b/nifi-commons/nifi-properties/src/test/java/org/apache/nifi/util/NiFiPropertiesTest.java
@@ -17,8 +17,9 @@
package org.apache.nifi.util;
import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -269,7 +270,7 @@ public class NiFiPropertiesTest {
}});
// Assert defaults match expectations:
- assertEquals(properties.getWebMaxContentSize(), "20 MB");
+ assertNull(properties.getWebMaxContentSize());
// Re-arrange with specific values:
final String size = "size value";
diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc
b/nifi-docs/src/main/asciidoc/administration-guide.adoc
index a7fcd62..ffa9d06 100644
--- a/nifi-docs/src/main/asciidoc/administration-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc
@@ -3275,7 +3275,7 @@ For example, when running in a Docker container or behind
a proxy (e.g. localhos
host[:port] that NiFi is bound to.
|`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 for PUT and POST requests. The
default value is `20 MB`.
+|`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`.
|`nifi.web.max.requests.per.second`|The maximum number of requests from a
connection per second. Requests in excess of this are first delayed, then
throttled.
|====
diff --git
a/nifi-framework-api/src/main/java/org/apache/nifi/bundle/Bundle.java
b/nifi-framework-api/src/main/java/org/apache/nifi/bundle/Bundle.java
index f8290cb..c49f9ba 100644
--- a/nifi-framework-api/src/main/java/org/apache/nifi/bundle/Bundle.java
+++ b/nifi-framework-api/src/main/java/org/apache/nifi/bundle/Bundle.java
@@ -45,4 +45,9 @@ public class Bundle {
public ClassLoader getClassLoader() {
return classLoader;
}
+
+ @Override
+ public String toString() {
+ return bundleDetails.toString();
+ }
}
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 a4412fa..c02dc01 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
@@ -991,4 +991,20 @@ class StandardNiFiPropertiesGroovyTest extends
GroovyTestCase {
assert niFiProperties.size() == 1
assert niFiProperties.getPropertyKeys() == ["key"] as Set
}
+
+
+ @Test
+ void testWebMaxContentSizeShouldDefaultToEmpty() {
+ // Arrange
+ Properties rawProps = new Properties(["nifi.web.max.content.size": ""])
+ NiFiProperties props = new StandardNiFiProperties(rawProps)
+ logger.info("Created a NiFiProperties instance with empty web max
content size property")
+
+ // Act
+ String webMaxContentSize = props.getWebMaxContentSize()
+ logger.info("Read from NiFiProperties instance: ${webMaxContentSize}")
+
+ // Assert
+ assert webMaxContentSize == ""
+ }
}
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 a93552a..4b70d7b 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
@@ -144,7 +144,7 @@
<nifi.web.max.header.size>16 KB</nifi.web.max.header.size>
<nifi.web.proxy.context.path />
<nifi.web.proxy.host />
- <nifi.web.max.content.size>20 MB</nifi.web.max.content.size>
+ <nifi.web.max.content.size/>
<nifi.web.max.requests.per.second>30000</nifi.web.max.requests.per.second>
<nifi.web.should.send.server.version>true</nifi.web.should.send.server.version>
<!-- nifi.properties: security properties -->
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-site-to-site/src/main/java/org/apache/nifi/remote/StandardPublicPort.java
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-site-to-site/src/main/java/org/apache/nifi/remote/StandardPublicPort.java
index d1ee69f..a74a99a 100644
---
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-site-to-site/src/main/java/org/apache/nifi/remote/StandardPublicPort.java
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-site-to-site/src/main/java/org/apache/nifi/remote/StandardPublicPort.java
@@ -16,6 +16,23 @@
*/
package org.apache.nifi.remote;
+import static java.util.Objects.requireNonNull;
+
+import java.io.IOException;
+import java.net.SocketTimeoutException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.apache.nifi.authorization.AuthorizationResult;
@@ -31,6 +48,7 @@ import org.apache.nifi.authorization.util.IdentityMappingUtil;
import org.apache.nifi.authorization.util.UserGroupUtil;
import org.apache.nifi.components.ValidationResult;
import org.apache.nifi.connectable.ConnectableType;
+import org.apache.nifi.connectable.Connection;
import org.apache.nifi.controller.AbstractPort;
import org.apache.nifi.controller.ProcessScheduler;
import org.apache.nifi.controller.ScheduledState;
@@ -40,6 +58,7 @@ import org.apache.nifi.processor.ProcessContext;
import org.apache.nifi.processor.ProcessSession;
import org.apache.nifi.processor.ProcessSessionFactory;
import org.apache.nifi.processor.Relationship;
+import org.apache.nifi.processor.exception.FlowFileAccessException;
import org.apache.nifi.processor.exception.ProcessException;
import org.apache.nifi.remote.codec.FlowFileCodec;
import org.apache.nifi.remote.exception.BadRequestException;
@@ -53,27 +72,10 @@ import org.apache.nifi.reporting.BulletinRepository;
import org.apache.nifi.reporting.ComponentType;
import org.apache.nifi.reporting.Severity;
import org.apache.nifi.scheduling.SchedulingStrategy;
+import org.apache.nifi.util.NiFiProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import java.io.IOException;
-import java.net.SocketTimeoutException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.ArrayBlockingQueue;
-import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
-
-import static java.util.Objects.requireNonNull;
-
public class StandardPublicPort extends AbstractPort implements PublicPort {
private static final String CATEGORY = "Site to Site";
@@ -262,9 +264,20 @@ public class StandardPublicPort extends AbstractPort
implements PublicPort {
return
receiveRequest.getProtocol().receiveFlowFiles(receiveRequest.getPeer(),
context, session, codec);
}
+ /**
+ * Returns {@code true} if the port is not a <em>local</em> input port
(remote input ports are
+ * handled by {@link StandardRemoteGroupPort}), or if the local input port
has at least one
+ * available connection.
+ *
+ * @return true if this port is valid
+ */
@Override
public boolean isValid() {
- return getConnectableType() == ConnectableType.INPUT_PORT ?
!getConnections(Relationship.ANONYMOUS).isEmpty() : true;
+ if (getConnectableType() == ConnectableType.INPUT_PORT) {
+ Set<Connection> availableConnections =
getConnections(Relationship.ANONYMOUS);
+ return !availableConnections.isEmpty();
+ }
+ return true;
}
@Override
@@ -561,6 +574,16 @@ public class StandardPublicPort extends AbstractPort
implements PublicPort {
throw e;
} catch (final ProtocolException e) {
throw new BadRequestException(e);
+ } catch (final IOException | FlowFileAccessException e) {
+ // The content length filter might be blocking the transmission
+ final String REQUEST_TOO_LONG_MSG = "Request input stream longer
than";
+ if (e.getMessage() != null &&
e.getMessage().contains(REQUEST_TOO_LONG_MSG)) {
+ logger.error("The content length filter (configured with {})
is blocking the site-to-site connection: {}",
NiFiProperties.WEB_MAX_CONTENT_SIZE, e.getMessage());
+ // Perhaps BRE causes the sender to back off?
+ throw new BadRequestException(e);
+ } else {
+ throw new ProcessException(e);
+ }
} catch (final Exception e) {
throw new ProcessException(e);
}
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-site-to-site/src/test/groovy/org/apache/nifi/remote/StandardPublicPortGroovyTest.groovy
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-site-to-site/src/test/groovy/org/apache/nifi/remote/StandardPublicPortGroovyTest.groovy
new file mode 100644
index 0000000..e9e9b6c
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-site-to-site/src/test/groovy/org/apache/nifi/remote/StandardPublicPortGroovyTest.groovy
@@ -0,0 +1,116 @@
+/*
+ * 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.remote
+
+
+import org.apache.nifi.authorization.Authorizer
+import org.apache.nifi.connectable.Connectable
+import org.apache.nifi.connectable.ConnectableType
+import org.apache.nifi.controller.ProcessScheduler
+import org.apache.nifi.properties.StandardNiFiProperties
+import org.apache.nifi.remote.protocol.CommunicationsSession
+import org.apache.nifi.remote.protocol.ServerProtocol
+import org.apache.nifi.reporting.BulletinRepository
+import org.apache.nifi.util.NiFiProperties
+import org.junit.After
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+@RunWith(JUnit4.class)
+class StandardPublicPortGroovyTest extends GroovyTestCase {
+ private static final Logger logger =
LoggerFactory.getLogger(StandardPublicPortGroovyTest.class)
+
+ @BeforeClass
+ static void setUpOnce() throws Exception {
+ logger.metaClass.methodMissing = { String name, args ->
+ logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}")
+ }
+ }
+
+ @Before
+ void setUp() {
+
+ }
+
+ @After
+ void tearDown() {
+
+ }
+
+ private static PublicPort createPublicPort(NiFiProperties niFiProperties) {
+ Authorizer mockAuthorizer = [:] as Authorizer
+ BulletinRepository mockBulletinRepository = [:] as BulletinRepository
+ ProcessScheduler mockProcessScheduler = [registerEvent: { Connectable
worker ->
+ logger.mock("Registered event for worker: ${worker}")
+ }] as ProcessScheduler
+
+ StandardPublicPort spp = new StandardPublicPort("id", "name",
TransferDirection.RECEIVE, ConnectableType.INPUT_PORT, mockAuthorizer,
mockBulletinRepository, mockProcessScheduler, false,
niFiProperties.getBoredYieldDuration(), [])
+ logger.info("Created SPP with mocked collaborators: ${spp}")
+ spp
+ }
+
+ // TODO: Implement test
+ @Ignore("Not yet implemented")
+ @Test
+ void testReceiveFlowFilesShouldHandleBlockedRequestDueToContentLength() {
+ // Arrange
+ Map badProps = [
+ (NiFiProperties.WEB_HTTP_HOST) : "localhost",
+ (NiFiProperties.WEB_HTTPS_HOST): "secure.host.com",
+ (NiFiProperties.WEB_THREADS) :
NiFiProperties.DEFAULT_WEB_THREADS
+ ]
+ NiFiProperties mockProps = [
+ getPort : { -> 8080 },
+ getSslPort : { -> 8443 },
+ getProperty: { String prop ->
+ String value = badProps[prop] ?: "no_value"
+ logger.mock("getProperty(${prop}) -> ${value}")
+ value
+ },
+ ] as StandardNiFiProperties
+
+ StandardPublicPort port = createPublicPort(mockProps)
+
+ final int LISTEN_SECS = 5
+
+ PeerDescription peerDescription = new PeerDescription("localhost",
8080, false)
+ CommunicationsSession mockCommunicationsSession = [:] as
CommunicationsSession
+ Peer peer = new Peer(peerDescription, mockCommunicationsSession,
"http://localhost", "")
+ ServerProtocol mockServerProtocol = [getRequestExpiration: { -> 500L
}] as ServerProtocol
+
+ // Act
+ port.onSchedulingStart()
+ logger.info("Listening on port for ${LISTEN_SECS} seconds")
+ long end = System.nanoTime() + LISTEN_SECS * 1_000_000_000
+ def responses = []
+ while (System.nanoTime() < end) {
+ responses << port.receiveFlowFiles(peer, mockServerProtocol)
+ logger.info("Received ${responses[-1]} flowfiles")
+ }
+ logger.info("Stopped listening on port")
+ logger.info("Received ${responses.sum()} total flowfiles")
+
+ // Assert
+ assert !responses.isEmpty()
+ }
+}
\ No newline at end of file
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 666fe00..4422612 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
@@ -209,7 +209,7 @@ public class JettyServer implements NiFiServer,
ExtensionUiLoader {
/**
* Instantiates this object but does not perform any configuration. Used
for unit testing.
*/
- JettyServer(Server server, NiFiProperties properties) {
+ JettyServer(Server server, NiFiProperties properties) {
this.server = server;
this.props = properties;
}
@@ -226,7 +226,7 @@ public class JettyServer implements NiFiServer,
ExtensionUiLoader {
File webDocsWar = null;
File webContentViewerWar = null;
Map<File, Bundle> otherWars = new HashMap<>();
- for (Map.Entry<File,Bundle> warBundleEntry :
warToBundleLookup.entrySet()) {
+ for (Map.Entry<File, Bundle> warBundleEntry :
warToBundleLookup.entrySet()) {
final File war = warBundleEntry.getKey();
final Bundle warBundle = warBundleEntry.getValue();
@@ -308,7 +308,7 @@ public class JettyServer implements NiFiServer,
ExtensionUiLoader {
@Override
public void loadExtensionUis(final Set<Bundle> bundles) {
- // Find and load any WARs contained within the set of bundles...
+ // Find and load any WARs contained within the set of bundles...
final Map<File, Bundle> warToBundleLookup = findWars(bundles);
final ExtensionUiInfo extensionUiInfo = loadWars(warToBundleLookup);
@@ -350,7 +350,7 @@ public class JettyServer implements NiFiServer,
ExtensionUiLoader {
}
private ExtensionUiInfo loadWars(final Map<File, Bundle>
warToBundleLookup) {
- // handlers for each war and init params for the web api
+ // handlers for each war and init params for the web api
final List<WebAppContext> webAppContexts = new ArrayList<>();
final Map<String, String> mimeMappings = new HashMap<>();
final Collection<WebAppContext> componentUiExtensionWebContexts = new
ArrayList<>();
@@ -363,7 +363,7 @@ public class JettyServer implements NiFiServer,
ExtensionUiLoader {
// deploy the other wars
if (!warToBundleLookup.isEmpty()) {
// ui extension organized by component type
- for (Map.Entry<File,Bundle> warBundleEntry :
warToBundleLookup.entrySet()) {
+ for (Map.Entry<File, Bundle> warBundleEntry :
warToBundleLookup.entrySet()) {
final File war = warBundleEntry.getKey();
final Bundle warBundle = warBundleEntry.getValue();
@@ -477,6 +477,7 @@ public class JettyServer implements NiFiServer,
ExtensionUiLoader {
bundles.forEach(bundle -> {
final BundleDetails details = bundle.getBundleDetails();
final File narDependencies = new
File(details.getWorkingDirectory(), "NAR-INF/bundled-dependencies");
+ logger.debug("Attempting to load bundle {} from {}", details,
narDependencies.getAbsolutePath());
if (narDependencies.isDirectory()) {
// list the wars from this nar
final File[] narDependencyDirs =
narDependencies.listFiles(WAR_FILTER);
@@ -484,6 +485,13 @@ public class JettyServer implements NiFiServer,
ExtensionUiLoader {
throw new IllegalStateException(String.format("Unable to
access working directory for NAR dependencies in: %s",
narDependencies.getAbsolutePath()));
}
+ if (logger.isDebugEnabled()) {
+ logger.debug("Found {} available WARs in {}",
narDependencyDirs.length, narDependencies.getAbsolutePath());
+ for (File f : narDependencyDirs) {
+ logger.debug("\t" + f.getAbsolutePath());
+ }
+ }
+
// add each war
for (final File war : narDependencyDirs) {
wars.put(war, bundle);
@@ -593,6 +601,7 @@ public class JettyServer implements NiFiServer,
ExtensionUiLoader {
webappContext.setMaxFormContentSize(600000);
// add HTTP security headers to all responses
+ // TODO: Allow more granular path configuration (e.g.
/nifi-api/site-to-site/ vs. /nifi-api/process-groups)
final String ALL_PATHS = "/*";
ArrayList<Class<? extends Filter>> filters =
new ArrayList<>(Arrays.asList(
@@ -604,8 +613,8 @@ public class JettyServer implements NiFiServer,
ExtensionUiLoader {
if(props.isHTTPSConfigured()) {
filters.add(StrictTransportSecurityFilter.class);
}
- filters.forEach( (filter) -> addFilters(filter, ALL_PATHS,
webappContext));
- addFiltersWithProps(ALL_PATHS, webappContext);
+ filters.forEach((filter) -> addFilters(filter, ALL_PATHS,
webappContext));
+ addDenialOfServiceFilters(ALL_PATHS, webappContext, props);
try {
// configure the class loader - webappClassLoader -> jetty nar ->
web app's nar -> ...
@@ -667,11 +676,27 @@ public class JettyServer implements NiFiServer,
ExtensionUiLoader {
}
/**
- * Adds configurable filters to the given context. Currently, this
implementation adds `DosFilter` and `ContentLengthFilter` filters.
- * @param path path spec for filters
- * @param webappContext context to which filters will be added
+ * Adds configurable filters relating to preventing denial of service
attacks to the given context. Currently, this implementation adds {@link
org.eclipse.jetty.servlets.DoSFilter} and {@link ContentLengthFilter} filters.
+ *
+ * @param path path spec for filters ({@code /*} by convention in
this class)
+ * @param webAppContext context to which filters will be added
+ * @param props the {@link NiFiProperties}
*/
- private void addFiltersWithProps(String path, WebAppContext webappContext)
{
+ private static void addDenialOfServiceFilters(String path, WebAppContext
webAppContext, NiFiProperties props) {
+ // Add the requests rate limiting filter to all requests
+ int maxWebRequestsPerSecond = determineMaxWebRequestsPerSecond(props);
+ addWebRequestRateLimitingFilter(path, webAppContext,
maxWebRequestsPerSecond);
+
+ // Only add the ContentLengthFilter if the property is explicitly set
(empty by default)
+ int maxRequestSize = determineMaxRequestSize(props);
+ if (maxRequestSize > 0) {
+ addContentLengthFilter(path, webAppContext, maxRequestSize);
+ } else {
+ logger.info("Not adding content-length filter because {} is not
set in nifi.properties", NiFiProperties.WEB_MAX_CONTENT_SIZE);
+ }
+ }
+
+ private static int determineMaxWebRequestsPerSecond(NiFiProperties props) {
int defaultMaxRequestsPerSecond =
Integer.parseInt(NiFiProperties.DEFAULT_WEB_MAX_REQUESTS_PER_SECOND);
int configuredMaxRequestsPerSecond = 0;
try {
@@ -680,31 +705,52 @@ public class JettyServer implements NiFiServer,
ExtensionUiLoader {
logger.warn("Exception parsing property " +
NiFiProperties.WEB_MAX_REQUESTS_PER_SECOND + "; using default value: " +
defaultMaxRequestsPerSecond);
}
- int maxRequestsPerSecond = configuredMaxRequestsPerSecond > 0 ?
configuredMaxRequestsPerSecond : defaultMaxRequestsPerSecond;
+ return configuredMaxRequestsPerSecond > 0 ?
configuredMaxRequestsPerSecond : defaultMaxRequestsPerSecond;
+ }
+
+ /**
+ * Adds the {@link org.eclipse.jetty.servlets.DoSFilter} to the specified
context and path. Limits incoming web requests to {@code
maxWebRequestsPerSecond} per second.
+ *
+ * @param path the path to apply this filter
+ * @param webAppContext the context to apply this filter
+ * @param maxWebRequestsPerSecond the maximum number of allowed requests
per second
+ */
+ private static void addWebRequestRateLimitingFilter(String path,
WebAppContext webAppContext, int maxWebRequestsPerSecond) {
FilterHolder holder = new FilterHolder(DoSFilter.class);
- holder.setInitParameters(new HashMap<String, String>(){{
- put("maxRequestsPerSec", String.valueOf(maxRequestsPerSecond));
+ holder.setInitParameters(new HashMap<String, String>() {{
+ put("maxRequestsPerSec", String.valueOf(maxWebRequestsPerSecond));
}});
holder.setName(DoSFilter.class.getSimpleName());
- logger.debug("Adding DoSFilter to context at path: " + path + " with
max req/sec: " + configuredMaxRequestsPerSecond);
- webappContext.addFilter(holder, path,
EnumSet.allOf(DispatcherType.class));
+ logger.debug("Adding DoSFilter to context at path: " + path + " with
max req/sec: " + maxWebRequestsPerSecond);
+ webAppContext.addFilter(holder, path,
EnumSet.allOf(DispatcherType.class));
+ }
- int defaultMaxRequestSize =
DataUnit.parseDataSize(NiFiProperties.DEFAULT_WEB_MAX_CONTENT_SIZE,
DataUnit.B).intValue();
- int configuredMaxRequestSize = 0;
+ private static int determineMaxRequestSize(NiFiProperties props) {
try {
- configuredMaxRequestSize =
DataUnit.parseDataSize(props.getWebMaxContentSize(), DataUnit.B).intValue();
+ final String webMaxContentSize = props.getWebMaxContentSize();
+ logger.debug("Read {} as {}", NiFiProperties.WEB_MAX_CONTENT_SIZE,
webMaxContentSize);
+ if (StringUtils.isNotBlank(webMaxContentSize)) {
+ int configuredMaxRequestSize =
DataUnit.parseDataSize(webMaxContentSize, DataUnit.B).intValue();
+ logger.debug("Parsed max content length as {} bytes",
configuredMaxRequestSize);
+ return configuredMaxRequestSize;
+ } else {
+ logger.info("Can't parse valid max content length from {}",
webMaxContentSize);
+ }
} catch (final IllegalArgumentException e) {
- logger.warn("Exception parsing property " +
NiFiProperties.WEB_MAX_CONTENT_SIZE + "; using default value: " +
defaultMaxRequestSize);
+ logger.warn("Exception parsing property {}; disabling content
length filter", NiFiProperties.WEB_MAX_CONTENT_SIZE);
+ logger.debug("Error during parsing: ", e);
}
+ return -1;
+ }
- int maxRequestSize = configuredMaxRequestSize > 0 ?
configuredMaxRequestSize : defaultMaxRequestSize;
- holder = new FilterHolder(ContentLengthFilter.class);
+ private static void addContentLengthFilter(String path, WebAppContext
webAppContext, int maxContentLength) {
+ FilterHolder holder = new FilterHolder(ContentLengthFilter.class);
holder.setInitParameters(new HashMap<String, String>() {{
- put("maxContentLength", String.valueOf(maxRequestSize));
+ put("maxContentLength", String.valueOf(maxContentLength));
}});
- holder.setName(FilterHolder.class.getSimpleName());
- logger.debug("Adding ContentLengthFilter to context at path: " + path
+ " with max request size: " + maxRequestSize + "B");
- webappContext.addFilter(holder, path,
EnumSet.allOf(DispatcherType.class));
+ holder.setName(ContentLengthFilter.class.getSimpleName());
+ logger.debug("Adding ContentLengthFilter to context at path: " + path
+ " with max request size: " + maxContentLength + "B");
+ webAppContext.addFilter(holder, path,
EnumSet.allOf(DispatcherType.class));
}
/**
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/test/groovy/org/apache/nifi/web/server/JettyServerGroovyTest.groovy
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/test/groovy/org/apache/nifi/web/server/JettyServerGroovyTest.groovy
index 59c38de..1cad4f8 100644
---
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/test/groovy/org/apache/nifi/web/server/JettyServerGroovyTest.groovy
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/test/groovy/org/apache/nifi/web/server/JettyServerGroovyTest.groovy
@@ -19,6 +19,7 @@ package org.apache.nifi.web.server
import org.apache.log4j.AppenderSkeleton
import org.apache.log4j.spi.LoggingEvent
import org.apache.nifi.bundle.Bundle
+import org.apache.nifi.processor.DataUnit
import org.apache.nifi.properties.StandardNiFiProperties
import org.apache.nifi.security.util.CertificateUtils
import org.apache.nifi.security.util.TlsConfiguration
@@ -29,7 +30,9 @@ import org.eclipse.jetty.server.HttpConfiguration
import org.eclipse.jetty.server.Server
import org.eclipse.jetty.server.ServerConnector
import org.eclipse.jetty.server.SslConnectionFactory
+import org.eclipse.jetty.servlet.FilterHolder
import org.eclipse.jetty.util.ssl.SslContextFactory
+import org.eclipse.jetty.webapp.WebAppContext
import org.junit.After
import org.junit.AfterClass
import org.junit.Assume
@@ -48,6 +51,7 @@ import org.slf4j.LoggerFactory
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
+import javax.servlet.DispatcherType
import java.nio.charset.StandardCharsets
import java.security.Security
@@ -388,6 +392,83 @@ class JettyServerGroovyTest extends GroovyTestCase {
assert (sslContextFactory._excludeProtocols as
List<String>).containsAll(LEGACY_TLS_PROTOCOLS)
assert sslContextFactory._selectedProtocols ==
EXPECTED_SELECTED_PROTOCOLS as String[]
}
+
+ @Test
+ void testShouldEnableContentLengthFilterIfWebMaxContentSizeSet() {
+ // Arrange
+ Map defaultProps = [
+ (NiFiProperties.WEB_HTTP_PORT) : "8080",
+ (NiFiProperties.WEB_HTTP_HOST) : "localhost",
+ (NiFiProperties.WEB_MAX_CONTENT_SIZE): "1 MB",
+ ]
+ NiFiProperties mockProps = new StandardNiFiProperties(new
Properties(defaultProps))
+
+ List<FilterHolder> filters = []
+ def mockWebContext = [
+ addFilter: { FilterHolder fh, String path,
EnumSet<DispatcherType> d ->
+ logger.mock("Called addFilter(${fh.name}, ${path}, ${d})")
+ filters.add(fh)
+ fh
+ }] as WebAppContext
+
+ JettyServer jettyServer = new JettyServer(new Server(), mockProps)
+ logger.info("Created JettyServer: ${jettyServer.dump()}")
+
+ String path = "/mock"
+
+ final int MAX_CONTENT_LENGTH_BYTES =
DataUnit.parseDataSize(defaultProps[NiFiProperties.WEB_MAX_CONTENT_SIZE],
DataUnit.B).intValue()
+
+ // Act
+ jettyServer.addDenialOfServiceFilters(path, mockWebContext, mockProps)
+
+ // Assert
+ assert filters.size() == 2
+ def filterNames = filters*.name
+ logger.info("Web API Context has ${filters.size()} filters:
${filterNames.join(", ")}".toString())
+ assert filterNames.contains("DoSFilter")
+ assert filterNames.contains("ContentLengthFilter")
+
+ FilterHolder clfHolder = filters.find { it.name ==
"ContentLengthFilter" }
+ String maxContentLength =
clfHolder.getInitParameter("maxContentLength")
+ assert maxContentLength == MAX_CONTENT_LENGTH_BYTES as String
+
+ // Filter is not instantiated just by adding it
+// ContentLengthFilter clf = filters?.find { it.className ==
"ContentLengthFilter" }?.filter as ContentLengthFilter
+// assert clf.getMaxContentLength() == MAX_CONTENT_LENGTH_BYTES
+ }
+
+ @Test
+ void testShouldNotEnableContentLengthFilterIfWebMaxContentSizeEmpty() {
+ // Arrange
+ Map defaultProps = [
+ (NiFiProperties.WEB_HTTP_PORT): "8080",
+ (NiFiProperties.WEB_HTTP_HOST): "localhost",
+ ]
+ NiFiProperties mockProps = new StandardNiFiProperties(new
Properties(defaultProps))
+
+ List<FilterHolder> filters = []
+ def mockWebContext = [
+ addFilter: { FilterHolder fh, String path,
EnumSet<DispatcherType> d ->
+ logger.mock("Called addFilter(${fh.name}, ${path}, ${d})")
+ filters.add(fh)
+ fh
+ }] as WebAppContext
+
+ JettyServer jettyServer = new JettyServer(new Server(), mockProps)
+ logger.info("Created JettyServer: ${jettyServer.dump()}")
+
+ String path = "/mock"
+
+ // Act
+ jettyServer.addDenialOfServiceFilters(path, mockWebContext, mockProps)
+
+ // Assert
+ assert filters.size() == 1
+ def filterNames = filters*.name
+ logger.info("Web API Context has ${filters.size()} filters:
${filterNames.join(", ")}".toString())
+ assert filterNames.contains("DoSFilter")
+ assert !filterNames.contains("ContentLengthFilter")
+ }
}
class TestAppender extends AppenderSkeleton {
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/filter/ContentLengthFilterTest.java
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/filter/ContentLengthFilterTest.java
deleted file mode 100644
index 1dd8cf5..0000000
---
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/filter/ContentLengthFilterTest.java
+++ /dev/null
@@ -1,185 +0,0 @@
-/*
- * 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.filter;
-
-import java.io.IOException;
-import java.util.EnumSet;
-import java.util.concurrent.TimeUnit;
-import javax.servlet.DispatcherType;
-import javax.servlet.ServletException;
-import javax.servlet.ServletInputStream;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.apache.commons.lang3.StringUtils;
-import org.apache.nifi.web.security.requests.ContentLengthFilter;
-import org.eclipse.jetty.server.LocalConnector;
-import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.servlet.FilterHolder;
-import org.eclipse.jetty.servlet.ServletContextHandler;
-import org.eclipse.jetty.servlet.ServletHolder;
-import org.junit.After;
-import org.junit.Assert;
-import org.junit.Test;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ContentLengthFilterTest {
- private static final Logger logger =
LoggerFactory.getLogger(ContentLengthFilterTest.class);
-
- private static final int MAX_CONTENT_LENGTH = 1000;
- private static final int SERVER_IDLE_TIMEOUT = 2500; // only one request
needed + value large enough for slow systems
- private static final String POST_REQUEST = "POST /
HTTP/1.1\r\nContent-Length: %d\r\nHost: h\r\n\r\n%s";
- private static final String FORM_REQUEST = "POST /
HTTP/1.1\r\nContent-Length: %d\r\nHost: h\r\nContent-Type:
application/x-www-form-urlencoded\r\nAccept-Charset: UTF-8\r\n\r\n%s";
- public static final int FORM_CONTENT_SIZE = 128;
-
- private Server serverUnderTest;
- private LocalConnector localConnector;
- private ServletContextHandler contextUnderTest;
-
- @After
- public void stopServer() throws Exception {
- if (serverUnderTest != null && serverUnderTest.isRunning()) {
- serverUnderTest.stop();
- }
- }
-
- private void configureAndStartServer(HttpServlet servlet, int
maxFormContentSize) throws Exception {
- serverUnderTest = new Server();
- localConnector = new LocalConnector(serverUnderTest);
- localConnector.setIdleTimeout(SERVER_IDLE_TIMEOUT);
- serverUnderTest.addConnector(localConnector);
-
- contextUnderTest = new ServletContextHandler(serverUnderTest, "/");
- if (maxFormContentSize > 0) {
- contextUnderTest.setMaxFormContentSize(maxFormContentSize);
- }
- contextUnderTest.addServlet(new ServletHolder(servlet), "/*");
-
- // This only adds the ContentLengthFilter if a valid
maxFormContentSize is not provided
- if (maxFormContentSize < 0) {
- FilterHolder holder =
contextUnderTest.addFilter(ContentLengthFilter.class, "/*",
EnumSet.of(DispatcherType.REQUEST));
- holder.setInitParameter(ContentLengthFilter.MAX_LENGTH_INIT_PARAM,
String.valueOf(MAX_CONTENT_LENGTH));
- }
- serverUnderTest.start();
- }
-
- @Test
- public void testRequestsWithMissingContentLengthHeader() throws Exception {
- configureAndStartServer(new HttpServlet() {
- @Override
- public void doPost(HttpServletRequest req, HttpServletResponse
resp) throws ServletException, IOException {
- ServletInputStream input = req.getInputStream();
- while (!input.isFinished()) {
- input.read();
- }
- resp.setStatus(HttpServletResponse.SC_OK);
- }
- }, -1);
-
- // This shows that the ContentLengthFilter allows a request that does
not have a content-length header.
- String response = localConnector.getResponse("POST /
HTTP/1.0\r\n\r\n");
- Assert.assertFalse(StringUtils.containsIgnoreCase(response, "411
Length Required"));
- }
-
- @Test
- public void testRequestsWithContentLengthHeader() throws Exception {
- configureAndStartServer(new HttpServlet() {
- @Override
- public void doPost(HttpServletRequest req, HttpServletResponse
resp) throws ServletException, IOException {
- ServletInputStream input = req.getInputStream();
- while (!input.isFinished()) {
- input.read();
- }
- resp.setStatus(HttpServletResponse.SC_OK);
- }
- }, -1);
-
- int smallClaim = 150;
- int largeClaim = 2000;
-
- String incompletePayload = StringUtils.repeat("1", 10);
- String largePayload = StringUtils.repeat("1", largeClaim + 200);
-
- // This shows that the ContentLengthFilter rejects a request when the
client claims more than the max + sends more than the max:
- String response =
localConnector.getResponse(String.format(POST_REQUEST, largeClaim,
largePayload));
- Assert.assertTrue(StringUtils.containsIgnoreCase(response, "413
Payload Too Large"));
-
- // This shows that the ContentLengthFilter rejects a request when the
client claims more than the max + sends less the max:
- response = localConnector.getResponse(String.format(POST_REQUEST,
largeClaim, incompletePayload));
- Assert.assertTrue(StringUtils.containsIgnoreCase(response, "413
Payload Too Large"));
-
- // This shows that the ContentLengthFilter allows a request when it
claims less than the max + sends more than the max:
- response = localConnector.getResponse(String.format(POST_REQUEST,
smallClaim, largePayload));
- Assert.assertTrue(StringUtils.containsIgnoreCase(response, "200 OK"));
-
- // This shows that the server times out when the client claims less
than the max + sends less than the max + sends less than it claims to send:
- response = localConnector.getResponse(String.format(POST_REQUEST,
smallClaim, incompletePayload), 500, TimeUnit.MILLISECONDS);
- Assert.assertTrue(StringUtils.containsIgnoreCase(response, "500 Server
Error"));
- Assert.assertTrue(StringUtils.containsIgnoreCase(response, "Timeout"));
- }
-
- @Test
- public void testJettyMaxFormSize() throws Exception {
- // This shows that the jetty server option for 'maxFormContentSize' is
insufficient for our needs because it
- // catches requests like this:
-
- // Configure the server but do not apply the CLF because the
FORM_CONTENT_SIZE > 0
- configureAndStartServer(new HttpServlet() {
- @Override
- protected void doPost(HttpServletRequest req, HttpServletResponse
resp) throws ServletException, IOException {
- try {
- req.getParameterMap();
- ServletInputStream input = req.getInputStream();
- int count = 0;
- while (!input.isFinished()) {
- input.read();
- count += 1;
- }
- final int FORM_LIMIT_BYTES = FORM_CONTENT_SIZE +
"a=\n".length();
- if (count > FORM_LIMIT_BYTES) {
- logger.warn("Bytes read ({}) is larger than the limit
({})", count, FORM_LIMIT_BYTES);
-
resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Should not reach
this code.");
- } else {
- logger.warn("Bytes read ({}) is less than or equal to
the limit ({})", count, FORM_LIMIT_BYTES);
-
resp.sendError(HttpServletResponse.SC_EXPECTATION_FAILED, "Read Too Many
Bytes");
- }
- } catch (final Exception e) {
- // This is the jetty context returning a 400 from the
maxFormContentSize setting:
- if
(StringUtils.containsIgnoreCase(e.getCause().toString(), "Form is larger than
max length " + FORM_CONTENT_SIZE)) {
- logger.warn("Exception thrown by input stream: ", e);
-
resp.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Payload Too
Large");
- } else {
- logger.warn("Exception thrown by input stream: ", e);
- resp.sendError(HttpServletResponse.SC_FORBIDDEN,
"Should not reach this code, either.");
- }
- }
- }
- }, FORM_CONTENT_SIZE);
-
- // Test to catch a form submission that exceeds the FORM_CONTENT_SIZE
limit
- String form = "a=" + StringUtils.repeat("1", FORM_CONTENT_SIZE);
- String response =
localConnector.getResponse(String.format(FORM_REQUEST, form.length(), form));
- logger.info("Response: " + response);
- Assert.assertTrue(StringUtils.containsIgnoreCase(response, "413
Payload Too Large"));
-
-
- // But it does not catch requests like this:
- response = localConnector.getResponse(String.format(POST_REQUEST,
form.length(), form+form));
- Assert.assertTrue(StringUtils.containsIgnoreCase(response, "417 Read
Too Many Bytes"));
- }
-}
\ No newline at end of file
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 3f875e4..7dc2f30 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
@@ -31,6 +31,8 @@
<logger name="org.apache.nifi" level="INFO"/>
<logger name="org.apache.nifi.web.api" level="DEBUG"/>
+ <logger name="org.apache.nifi.web.server" level="DEBUG"/>
+ <logger name="org.apache.nifi.web.security.requests" level="TRACE"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/requests/ContentLengthFilter.java
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/requests/ContentLengthFilter.java
index 017d96f..10aa453 100644
---
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/requests/ContentLengthFilter.java
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/requests/ContentLengthFilter.java
@@ -17,6 +17,8 @@
package org.apache.nifi.web.security.requests;
import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
@@ -40,6 +42,8 @@ public class ContentLengthFilter implements Filter {
public final static int MAX_LENGTH_DEFAULT = 10_000_000;
private int maxContentLength;
+ private static final List<String> BYPASS_URI_PREFIXES =
Arrays.asList("/nifi-api/data-transfer", "/nifi-api/site-to-site");
+
public void init() {
maxContentLength = MAX_LENGTH_DEFAULT;
logger.debug("Filter initialized without configuration and set max
content length: " + formatSize(maxContentLength));
@@ -61,6 +65,13 @@ public class ContentLengthFilter implements Filter {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String httpMethod = httpRequest.getMethod();
+ // If the request is in the framework allow list, do not evaluate or
block based on content length
+ if (!isSubjectToFilter(httpRequest)) {
+ logger.trace("Request {} is not subject to content length checks",
httpRequest.getRequestURI());
+ chain.doFilter(request, response);
+ return;
+ }
+
// Check the HTTP method because the spec says clients don't have to
send a content-length header for methods
// that don't use it. So even though an attacker may provide a large
body in a GET request, the body should go
// unread and a size filter is unneeded at best. See RFC 2616 section
14.13, and RFC 1945 section 10.4.
@@ -91,6 +102,31 @@ public class ContentLengthFilter implements Filter {
}
/**
+ * Returns the currently configured max content length in bytes.
+ *
+ * @return the max content length
+ */
+ public int getMaxContentLength() {
+ return maxContentLength;
+ }
+
+ /**
+ * Returns {@code true} if this request is subject to the filter
operation, {@code false} if not.
+ *
+ * @param request the incoming request
+ * @return true if this request should be filtered
+ */
+ private boolean isSubjectToFilter(HttpServletRequest request) {
+ for (String uriPrefix : BYPASS_URI_PREFIXES) {
+ if (request.getRequestURI().startsWith(uriPrefix)) {
+ logger.debug("Incoming request {} matches filter bypass prefix
{}; content length filter is not applied", request.getRequestURI(), uriPrefix);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
* Formats a value like {@code 1048576} to {@code 1 MB} for easier human
consumption.
*
* @param byteSize the size in bytes
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/requests/ContentLengthFilterTest.groovy
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/requests/ContentLengthFilterTest.groovy
new file mode 100644
index 0000000..f4553e0
--- /dev/null
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/requests/ContentLengthFilterTest.groovy
@@ -0,0 +1,297 @@
+/*
+ * 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.security.requests
+
+import org.apache.commons.lang3.StringUtils
+import org.apache.nifi.stream.io.StreamUtils
+import org.eclipse.jetty.server.LocalConnector
+import org.eclipse.jetty.server.Server
+import org.eclipse.jetty.servlet.FilterHolder
+import org.eclipse.jetty.servlet.ServletContextHandler
+import org.eclipse.jetty.servlet.ServletHolder
+import org.junit.After
+import org.junit.Assert
+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.DispatcherType
+import javax.servlet.ServletException
+import javax.servlet.ServletInputStream
+import javax.servlet.http.HttpServlet
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
+import java.util.concurrent.TimeUnit
+
+@RunWith(JUnit4.class)
+class ContentLengthFilterTest extends GroovyTestCase {
+ private static final Logger logger =
LoggerFactory.getLogger(ContentLengthFilterTest.class)
+
+ private static final int MAX_CONTENT_LENGTH = 1000
+ private static final int SERVER_IDLE_TIMEOUT = 2500 // only one request
needed + value large enough for slow systems
+ private static final String POST_REQUEST = "POST /
HTTP/1.1\r\nContent-Length: %d\r\nHost: h\r\n\r\n%s"
+ private static final String FORM_REQUEST = "POST /
HTTP/1.1\r\nContent-Length: %d\r\nHost: h\r\nContent-Type:
application/x-www-form-urlencoded\r\nAccept-Charset: UTF-8\r\n\r\n%s"
+ public static final int FORM_CONTENT_SIZE = 128
+
+ // These variables hold data for content small enough to be allowed
+ private static final int SMALL_CLAIM_SIZE_BYTES = 150
+ private static final String SMALL_PAYLOAD = "1" * SMALL_CLAIM_SIZE_BYTES
+
+ // These variables hold data for content too large to be allowed
+ private static final int LARGE_CLAIM_SIZE_BYTES = 2000
+ private static final String LARGE_PAYLOAD = "1" * LARGE_CLAIM_SIZE_BYTES
+
+ private Server serverUnderTest
+ private LocalConnector localConnector
+ private ServletContextHandler contextUnderTest
+
+ @BeforeClass
+ static void setUpOnce() {
+ logger.metaClass.methodMissing = { String name, args ->
+ logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}")
+ }
+ }
+
+ @Before
+ void setUp() {
+
+ }
+
+ @After
+ void tearDown() {
+ stopServer()
+ }
+
+ void stopServer() throws Exception {
+ if (serverUnderTest && serverUnderTest.isRunning()) {
+ serverUnderTest.stop()
+ }
+ }
+
+ private void configureAndStartServer(HttpServlet servlet, int
maxFormContentSize) throws Exception {
+ serverUnderTest = new Server()
+ localConnector = new LocalConnector(serverUnderTest)
+ localConnector.setIdleTimeout(SERVER_IDLE_TIMEOUT)
+ serverUnderTest.addConnector(localConnector)
+
+ contextUnderTest = new ServletContextHandler(serverUnderTest, "/")
+ if (maxFormContentSize > 0) {
+ contextUnderTest.setMaxFormContentSize(maxFormContentSize)
+ }
+ contextUnderTest.addServlet(new ServletHolder(servlet), "/*")
+
+ // This only adds the ContentLengthFilter if a valid
maxFormContentSize is not provided
+ if (maxFormContentSize < 0) {
+ FilterHolder holder =
contextUnderTest.addFilter(ContentLengthFilter.class, "/*",
EnumSet.of(DispatcherType.REQUEST) as EnumSet<DispatcherType>)
+ holder.setInitParameter(ContentLengthFilter.MAX_LENGTH_INIT_PARAM,
String.valueOf(MAX_CONTENT_LENGTH))
+ }
+ serverUnderTest.start()
+ }
+
+ /**
+ * Initializes a server which consumes any provided request input stream
and returns HTTP 200. It has no
+ * {@code maxFormContentSize}, so the {@link ContentLengthFilter} is
applied. The response contains a header and the
+ * response body indicating the total number of request content bytes read.
+ *
+ * @throws Exception if there is a problem setting up the server
+ */
+ private void createSimpleReadServer() throws Exception {
+ HttpServlet mockServlet = [
+ doPost: { HttpServletRequest req, HttpServletResponse resp ->
+ byte[] byteBuffer = new byte[2048]
+ int bytesRead =
StreamUtils.fillBuffer(req.getInputStream(), byteBuffer, false)
+ resp.setHeader("Bytes-Read", bytesRead as String)
+ resp.setStatus(HttpServletResponse.SC_OK)
+ resp.getWriter().write("Read ${bytesRead} bytes of request
input")
+ }
+ ] as HttpServlet
+ configureAndStartServer(mockServlet, -1)
+ }
+
+ private static void logResponse(String response, String s = "Response: ") {
+ String responseId = String.valueOf(System.currentTimeMillis() % 100)
+ final String delimiterLine = "\n-----" + responseId + "-----\n"
+ String formattedResponse = s + delimiterLine + response + delimiterLine
+ logger.info(formattedResponse)
+ }
+
+ @Test
+ void testRequestsWithMissingContentLengthHeader() throws Exception {
+ createSimpleReadServer()
+
+ // This shows that the ContentLengthFilter allows a request that does
not have a content-length header.
+ String response = localConnector.getResponse("POST / HTTP/1.0\r\n\r\n")
+ Assert.assertFalse(StringUtils.containsIgnoreCase(response, "411
Length Required"))
+ }
+
+ /**
+ * This shows that the ContentLengthFilter rejects a request when the
client claims more than the max + sends more than
+ * the max.
+ */
+ @Test
+ void testShouldRejectRequestWithLongContentLengthHeader() throws Exception
{
+ // Arrange
+ createSimpleReadServer()
+ final String requestBody = String.format(POST_REQUEST,
LARGE_CLAIM_SIZE_BYTES, LARGE_PAYLOAD)
+ logger.info("Making request with CL: ${LARGE_CLAIM_SIZE_BYTES} and
actual length: ${LARGE_PAYLOAD.length()}")
+
+ // Act
+ String response = localConnector.getResponse(requestBody)
+ logResponse(response)
+
+ // Assert
+ assert response =~ "413 Payload Too Large"
+ }
+
+ /**
+ * This shows that the ContentLengthFilter rejects a request when the
client claims more than the max + sends less than
+ * the claim.
+ */
+ @Test
+ void testShouldRejectRequestWithLongContentLengthHeaderAndSmallPayload()
throws Exception {
+ // Arrange
+ createSimpleReadServer()
+
+ String incompletePayload = "1" * (SMALL_CLAIM_SIZE_BYTES / 2)
+ final String requestBody = String.format(POST_REQUEST,
LARGE_CLAIM_SIZE_BYTES, incompletePayload)
+ logger.info("Making request with CL: ${LARGE_CLAIM_SIZE_BYTES} and
actual length: ${incompletePayload.length()}")
+
+ // Act
+ String response = localConnector.getResponse(requestBody)
+ logResponse(response)
+
+ // Assert
+ assert response =~ "413 Payload Too Large"
+ }
+
+ /**
+ * This shows that the ContentLengthFilter <em>allows</em> a request when
the client claims less
+ * than the max + sends more than the max, but restricts the request body
to the stated content
+ * length size.
+ */
+ @Test
+ void testShouldRejectRequestWithSmallContentLengthHeaderAndLargePayload()
throws Exception {
+ // Arrange
+ createSimpleReadServer()
+ final String requestBody = String.format(POST_REQUEST,
SMALL_CLAIM_SIZE_BYTES, LARGE_PAYLOAD)
+ logger.info("Making request with CL: ${SMALL_CLAIM_SIZE_BYTES} and
actual length: ${LARGE_PAYLOAD.length()}")
+
+ // Act
+ String response = localConnector.getResponse(requestBody)
+ logResponse(response)
+
+ // Assert
+ assert response =~ "200"
+ assert response =~ "Bytes-Read: ${SMALL_CLAIM_SIZE_BYTES}"
+ assert response =~ "Read ${SMALL_CLAIM_SIZE_BYTES} bytes"
+
+ }
+
+ /**
+ * This shows that the server times out when the client claims less than
the max + sends less than the max + sends
+ * less than it claims to send.
+ */
+ @Test
+ void
testShouldTimeoutRequestWithSmallContentLengthHeaderAndSmallerPayload() throws
Exception {
+ // Arrange
+ createSimpleReadServer()
+
+ String smallerPayload = SMALL_PAYLOAD[0..(SMALL_PAYLOAD.length() / 2)]
+ final String requestBody = String.format(POST_REQUEST,
SMALL_CLAIM_SIZE_BYTES, smallerPayload)
+ logger.info("Making request with CL: ${SMALL_CLAIM_SIZE_BYTES} and
actual length: ${smallerPayload.length()}")
+
+ // Act
+ String response = localConnector.getResponse(requestBody, 500,
TimeUnit.MILLISECONDS)
+ logResponse(response)
+
+ // Assert
+ assert response =~ "500 Server Error"
+ assert response =~ "Timeout"
+ }
+
+ @Test
+ void testFilterShouldAllowSiteToSiteTransfer() throws Exception {
+ // Arrange
+ createSimpleReadServer()
+
+ final String SITE_TO_SITE_POST_REQUEST = "POST
/nifi-api/data-transfer/input-ports HTTP/1.1\r\nContent-Length: %d\r\nHost:
h\r\n\r\n%s"
+
+ final String siteToSiteRequest =
String.format(SITE_TO_SITE_POST_REQUEST, LARGE_CLAIM_SIZE_BYTES, LARGE_PAYLOAD)
+ logResponse(siteToSiteRequest, "Request: ")
+
+ // Act
+ String response = localConnector.getResponse(siteToSiteRequest)
+ logResponse(response)
+
+ // Assert
+ assert response =~ "200 OK"
+ }
+
+ @Test
+ void testJettyMaxFormSize() throws Exception {
+ // This shows that the jetty server option for 'maxFormContentSize' is
insufficient for our needs because it
+ // catches requests like this:
+
+ // Configure the server but do not apply the CLF because the
FORM_CONTENT_SIZE > 0
+ configureAndStartServer(new HttpServlet() {
+ @Override
+ protected void doPost(HttpServletRequest req, HttpServletResponse
resp) throws ServletException, IOException {
+ try {
+ req.getParameterMap()
+ ServletInputStream input = req.getInputStream()
+ int count = 0
+ while (!input.isFinished()) {
+ input.read()
+ count += 1
+ }
+ final int FORM_LIMIT_BYTES = FORM_CONTENT_SIZE +
"a=\n".length()
+ if (count > FORM_LIMIT_BYTES) {
+ logger.warn("Bytes read ({}) is larger than the limit
({})", count, FORM_LIMIT_BYTES)
+
resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Should not reach
this code.")
+ } else {
+ logger.warn("Bytes read ({}) is less than or equal to
the limit ({})", count, FORM_LIMIT_BYTES)
+
resp.sendError(HttpServletResponse.SC_EXPECTATION_FAILED, "Read Too Many Bytes")
+ }
+ } catch (final Exception e) {
+ // This is the jetty context returning a 400 from the
maxFormContentSize setting:
+ if
(StringUtils.containsIgnoreCase(e.getCause().toString(), "Form is larger than
max length " + FORM_CONTENT_SIZE)) {
+ logger.warn("Exception thrown by input stream: ", e)
+
resp.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Payload Too
Large")
+ } else {
+ logger.warn("Exception thrown by input stream: ", e)
+ resp.sendError(HttpServletResponse.SC_FORBIDDEN,
"Should not reach this code, either.")
+ }
+ }
+ }
+ }, FORM_CONTENT_SIZE)
+
+ // Test to catch a form submission that exceeds the FORM_CONTENT_SIZE
limit
+ String form = "a=" + "1" * FORM_CONTENT_SIZE
+ String response =
localConnector.getResponse(String.format(FORM_REQUEST, form.length(), form))
+ logResponse(response)
+ assert response =~ "413 Payload Too Large"
+
+
+ // But it does not catch requests like this:
+ response = localConnector.getResponse(String.format(POST_REQUEST,
form.length(), form + form))
+ assert response =~ "417 Read Too Many Bytes"
+ }
+}
\ No newline at end of file
diff --git
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/logback-test.xml
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/logback-test.xml
index fe5d27e..0aff690 100644
---
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/logback-test.xml
+++
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/logback-test.xml
@@ -29,7 +29,11 @@
</appender>
- <logger name="org.apache.nifi" level="TRACE"/>
+ <logger name="org.apache.nifi" level="INFO"/>
+ <logger name="org.apache.nifi.web.api" level="DEBUG"/>
+ <logger name="org.apache.nifi.web.server" level="DEBUG"/>
+ <logger name="org.apache.nifi.web.security.requests" level="DEBUG"/>
+
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>