This is an automated email from the ASF dual-hosted git repository.
exceptionfactory 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 6bf01d4a6b1 NIFI-16332 Fixed duplicate Content-Type header on
replicated upload requests (#11665)
6bf01d4a6b1 is described below
commit 6bf01d4a6b177b54c13f7caf627d0828833ef7e0
Author: Sönke Liebau <[email protected]>
AuthorDate: Thu Sep 10 18:01:39 2026 +0200
NIFI-16332 Fixed duplicate Content-Type header on replicated upload
requests (#11665)
Signed-off-by: David Handermann <[email protected]>
---
.../StandardUploadRequestReplicator.java | 14 +++-
...TestStandardUploadRequestReplicatorHeaders.java | 88 ++++++++++++++++++++++
2 files changed, 100 insertions(+), 2 deletions(-)
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/StandardUploadRequestReplicator.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/StandardUploadRequestReplicator.java
index 2847435bcde..deaa2a3cb80 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/StandardUploadRequestReplicator.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/StandardUploadRequestReplicator.java
@@ -176,7 +176,8 @@ public class StandardUploadRequestReplicator implements
UploadRequestReplicator
* <li>Start with any forwarded inbound servlet headers.</li>
* <li>Strip all {@link RequestReplicationHeader} names (prevent
spoofing).</li>
* <li>Strip hop-by-hop / transport-framing headers.</li>
- * <li>Apply explicit builder headers (filename, content-type, seed) so
upload metadata wins.</li>
+ * <li>Apply explicit builder headers (filename, content-type, seed)
case-insensitively so upload
+ * metadata wins without leaving a differently-cased inbound
duplicate.</li>
* <li>Apply user proxy headers and strip credentials (Authorization,
auth cookies, Host).</li>
* <li>Force-set {@code request-replicated} and {@code
execution-continue}.</li>
* </ol>
@@ -187,7 +188,16 @@ public class StandardUploadRequestReplicator implements
UploadRequestReplicator
ReplicationHeaderUtils.stripRequestReplicationHeaders(headers);
ReplicationHeaderUtils.stripHopByHopHeaders(headers);
- headers.putAll(uploadRequest.getHeaders());
+ // Apply explicit builder headers case-insensitively. Inbound header
names can arrive in a variety of lower-
+ // and upper-case variations, which we only have limited control over.
+ // So a plain putAll into the case-sensitive header map would risk
keeping duplicated headers that only differ
+ // in the capitalization.
+ // In the case of "Content-Type" this can create actual issues,
because requests with a duplicate default header
+ // have to be considered malformed and are rejected with http 400.
+ for (final Map.Entry<String, String> builderHeader :
uploadRequest.getHeaders().entrySet()) {
+
headers.keySet().removeIf(builderHeader.getKey()::equalsIgnoreCase);
+ headers.put(builderHeader.getKey(), builderHeader.getValue());
+ }
ReplicationHeaderUtils.applyUserProxyAndStripCredentials(headers,
uploadRequest.getUser());
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestStandardUploadRequestReplicatorHeaders.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestStandardUploadRequestReplicatorHeaders.java
index 64125696c88..871dd1015c5 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestStandardUploadRequestReplicatorHeaders.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestStandardUploadRequestReplicatorHeaders.java
@@ -31,6 +31,7 @@ import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Properties;
@@ -181,6 +182,93 @@ class TestStandardUploadRequestReplicatorHeaders {
assertEquals("explicit-name.txt", result.get(FILENAME_HEADER));
}
+ @Test
+ void testForwardedContentTypeCaseVariantCollapsesToSingleHeader() {
+ // Regression for the NAR upload 400 in cluster mode: HTTP/2 (and
Envoy) lowercase header names,
+ // so the forwarded inbound header arrives as "content-type" while the
builder adds "Content-Type".
+ // Without case-insensitive merging both survive in the outbound
request, and a duplicate singleton
+ // field such as Content-Type is rejected by the receiving node with
400.
+ final Map<String, String> forwarded = new HashMap<>();
+ forwarded.put("content-type", CONTENT_TYPE_VALUE);
+
+ final UploadRequest<String> request = buildUploadRequest(forwarded);
+ final Map<String, String> result =
replicator.buildOutboundHeaders(request);
+
+ final List<String> contentTypeKeys = result.keySet().stream()
+ .filter(CONTENT_TYPE_HEADER::equalsIgnoreCase)
+ .toList();
+ assertEquals(List.of(CONTENT_TYPE_HEADER), contentTypeKeys,
+ "Expected a single Content-Type header, but got: " +
result.keySet());
+ assertEquals(CONTENT_TYPE_VALUE, result.get(CONTENT_TYPE_HEADER));
+ }
+
+ @Test
+ void
testMultipleForwardedContentTypeCaseVariantsAllCollapseToSingleHeader() {
+ // Defensive: a single servlet request cannot deliver multiple
case-variants of one header
+ // (the container collapses them), but buildOutboundHeaders must still
remove every inbound
+ // case-variant, not just the first, so a differently-cased builder
header cannot leave strays.
+ final Map<String, String> forwarded = new HashMap<>();
+ forwarded.put("content-type", CONTENT_TYPE_VALUE);
+ forwarded.put("Content-TYPE", CONTENT_TYPE_VALUE);
+ forwarded.put("CONTENT-type", CONTENT_TYPE_VALUE);
+
+ final UploadRequest<String> request = buildUploadRequest(forwarded);
+ final Map<String, String> result =
replicator.buildOutboundHeaders(request);
+
+ final List<String> contentTypeKeys = result.keySet().stream()
+ .filter(CONTENT_TYPE_HEADER::equalsIgnoreCase)
+ .toList();
+ assertEquals(List.of(CONTENT_TYPE_HEADER), contentTypeKeys,
+ "Expected a single Content-Type header, but got: " +
result.keySet());
+ assertEquals(CONTENT_TYPE_VALUE, result.get(CONTENT_TYPE_HEADER));
+ }
+
+ @Test
+ void testUnrelatedForwardedHeadersUntouchedWhileCollisionCollapses() {
+ // The case-insensitive removal must be scoped to the builder's own
header names: a Content-Type
+ // collision collapses to one, while unrelated forwarded headers pass
through verbatim - exact key
+ // case preserved and value unchanged.
+ final Map<String, String> forwarded = new HashMap<>();
+ forwarded.put("content-type", CONTENT_TYPE_VALUE); // collides with
builder "Content-Type"
+ forwarded.put("Content-TYPE", CONTENT_TYPE_VALUE); // a second
variant
+ forwarded.put(CUSTOM_HEADER, CUSTOM_HEADER_VALUE); // unrelated,
mixed case
+ forwarded.put(CUSTOM_TOKEN_HEADER, CUSTOM_TOKEN_VALUE);
+
+ final UploadRequest<String> request = buildUploadRequest(forwarded);
+ final Map<String, String> result =
replicator.buildOutboundHeaders(request);
+
+ // Content-Type collapses to a single canonical entry with the
builder's value.
+ final List<String> contentTypeKeys = result.keySet().stream()
+ .filter(CONTENT_TYPE_HEADER::equalsIgnoreCase)
+ .toList();
+ assertEquals(List.of(CONTENT_TYPE_HEADER), contentTypeKeys,
+ "Expected a single Content-Type header, but got: " +
result.keySet());
+ assertEquals(CONTENT_TYPE_VALUE, result.get(CONTENT_TYPE_HEADER));
+
+ // Unrelated forwarded headers are untouched: exact key case preserved
and value unchanged.
+ assertTrue(result.containsKey(CUSTOM_HEADER));
+ assertEquals(CUSTOM_HEADER_VALUE, result.get(CUSTOM_HEADER));
+ assertTrue(result.containsKey(CUSTOM_TOKEN_HEADER));
+ assertEquals(CUSTOM_TOKEN_VALUE, result.get(CUSTOM_TOKEN_HEADER));
+ }
+
+ @Test
+ void testLowercaseForwardedFilenameCollapsesToSingleCanonicalHeader() {
+ // Same case-collision guard for the Filename header (lowercased
inbound + canonical builder header).
+ final Map<String, String> forwarded = new HashMap<>();
+ forwarded.put("filename", "forwarded-name.txt");
+
+ final UploadRequest<String> request = buildUploadRequest(forwarded);
+ final Map<String, String> result =
replicator.buildOutboundHeaders(request);
+
+ final List<String> filenameKeys = result.keySet().stream()
+ .filter(FILENAME_HEADER::equalsIgnoreCase)
+ .toList();
+ assertEquals(List.of(FILENAME_HEADER), filenameKeys,
+ "Expected a single, canonically-cased Filename header, but
got: " + result.keySet());
+ assertEquals(TEST_FILENAME, result.get(FILENAME_HEADER));
+ }
+
@Test
void testProxiedEntitiesSetFromUser() {
final Map<String, String> forwarded = new HashMap<>();