This is an automated email from the ASF dual-hosted git repository.
pvillard31 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 26e9952b2a6 NIFI-16417 Normalized Proxy Entity Headers for Request
Replication (#11765)
26e9952b2a6 is described below
commit 26e9952b2a6b0f5857c13037c84992f0ec5f2ace
Author: David Handermann <[email protected]>
AuthorDate: Tue Oct 6 16:06:20 2026 -0500
NIFI-16417 Normalized Proxy Entity Headers for Request Replication (#11765)
- Add validation for single instance of Proxy Entity and Cookie headers
regardless of casing
---
.../http/replication/ReplicationHeaderUtils.java | 38 ++++----
.../replication/ThreadPoolRequestReplicator.java | 17 ++--
.../client/StandardHttpReplicationClient.java | 5 +
.../replication/TestReplicationHeaderUtils.java | 103 ++++++++++++++++++++-
...TestStandardUploadRequestReplicatorHeaders.java | 41 +++++++-
.../client/TestStandardHttpReplicationClient.java | 27 ++++++
.../apache/nifi/web/api/ApplicationResource.java | 10 +-
.../nifi/web/api/TestApplicationResource.java | 44 +++++++++
.../security/x509/X509AuthenticationFilter.java | 18 ++++
.../x509/X509AuthenticationFilterTest.java | 103 +++++++++++++++++++++
.../authentication/x509/X509IdentityProvider.java | 16 ++++
.../x509/X509IdentityProviderTest.java | 99 ++++++++++++++++++++
12 files changed, 488 insertions(+), 33 deletions(-)
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ReplicationHeaderUtils.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ReplicationHeaderUtils.java
index 60be20e5cb4..84f320efcd1 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ReplicationHeaderUtils.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ReplicationHeaderUtils.java
@@ -25,8 +25,8 @@ import org.apache.nifi.web.security.http.SecurityHeader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.List;
import java.util.Map;
-import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -66,9 +66,10 @@ public final class ReplicationHeaderUtils {
/**
* Prepares headers for a replicated request. When a non-null user is
provided, the
* {@code X-ProxiedEntitiesChain} and {@code X-ProxiedEntityGroups}
headers are set so the
- * receiving node knows the request is on behalf of that user. When user
is {@code null},
- * these headers are omitted, indicating the request is made directly by
the cluster node
- * itself (e.g. for background connector state polling).
+ * receiving node knows the request is on behalf of that user. Existing
values for those
+ * header names are removed first, regardless of letter case, so the
framework value is the
+ * only one present. When user is {@code null}, these headers are omitted,
indicating the
+ * request is made directly by the cluster node itself (e.g. for
background connector state polling).
*
* <p>In all cases the {@code Authorization} header, auth-related cookies,
and the
* {@code Host} header are removed because the replicated call uses mTLS
for transport
@@ -78,6 +79,9 @@ public final class ReplicationHeaderUtils {
* @param user the user on whose behalf the request is being made, or
{@code null} for direct node requests
*/
public static void applyUserProxyAndStripCredentials(final Map<String,
String> headers, final NiFiUser user) {
+ removeHeader(headers, ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN);
+ removeHeader(headers, ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS);
+
if (user == null) {
logger.debug("No user provided: omitting proxied entities header
from request");
} else {
@@ -133,14 +137,19 @@ public final class ReplicationHeaderUtils {
}
static void removeHeader(final Map<String, String> headers, final String
headerNameSearch) {
- findHeaderName(headers, headerNameSearch).ifPresent(headers::remove);
+ if (headerNameSearch == null || headerNameSearch.isBlank()) {
+ return;
+ }
+
+ headers.keySet().removeIf(headerNameSearch::equalsIgnoreCase);
}
static void removeCookie(final Map<String, String> headers, final String
cookieName) {
- final Optional<String> cookieHeaderNameFound = findHeaderName(headers,
COOKIE_HEADER);
+ final List<String> cookieHeaderNames = headers.keySet().stream()
+ .filter(COOKIE_HEADER::equalsIgnoreCase)
+ .toList();
- if (cookieHeaderNameFound.isPresent()) {
- final String cookieHeaderName = cookieHeaderNameFound.get();
+ for (final String cookieHeaderName : cookieHeaderNames) {
final String rawCookies = headers.get(cookieHeaderName);
final String[] rawCookieParts = rawCookies.split(";");
final Set<String> filteredCookieParts = Stream.of(rawCookieParts)
@@ -155,17 +164,4 @@ public final class ReplicationHeaderUtils {
}
}
}
-
- /**
- * Find an HTTP header name in a map regardless of case, since HTTP/1.1
capitalises headers
- * but HTTP/2 returns lowercased headers.
- */
- static Optional<String> findHeaderName(final Map<String, String> headers,
final String headerName) {
- if (headerName == null || headerName.isBlank()) {
- return Optional.empty();
- }
- return headers.keySet().stream()
- .filter(headerName::equalsIgnoreCase)
- .findFirst();
- }
}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java
index 9fe079cc26f..c8788383e6e 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java
@@ -535,15 +535,20 @@ public class ThreadPoolRequestReplicator implements
RequestReplicator, Closeable
try {
final Map<String, String> cancelLockHeaders = new
HashMap<>(headers);
-
cancelLockHeaders.put(RequestReplicationHeader.CANCEL_TRANSACTION.getHeader(),
"true");
+
ReplicationHeaderUtils.removeHeader(cancelLockHeaders,
RequestReplicationHeader.CANCEL_TRANSACTION.getHeader());
+
cancelLockHeaders.put(RequestReplicationHeader.CANCEL_TRANSACTION.getHeader(),
Boolean.TRUE.toString());
final Thread cancelLockThread = new Thread(() -> {
- logger.debug("Found {} dissenting nodes for {}
{}; canceling claim request", dissentingCount, method, uri.getPath());
+ try {
+ logger.debug("Found {} dissenting nodes
for {} {}; canceling claim request", dissentingCount, method, uri.getPath());
- final PreparedRequest request =
httpClient.prepareRequest(method, cancelLockHeaders, entity);
- final Function<NodeIdentifier,
NodeHttpRequest> requestFactory =
- nodeId -> new NodeHttpRequest(request,
nodeId, createURI(uri, nodeId), null, clusterResponse);
+ final PreparedRequest request =
httpClient.prepareRequest(method, cancelLockHeaders, entity);
+ final Function<NodeIdentifier,
NodeHttpRequest> requestFactory =
+ nodeId -> new NodeHttpRequest(request,
nodeId, createURI(uri, nodeId), null, clusterResponse);
- submitAsyncRequest(nodeIds, requestFactory);
+ submitAsyncRequest(nodeIds,
requestFactory);
+ } catch (final Exception e) {
+ logger.error("Failed to cancel transaction
for {} {}", method, uri.getPath(), e);
+ }
});
cancelLockThread.setName("Cancel Flow Locks");
cancelLockThread.start();
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/client/StandardHttpReplicationClient.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/client/StandardHttpReplicationClient.java
index b010176fad6..6aa78bd9f88 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/client/StandardHttpReplicationClient.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/client/StandardHttpReplicationClient.java
@@ -165,6 +165,11 @@ public class StandardHttpReplicationClient implements
HttpReplicationClient {
}
final String headerValue = header.getValue();
+ final String preparedHeaderValue = preparedHeaders.get(headerName);
+ if (preparedHeaders.containsKey(headerName) &&
!Objects.equals(preparedHeaderValue, headerValue)) {
+ throw new IllegalArgumentException("Conflicting values for
HTTP header [%s]".formatted(headerName));
+ }
+
preparedHeaders.put(headerName, headerValue);
}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestReplicationHeaderUtils.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestReplicationHeaderUtils.java
index 1f9727de74f..ff647820dde 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestReplicationHeaderUtils.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestReplicationHeaderUtils.java
@@ -24,6 +24,8 @@ import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -36,12 +38,14 @@ class TestReplicationHeaderUtils {
private static final String TEST_USER_IDENTITY = "alice";
private static final String AUTHORIZATION_HEADER = "Authorization";
+ private static final String AUTHORIZATION_HEADER_UPPER = "AUTHORIZATION";
private static final String AUTHORIZATION_VALUE = "Bearer secret";
private static final String CUSTOM_HEADER = "X-Custom-Token";
private static final String CUSTOM_HEADER_VALUE = "custom-token-123";
private static final String COOKIE_HEADER = "Cookie";
+ private static final String COOKIE_HEADER_LOWER = "cookie";
private static final String HOST_HEADER = "Host";
private static final String HOST_VALUE = "original-host:8080";
@@ -55,6 +59,17 @@ class TestReplicationHeaderUtils {
private static final String SPOOFED_VALUE = "spoofed";
private static final String SHOULD_SURVIVE_VALUE = "should-survive";
+ private static final String PROXIED_ENTITIES_CHAIN_UPPER =
"X-PROXIEDENTITIESCHAIN";
+ private static final String PROXIED_ENTITIES_CHAIN_LOWER =
"x-proxiedentitieschain";
+ private static final String PROXIED_ENTITY_GROUPS_UPPER =
"X-PROXIEDENTITYGROUPS";
+ private static final String PROXIED_ENTITY_GROUPS_LOWER =
"x-proxiedentitygroups";
+
+ private static final String OTHER_IDENTITY = "other-user";
+ private static final String ANOTHER_IDENTITY = "another-user";
+ private static final String IDENTITY_PROVIDER_GROUP = "analysts";
+ private static final String OTHER_GROUP = "other-group";
+ private static final String ANOTHER_GROUP = "another-group";
+
@Test
void testApplyUserProxyAndStripCredentialsSetsProxiedEntities() {
final NiFiUser user = new
StandardNiFiUser.Builder().identity(TEST_USER_IDENTITY).build();
@@ -83,6 +98,53 @@ class TestReplicationHeaderUtils {
assertNull(headers.get(AUTHORIZATION_HEADER));
}
+ @Test
+ void testApplyUserProxyReplacesCaseVariantProxiedEntityHeaders() {
+ final NiFiUser user = new StandardNiFiUser.Builder()
+ .identity(TEST_USER_IDENTITY)
+ .identityProviderGroups(Set.of(IDENTITY_PROVIDER_GROUP))
+ .build();
+ final Map<String, String> headers = new HashMap<>();
+ headers.put(PROXIED_ENTITIES_CHAIN_UPPER,
formatEntities(OTHER_IDENTITY));
+ headers.put(PROXIED_ENTITIES_CHAIN_LOWER,
formatEntities(ANOTHER_IDENTITY));
+ headers.put(PROXIED_ENTITY_GROUPS_UPPER, formatEntities(OTHER_GROUP));
+ headers.put(PROXIED_ENTITY_GROUPS_LOWER,
formatEntities(ANOTHER_GROUP));
+
+ ReplicationHeaderUtils.applyUserProxyAndStripCredentials(headers,
user);
+
+ assertEquals(Set.of(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN),
matchingHeaderNames(headers, ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN));
+ assertEquals(Set.of(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS),
matchingHeaderNames(headers, ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS));
+
assertTrue(headers.get(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN).contains(TEST_USER_IDENTITY));
+
assertTrue(headers.get(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS).contains(IDENTITY_PROVIDER_GROUP));
+
assertFalse(headers.get(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN).contains(OTHER_IDENTITY));
+
assertFalse(headers.get(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN).contains(ANOTHER_IDENTITY));
+
assertFalse(headers.get(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS).contains(OTHER_GROUP));
+
assertFalse(headers.get(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS).contains(ANOTHER_GROUP));
+ }
+
+ @Test
+ void testApplyUserProxyWithNullUserRemovesExistingProxiedEntityHeaders() {
+ final Map<String, String> headers = new HashMap<>();
+ headers.put(PROXIED_ENTITIES_CHAIN_UPPER,
formatEntities(OTHER_IDENTITY));
+ headers.put(PROXIED_ENTITY_GROUPS_LOWER, formatEntities(OTHER_GROUP));
+
+ ReplicationHeaderUtils.applyUserProxyAndStripCredentials(headers,
null);
+
+ assertTrue(matchingHeaderNames(headers,
ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN).isEmpty());
+ assertTrue(matchingHeaderNames(headers,
ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS).isEmpty());
+ }
+
+ @Test
+ void testApplyUserProxyRemovesAllAuthorizationCaseVariants() {
+ final Map<String, String> headers = new HashMap<>();
+ headers.put(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE);
+ headers.put(AUTHORIZATION_HEADER_UPPER, AUTHORIZATION_VALUE);
+
+ ReplicationHeaderUtils.applyUserProxyAndStripCredentials(headers,
null);
+
+ assertTrue(matchingHeaderNames(headers,
AUTHORIZATION_HEADER).isEmpty());
+ }
+
@Test
void testStripRequestReplicationHeadersRemovesAllProtocolHeaders() {
final Map<String, String> headers = new HashMap<>();
@@ -130,8 +192,8 @@ class TestReplicationHeaderUtils {
@Test
void testStripReplicationMarkerHeadersCaseInsensitive() {
final Map<String, String> headers = new HashMap<>();
- headers.put("Request-Replicated", "true");
- headers.put("Request-Forwarded-To-Coordinator", "true");
+ headers.put("Request-Replicated", Boolean.TRUE.toString());
+ headers.put("Request-Forwarded-To-Coordinator",
Boolean.TRUE.toString());
headers.put("Replication-Target-Id", SHOULD_SURVIVE_VALUE);
ReplicationHeaderUtils.stripReplicationMarkerHeaders(headers);
@@ -144,8 +206,8 @@ class TestReplicationHeaderUtils {
@Test
void testStripRequestReplicationHeadersCaseInsensitive() {
final Map<String, String> headers = new HashMap<>();
- headers.put("Request-Replicated", "true");
- headers.put("EXECUTION-CONTINUE", "true");
+ headers.put("Request-Replicated", Boolean.TRUE.toString());
+ headers.put("EXECUTION-CONTINUE", Boolean.TRUE.toString());
ReplicationHeaderUtils.stripRequestReplicationHeaders(headers);
@@ -153,6 +215,19 @@ class TestReplicationHeaderUtils {
assertFalse(headers.containsKey("EXECUTION-CONTINUE"));
}
+ @Test
+ void testStripRequestReplicationHeadersRemovesEveryCaseVariant() {
+ final Map<String, String> headers = new HashMap<>();
+ headers.put("Request-Replicated", Boolean.TRUE.toString());
+ headers.put("request-replicated", Boolean.FALSE.toString());
+ headers.put("EXECUTION-CONTINUE", Boolean.TRUE.toString());
+ headers.put("execution-continue", Boolean.FALSE.toString());
+
+ ReplicationHeaderUtils.stripRequestReplicationHeaders(headers);
+
+ assertTrue(headers.isEmpty());
+ }
+
@Test
void testStripHopByHopHeaders() {
final Map<String, String> headers = new HashMap<>();
@@ -181,6 +256,7 @@ class TestReplicationHeaderUtils {
void testStripAuthCookies() {
final Map<String, String> headers = new HashMap<>();
headers.put(COOKIE_HEADER, "__Secure-Authorization-Bearer=token123;
__Secure-Request-Token=rt456; other=value");
+ headers.put(COOKIE_HEADER_LOWER,
"__Secure-Authorization-Bearer=other-token; keep=yes");
ReplicationHeaderUtils.applyUserProxyAndStripCredentials(headers,
null);
@@ -189,6 +265,11 @@ class TestReplicationHeaderUtils {
assertFalse(remaining.contains("__Secure-Authorization-Bearer"));
assertFalse(remaining.contains("__Secure-Request-Token"));
assertTrue(remaining.contains("other=value"));
+
+ final String lowerCaseCookies = headers.get(COOKIE_HEADER_LOWER);
+ assertNotNull(lowerCaseCookies);
+
assertFalse(lowerCaseCookies.contains("__Secure-Authorization-Bearer"));
+ assertTrue(lowerCaseCookies.contains("keep=yes"));
}
@Test
@@ -202,4 +283,18 @@ class TestReplicationHeaderUtils {
assertNull(headers.get(HOST_HEADER));
assertEquals(SHOULD_SURVIVE_VALUE, headers.get(CUSTOM_HEADER));
}
+
+ private static Set<String> matchingHeaderNames(final Map<String, String>
headers, final String headerName) {
+ return headers.keySet().stream()
+ .filter(headerName::equalsIgnoreCase)
+ .collect(Collectors.toSet());
+ }
+
+ private static String formatEntities(final String... entities) {
+ final StringBuilder formattedEntities = new StringBuilder();
+ for (final String entity : entities) {
+ formattedEntities.append('<').append(entity).append('>');
+ }
+ return formattedEntities.toString();
+ }
}
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 871dd1015c5..4df41d18e30 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
@@ -83,6 +83,12 @@ class TestStandardUploadRequestReplicatorHeaders {
private static final String SPOOFED_VALUE = "spoofed";
private static final String SPOOFED_TX_VALUE = "spoofed-tx";
+ private static final String PROXIED_ENTITIES_CHAIN_UPPER =
"X-PROXIEDENTITIESCHAIN";
+ private static final String PROXIED_ENTITIES_CHAIN_LOWER =
"x-proxiedentitieschain";
+ private static final String PROXIED_ENTITY_GROUPS_UPPER =
"X-PROXIEDENTITYGROUPS";
+ private static final String PROXIED_ENTITY_GROUPS_LOWER =
"x-proxiedentitygroups";
+ private static final String FORWARDED_GROUP = "admins";
+
private StandardUploadRequestReplicator replicator;
@BeforeEach
@@ -318,7 +324,7 @@ class TestStandardUploadRequestReplicatorHeaders {
.filename(TEST_FILENAME)
.identifier(TEST_IDENTIFIER)
.contents(new ByteArrayInputStream(new byte[0]))
- .header(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN, "<" +
EVIL_USER_IDENTITY + ">")
+ .header(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN,
formatEntities(EVIL_USER_IDENTITY))
.exampleRequestUri(TEST_REQUEST_URI)
.responseClass(String.class)
.successfulResponseStatus(SUCCESS_STATUS)
@@ -330,6 +336,31 @@ class TestStandardUploadRequestReplicatorHeaders {
assertTrue(result.get(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN).contains(REAL_USER_IDENTITY));
}
+ @Test
+ void testForwardedProxiedEntityCaseVariantsReplacedWithAuthenticatedUser()
{
+ final Map<String, String> forwarded = new HashMap<>();
+ forwarded.put(PROXIED_ENTITIES_CHAIN_UPPER,
formatEntities(EVIL_USER_IDENTITY));
+ forwarded.put(PROXIED_ENTITIES_CHAIN_LOWER,
formatEntities(EVIL_USER_IDENTITY));
+ forwarded.put(PROXIED_ENTITY_GROUPS_UPPER,
formatEntities(FORWARDED_GROUP));
+ forwarded.put(PROXIED_ENTITY_GROUPS_LOWER,
formatEntities(FORWARDED_GROUP));
+
+ final UploadRequest<String> request = buildUploadRequest(forwarded);
+ final Map<String, String> result =
replicator.buildOutboundHeaders(request);
+
+ final List<String> chainKeys = result.keySet().stream()
+
.filter(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN::equalsIgnoreCase)
+ .toList();
+ assertEquals(List.of(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN),
chainKeys);
+
assertTrue(result.get(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN).contains(TEST_USER_IDENTITY));
+
assertFalse(result.get(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN).contains(EVIL_USER_IDENTITY));
+
+ final List<String> groupKeys = result.keySet().stream()
+
.filter(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS::equalsIgnoreCase)
+ .toList();
+ assertEquals(List.of(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS),
groupKeys);
+
assertFalse(result.get(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS).contains(FORWARDED_GROUP));
+ }
+
@Test
void testAuthCookiesStrippedFromForwardedHeaders() {
final Map<String, String> forwarded = new HashMap<>();
@@ -392,4 +423,12 @@ class TestStandardUploadRequestReplicatorHeaders {
.successfulResponseStatus(SUCCESS_STATUS)
.build();
}
+
+ private static String formatEntities(final String... entities) {
+ final StringBuilder formattedEntities = new StringBuilder();
+ for (final String entity : entities) {
+ formattedEntities.append('<').append(entity).append('>');
+ }
+ return formattedEntities.toString();
+ }
}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/client/TestStandardHttpReplicationClient.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/client/TestStandardHttpReplicationClient.java
index fbbd4473df3..5ae1b8fe363 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/client/TestStandardHttpReplicationClient.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/client/TestStandardHttpReplicationClient.java
@@ -38,6 +38,7 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -96,6 +97,11 @@ class TestStandardHttpReplicationClient {
private static final String STATUS_PSEUDO_HEADER = ":status";
+ private static final String CUSTOM_HEADER = "X-Custom";
+ private static final String CUSTOM_HEADER_LOWER = "x-custom";
+ private static final String CUSTOM_HEADER_VALUE = "same";
+ private static final String CUSTOM_HEADER_OTHER_VALUE = "other";
+
@Mock
private WebClientService webClientService;
@@ -138,6 +144,27 @@ class TestStandardHttpReplicationClient {
assertArrayEquals(EMPTY_MAP_SERIALIZED,
standardPreparedRequest.requestBody());
}
+ @Test
+ void testPrepareRequestRejectsConflictingHeaderValues() {
+ final Map<String, String> headers = new LinkedHashMap<>();
+ headers.put(CUSTOM_HEADER, CUSTOM_HEADER_VALUE);
+ headers.put(CUSTOM_HEADER_LOWER, CUSTOM_HEADER_OTHER_VALUE);
+
+ assertThrows(IllegalArgumentException.class, () ->
client.prepareRequest(GET_METHOD, headers, Collections.emptyMap()));
+ }
+
+ @Test
+ void testPrepareRequestCollapsesIdenticalHeaderValues() {
+ final Map<String, String> headers = new LinkedHashMap<>();
+ headers.put(CUSTOM_HEADER, CUSTOM_HEADER_VALUE);
+ headers.put(CUSTOM_HEADER_LOWER, CUSTOM_HEADER_VALUE);
+
+ final PreparedRequest preparedRequest =
client.prepareRequest(GET_METHOD, headers, Collections.emptyMap());
+
+ assertEquals(CUSTOM_HEADER_VALUE,
preparedRequest.getHeaders().get(CUSTOM_HEADER_LOWER));
+ assertNull(preparedRequest.getHeaders().get(CUSTOM_HEADER));
+ }
+
@Test
void testReplicateIllegalArgumentException() {
assertThrows(IllegalArgumentException.class, () ->
client.replicate(null, REPLICATE_URI));
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java
index b43c9614ef6..f0fc58b052d 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ApplicationResource.java
@@ -332,7 +332,11 @@ public abstract class ApplicationResource {
}
final URI requestUri =
RequestUriBuilder.fromHttpServletRequest(httpServletRequest).build();
- // Set Proxy Headers based on resolved URI from supported values
+ // Resolved proxy headers replace inbound values. The header map is
case-sensitive, so a differently
+ // cased name would otherwise remain beside the canonical name.
+ removeHeaderIgnoreCase(result, ProxyHeader.PROXY_SCHEME.getHeader());
+ removeHeaderIgnoreCase(result, ProxyHeader.PROXY_HOST.getHeader());
+ removeHeaderIgnoreCase(result, ProxyHeader.PROXY_PORT.getHeader());
result.put(ProxyHeader.PROXY_SCHEME.getHeader(),
requestUri.getScheme());
result.put(ProxyHeader.PROXY_HOST.getHeader(), requestUri.getHost());
result.put(ProxyHeader.PROXY_PORT.getHeader(),
Integer.toString(requestUri.getPort()));
@@ -340,6 +344,10 @@ public abstract class ApplicationResource {
return result;
}
+ private static void removeHeaderIgnoreCase(final Map<String, String>
headers, final String headerName) {
+ headers.keySet().removeIf(headerName::equalsIgnoreCase);
+ }
+
/**
* Checks whether the request is part of a two-phase commit style request
(either phase 1 or phase 2)
*
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestApplicationResource.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestApplicationResource.java
index e1ad5fe3ff3..d0967fcaa98 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestApplicationResource.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestApplicationResource.java
@@ -100,6 +100,50 @@ public class TestApplicationResource {
SecurityContextHolder.clearContext();
}
+ @Test
+ public void testGetHeadersReplacesCaseVariantProxyHeaders() {
+ final String proxyHostLower = "x-proxyhost";
+ final String proxySchemeLower = "x-proxyscheme";
+ final String proxyPortLower = "x-proxyport";
+ final String acceptHeader = "Accept";
+ final String acceptValue = "application/json";
+ final String inboundScheme = "http";
+ final String inboundPort = "443";
+ final String proxyHostWithPort = HOST + ":" + PORT;
+
+
when(request.getHeaderNames()).thenReturn(Collections.enumeration(List.of(proxyHostLower,
proxySchemeLower, proxyPortLower, acceptHeader)));
+ when(request.getHeader(anyString())).thenAnswer(invocation -> {
+ final String headerName = invocation.getArgument(0, String.class);
+ if
(ProxyHeader.PROXY_HOST.getHeader().equalsIgnoreCase(headerName)) {
+ return proxyHostWithPort;
+ }
+
+ if
(ProxyHeader.PROXY_SCHEME.getHeader().equalsIgnoreCase(headerName)) {
+ return inboundScheme;
+ }
+
+ if
(ProxyHeader.PROXY_PORT.getHeader().equalsIgnoreCase(headerName)) {
+ return inboundPort;
+ }
+
+ if (acceptHeader.equalsIgnoreCase(headerName)) {
+ return acceptValue;
+ }
+
+ return null;
+ });
+
+ final Map<String, String> headers = resource.getHeaders();
+
+ assertEquals(HOST, headers.get(ProxyHeader.PROXY_HOST.getHeader()));
+ assertEquals(inboundScheme,
headers.get(ProxyHeader.PROXY_SCHEME.getHeader()));
+ assertEquals(Integer.toString(PORT),
headers.get(ProxyHeader.PROXY_PORT.getHeader()));
+ assertEquals(acceptValue, headers.get(acceptHeader));
+ assertEquals(1,
headers.keySet().stream().filter(ProxyHeader.PROXY_HOST.getHeader()::equalsIgnoreCase).count());
+ assertEquals(1,
headers.keySet().stream().filter(ProxyHeader.PROXY_SCHEME.getHeader()::equalsIgnoreCase).count());
+ assertEquals(1,
headers.keySet().stream().filter(ProxyHeader.PROXY_PORT.getHeader()::equalsIgnoreCase).count());
+ }
+
@Test
public void
testGenerateUriShouldBlockProxyContextPathHeaderIfNotInAllowList() {
when(request.getHeader(anyString())).thenAnswer(new RequestAnswer());
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationFilter.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationFilter.java
index 3cd356afe9f..3388a8ee612 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationFilter.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationFilter.java
@@ -17,6 +17,7 @@
package org.apache.nifi.web.security.x509;
import jakarta.servlet.http.HttpServletRequest;
+import org.apache.nifi.web.security.InvalidAuthenticationException;
import org.apache.nifi.web.security.NiFiAuthenticationFilter;
import org.apache.nifi.web.security.ProxiedEntitiesUtils;
import org.slf4j.Logger;
@@ -25,6 +26,7 @@ import org.springframework.security.core.Authentication;
import
org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor;
import java.security.cert.X509Certificate;
+import java.util.Enumeration;
/**
* Custom X509 filter that will inspect the HTTP headers for a proxied user
before extracting the user details from the client certificate.
@@ -49,6 +51,9 @@ public class X509AuthenticationFilter extends
NiFiAuthenticationFilter {
return null;
}
+ rejectMultipleHeaderValues(request,
ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN);
+ rejectMultipleHeaderValues(request,
ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS);
+
final String proxiedEntitiesChain =
request.getHeader(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN);
logger.debug("Raw {} - {}", ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN,
proxiedEntitiesChain);
@@ -73,4 +78,17 @@ public class X509AuthenticationFilter extends
NiFiAuthenticationFilter {
this.principalExtractor = principalExtractor;
}
+ private static void rejectMultipleHeaderValues(final HttpServletRequest
request, final String headerName) {
+ final Enumeration<String> headerValues =
request.getHeaders(headerName);
+ int valueCount = 0;
+ while (headerValues.hasMoreElements()) {
+ headerValues.nextElement();
+ valueCount++;
+ }
+
+ if (valueCount > 1) {
+ throw new InvalidAuthenticationException("Request provided
multiple values for header [%s]".formatted(headerName));
+ }
+ }
+
}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/x509/X509AuthenticationFilterTest.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/x509/X509AuthenticationFilterTest.java
new file mode 100644
index 00000000000..548f77c1cc5
--- /dev/null
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/x509/X509AuthenticationFilterTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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.x509;
+
+import org.apache.nifi.web.security.InvalidAuthenticationException;
+import org.apache.nifi.web.security.NiFiWebAuthenticationDetails;
+import org.apache.nifi.web.security.ProxiedEntitiesUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.core.Authentication;
+import
org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor;
+
+import java.security.cert.X509Certificate;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+
+class X509AuthenticationFilterTest {
+
+ private static final String CLIENT_CERTIFICATE_ATTRIBUTE =
"jakarta.servlet.request.X509Certificate";
+
+ private static final String ALICE = "alice";
+
+ private static final String BOB = "bob";
+
+ private static final String ANALYSTS = "analysts";
+
+ private static final String OPERATORS = "operators";
+
+ private X509AuthenticationFilter filter;
+
+ @BeforeEach
+ void setup() {
+ filter = new X509AuthenticationFilter();
+ filter.setCertificateExtractor(new X509CertificateExtractor());
+ filter.setPrincipalExtractor(mock(X509PrincipalExtractor.class));
+
filter.setAuthenticationDetailsSource(NiFiWebAuthenticationDetails::new);
+ }
+
+ @Test
+ void testMultipleProxiedEntitiesChainValuesRejected() {
+ final MockHttpServletRequest request = secureRequestWithCertificate();
+ request.addHeader(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN,
formatEntities(ALICE));
+ request.addHeader(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN,
formatEntities(BOB));
+
+ assertThrows(InvalidAuthenticationException.class, () ->
filter.attemptAuthentication(request));
+ }
+
+ @Test
+ void testMultipleProxiedEntityGroupsValuesRejected() {
+ final MockHttpServletRequest request = secureRequestWithCertificate();
+ request.addHeader(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN,
formatEntities(ALICE));
+ request.addHeader(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS,
formatEntities(ANALYSTS));
+ request.addHeader(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS,
formatEntities(OPERATORS));
+
+ assertThrows(InvalidAuthenticationException.class, () ->
filter.attemptAuthentication(request));
+ }
+
+ @Test
+ void testSingleProxiedEntityHeaderPassedThrough() {
+ final MockHttpServletRequest request = secureRequestWithCertificate();
+ request.addHeader(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN,
formatEntities(ALICE));
+ request.addHeader(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS,
formatEntities(ANALYSTS));
+
+ final Authentication authentication =
filter.attemptAuthentication(request);
+
+ final X509AuthenticationRequestToken token =
assertInstanceOf(X509AuthenticationRequestToken.class, authentication);
+ assertEquals(formatEntities(ALICE), token.getProxiedEntitiesChain());
+ assertEquals(formatEntities(ANALYSTS), token.getProxiedEntityGroups());
+ }
+
+ private static MockHttpServletRequest secureRequestWithCertificate() {
+ final MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setSecure(true);
+ request.setAttribute(CLIENT_CERTIFICATE_ATTRIBUTE, new
X509Certificate[] {mock(X509Certificate.class)});
+ return request;
+ }
+
+ private static String formatEntities(final String... entities) {
+ final StringBuilder formattedEntities = new StringBuilder();
+ for (final String entity : entities) {
+ formattedEntities.append('<').append(entity).append('>');
+ }
+ return formattedEntities.toString();
+ }
+}
diff --git
a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/x509/X509IdentityProvider.java
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/x509/X509IdentityProvider.java
index ba93cad37ad..a9648f38c8a 100644
---
a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/x509/X509IdentityProvider.java
+++
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/x509/X509IdentityProvider.java
@@ -35,6 +35,7 @@ import org.springframework.stereotype.Component;
import java.security.cert.CertificateExpiredException;
import java.security.cert.CertificateNotYetValidException;
import java.security.cert.X509Certificate;
+import java.util.Enumeration;
import java.util.concurrent.TimeUnit;
/**
@@ -108,6 +109,8 @@ public class X509IdentityProvider implements
IdentityProvider {
return null;
}
+ rejectMultipleHeaderValues(servletRequest,
ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN);
+
// extract the principal
final Object certificatePrincipal =
principalExtractor.extractPrincipal(certificates[0]);
final String principal = certificatePrincipal.toString();
@@ -171,4 +174,17 @@ public class X509IdentityProvider implements
IdentityProvider {
certificate.checkValidity();
}
+ private static void rejectMultipleHeaderValues(final HttpServletRequest
request, final String headerName) {
+ final Enumeration<String> headerValues =
request.getHeaders(headerName);
+ int valueCount = 0;
+ while (headerValues.hasMoreElements()) {
+ headerValues.nextElement();
+ valueCount++;
+ }
+
+ if (valueCount > 1) {
+ throw new InvalidCredentialsException("Request provided multiple
values for header %s".formatted(headerName));
+ }
+ }
+
}
diff --git
a/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/security/authentication/x509/X509IdentityProviderTest.java
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/security/authentication/x509/X509IdentityProviderTest.java
new file mode 100644
index 00000000000..eb7770e226b
--- /dev/null
+++
b/nifi-registry/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/security/authentication/x509/X509IdentityProviderTest.java
@@ -0,0 +1,99 @@
+/*
+ * 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.registry.web.security.authentication.x509;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.nifi.registry.security.authentication.AuthenticationRequest;
+import
org.apache.nifi.registry.security.authentication.exception.InvalidCredentialsException;
+import org.apache.nifi.registry.security.util.ProxiedEntitiesUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import
org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor;
+
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class X509IdentityProviderTest {
+
+ private static final String HTTP_METHOD = "GET";
+
+ private static final String CERTIFICATE_PRINCIPAL = "CN=node";
+
+ private static final String ALICE = "alice";
+
+ private static final String BOB = "bob";
+
+ private X509IdentityProvider provider;
+
+ private X509PrincipalExtractor principalExtractor;
+
+ private X509Certificate certificate;
+
+ @BeforeEach
+ void setup() {
+ principalExtractor = mock(X509PrincipalExtractor.class);
+ certificate = mock(X509Certificate.class);
+ final X509CertificateExtractor certificateExtractor =
mock(X509CertificateExtractor.class);
+
when(certificateExtractor.extractClientCertificate(any(HttpServletRequest.class))).thenReturn(new
X509Certificate[] {certificate});
+ provider = new X509IdentityProvider(principalExtractor,
certificateExtractor);
+ }
+
+ @Test
+ void testMultipleProxiedEntitiesChainValuesRejected() {
+ final HttpServletRequest request =
secureRequest(formatEntities(ALICE), formatEntities(BOB));
+
+ assertThrows(InvalidCredentialsException.class, () ->
provider.extractCredentials(request));
+ }
+
+ @Test
+ void testSingleProxiedEntitiesChainPassedThrough() {
+
when(principalExtractor.extractPrincipal(certificate)).thenReturn(CERTIFICATE_PRINCIPAL);
+ final String chain = formatEntities(ALICE);
+ final HttpServletRequest request = secureRequest(chain);
+
+ final AuthenticationRequest authenticationRequest =
provider.extractCredentials(request);
+
+ final X509AuthenticationRequestDetails details =
assertInstanceOf(X509AuthenticationRequestDetails.class,
authenticationRequest.getDetails());
+ assertEquals(chain, details.getProxiedEntitiesChain());
+ assertEquals(HTTP_METHOD, details.getHttpMethod());
+ }
+
+ private HttpServletRequest secureRequest(final String... chainValues) {
+ final HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.isSecure()).thenReturn(true);
+ when(request.getMethod()).thenReturn(HTTP_METHOD);
+
when(request.getHeaders(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN)).thenReturn(Collections.enumeration(List.of(chainValues)));
+
when(request.getHeader(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN)).thenReturn(chainValues[0]);
+ return request;
+ }
+
+ private static String formatEntities(final String... entities) {
+ final StringBuilder formattedEntities = new StringBuilder();
+ for (final String entity : entities) {
+ formattedEntities.append('<').append(entity).append('>');
+ }
+ return formattedEntities.toString();
+ }
+}