Skip to content

Commit

Permalink
NIFI-14026 Added proxied-entity module for web-security and toolkit
Browse files Browse the repository at this point in the history
- Added ProxiedEntityEncoder interface and implementation
- Removed nifi-web-security dependency from nifi-toolkit-client
- Removed Google Guava dependency from nifi-toolkit-cli
  • Loading branch information
exceptionfactory committed Dec 4, 2024
1 parent df793ce commit 1bc264b
Show file tree
Hide file tree
Showing 14 changed files with 242 additions and 192 deletions.
25 changes: 25 additions & 0 deletions nifi-commons/nifi-security-proxied-entity/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<!--
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.
-->
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.apache.nifi</groupId>
<artifactId>nifi-commons</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>nifi-security-proxied-entity</artifactId>
<description>Shared security components for handling Proxied Entities in HTTP requests and response</description>
</project>

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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.security.proxied.entity;

/**
* Proxied Entity Encoder sanitizes and formats identities for transmission as HTTP request headers
*/
public interface ProxiedEntityEncoder {
/**
* Get encoded representation of identity suitable for transmission
* @param identity Identity to be encoded
* @return Encoded Entity
*/
String getEncodedEntity(String identity);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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.security.proxied.entity;

import java.nio.charset.CharsetEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

/**
* Standard implementation singleton instance of Proxied Entity Encoder
*/
public class StandardProxiedEntityEncoder implements ProxiedEntityEncoder {
private static final String DELIMITED_FORMAT = "<%s>";

private static final String GT = ">";

private static final String ESCAPED_GT = "\\\\>";

private static final String LT = "<";

private static final String ESCAPED_LT = "\\\\<";

private static final CharsetEncoder headerValueCharsetEncoder = StandardCharsets.US_ASCII.newEncoder();

private static final Base64.Encoder headerValueEncoder = Base64.getEncoder();

private static final StandardProxiedEntityEncoder encoder = new StandardProxiedEntityEncoder();

private StandardProxiedEntityEncoder() {

}

/**
* Get singleton instance of standard encoder
*
* @return Proxied Entity Encoder
*/
public static ProxiedEntityEncoder getInstance() {
return encoder;
}

/**
* Get encoded entity sanitized and formatted for transmission as an HTTP header
*
* @param identity Identity to be encoded
* @return Encoded Entity
*/
@Override
public String getEncodedEntity(final String identity) {
final String sanitizedIdentity = getSanitizedIdentity(identity);
return DELIMITED_FORMAT.formatted(sanitizedIdentity);
}

private String getSanitizedIdentity(final String identity) {
final String sanitized;

if (identity == null || identity.isEmpty()) {
sanitized = identity;
} else {
final String escaped = identity.replaceAll(LT, ESCAPED_LT).replaceAll(GT, ESCAPED_GT);

if (headerValueCharsetEncoder.canEncode(escaped)) {
// Strings limited to US-ASCII characters can be transmitted as HTTP header values without encoding
sanitized = escaped;
} else {
// Non-ASCII characters require Base64 encoding and additional wrapping for transmission as headers
final byte[] escapedBinary = escaped.getBytes(StandardCharsets.UTF_8);
final String escapedEncoded = headerValueEncoder.encodeToString(escapedBinary);
sanitized = DELIMITED_FORMAT.formatted(escapedEncoded);
}
}

return sanitized;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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.security.proxied.entity;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertEquals;

class StandardProxiedEntityEncoderTest {

@ParameterizedTest
@CsvSource(
delimiter = ':',
value = {
":<null>",
"'':<>",
"username:<username>",
"CN=Common Name, OU=Organizational Unit:<CN=Common Name, OU=Organizational Unit>",
"CN=nifi.apache.org, O=Organization:<CN=nifi.apache.org, O=Organization>",
"<username>:<\\<username\\>>",
"<<username>>:<\\<\\<username\\>\\>>",
"CN=\uD83D\uDE00:<<Q0498J+YgA==>>"
}
)
void testGetEncodedEntity(final String identity, final String expected) {
final String encodedEntity = StandardProxiedEntityEncoder.getInstance().getEncodedEntity(identity);

assertEquals(expected, encodedEntity);
}
}
1 change: 1 addition & 0 deletions nifi-commons/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
<module>nifi-security-identity</module>
<module>nifi-security-kerberos-api</module>
<module>nifi-security-kerberos</module>
<module>nifi-security-proxied-entity</module>
<module>nifi-security-ssl</module>
<module>nifi-security-utils-api</module>
<module>nifi-single-user-utils</module>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@
<artifactId>nifi-security-cert</artifactId>
<version>2.1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.apache.nifi</groupId>
<artifactId>nifi-security-proxied-entity</artifactId>
<version>2.1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.authorization.user.NiFiUser;
import org.apache.nifi.authorization.user.NiFiUserUtils;
import org.apache.nifi.security.proxied.entity.ProxiedEntityEncoder;
import org.apache.nifi.security.proxied.entity.StandardProxiedEntityEncoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
Expand All @@ -51,32 +53,7 @@ public class ProxiedEntitiesUtils {
private static final String ANONYMOUS_CHAIN = "<>";
private static final String ANONYMOUS_IDENTITY = "";

/**
* Formats a list of DN/usernames to be set as a HTTP header using well known conventions.
*
* @param proxiedEntities the raw identities (usernames and DNs) to be formatted as a chain
* @return the value to use in the X-ProxiedEntitiesChain header
*/
public static String getProxiedEntitiesChain(final String... proxiedEntities) {
return getProxiedEntitiesChain(Arrays.asList(proxiedEntities));
}

/**
* Formats a list of DN/usernames to be set as a HTTP header using well known conventions.
*
* @param proxiedEntities the raw identities (usernames and DNs) to be formatted as a chain
* @return the value to use in the X-ProxiedEntitiesChain header
*/
public static String getProxiedEntitiesChain(final List<String> proxiedEntities) {
if (proxiedEntities == null) {
return null;
}

final List<String> proxiedEntityChain = proxiedEntities.stream()
.map(org.apache.nifi.registry.security.util.ProxiedEntitiesUtils::formatProxyDn)
.collect(Collectors.toList());
return StringUtils.join(proxiedEntityChain, "");
}
private static final ProxiedEntityEncoder proxiedEntityEncoder = StandardProxiedEntityEncoder.getInstance();

/**
* Tokenizes the specified proxy chain.
Expand Down Expand Up @@ -134,15 +111,13 @@ public static String buildProxiedEntitiesChainString(final NiFiUser user) {
if (proxyChain.isEmpty()) {
return ANONYMOUS_CHAIN;
}
proxyChain = proxyChain.stream().map(ProxiedEntitiesUtils::formatProxyDn).collect(Collectors.toList());
proxyChain = proxyChain.stream().map(proxiedEntityEncoder::getEncodedEntity).collect(Collectors.toList());
return StringUtils.join(proxyChain, "");
}

/**
* Builds the string representation for a set of groups that belong to a proxied entity.
*
* The resulting string will be formatted similar to a proxied-entity chain.
*
* Example:
* Groups set: ("group1", "group2", "group3")
* Returns: {@code "<group1><group2><group3> }
Expand All @@ -155,7 +130,7 @@ public static String buildProxiedEntityGroupsString(final Set<String> groups) {
return PROXY_ENTITY_GROUPS_EMPTY;
}

final List<String> formattedGroups = groups.stream().map(ProxiedEntitiesUtils::formatProxyDn).collect(Collectors.toList());
final List<String> formattedGroups = groups.stream().map(proxiedEntityEncoder::getEncodedEntity).collect(Collectors.toList());
return StringUtils.join(formattedGroups, "");
}

Expand Down Expand Up @@ -187,68 +162,6 @@ public static void unsuccessfulAuthentication(
}
}

/**
* Formats the specified DN to be set as a HTTP header using well known conventions.
*
* @param dn raw dn
* @return the dn formatted as an HTTP header
*/
public static String formatProxyDn(final String dn) {
return LT + sanitizeDn(dn) + GT;
}

/**
* Sanitizes a DN for safe and lossless transmission.
*
* Sanitization requires:
* <ol>
* <li>Encoded so that it can be sent losslessly using US-ASCII (the character set of HTTP Header values)</li>
* <li>Resilient to a DN with the sequence '><' to attempt to escape the tokenization process and impersonate another user.</li>
* </ol>
*
* <p>
* Example:
* <p>
* Provided DN: {@code jdoe><alopresto} -> {@code <jdoe><alopresto><proxy...>} would allow the user to impersonate jdoe
* <p>Алйс
* Provided DN: {@code Алйс} -> {@code <Алйс>} cannot be encoded/decoded as ASCII
*
* @param rawDn the unsanitized DN
* @return the sanitized DN
*/
private static String sanitizeDn(final String rawDn) {
if (StringUtils.isEmpty(rawDn)) {
return rawDn;
} else {

// First, escape any GT [>] or LT [<] characters, which are not safe
final String escapedDn = rawDn.replaceAll(GT, ESCAPED_GT).replaceAll(LT, ESCAPED_LT);
if (!escapedDn.equals(rawDn)) {
logger.warn("The provided DN [{}] contained dangerous characters that were escaped to [{}]", rawDn, escapedDn);
}

// Second, check for characters outside US-ASCII.
// This is necessary because X509 Certs can contain international/Unicode characters,
// but this value will be passed in an HTTP Header which must be US-ASCII.
// If non-ascii characters are present, base64 encode the DN and wrap in <angled-brackets>,
// to indicate to the receiving end that the value must be decoded.
// Note: We could have decided to always base64 encode these values,
// not only to avoid the isPureAscii(...) check, but also as a
// method of sanitizing GT [>] or LT [<] chars. However, there
// are advantages to encoding only when necessary, namely:
// 1. Backwards compatibility
// 2. Debugging this X-ProxiedEntitiesChain headers is easier unencoded.
// This algorithm can be revisited as part of the next major version change.
if (isPureAscii(escapedDn)) {
return escapedDn;
} else {
final String encodedDn = base64Encode(escapedDn);
logger.debug("The provided DN [{}] contained non-ASCII characters and was encoded as [{}]", rawDn, encodedDn);
return encodedDn;
}
}
}

/**
* Reconstitutes the original DN from the sanitized version passed in the proxy chain.
* <p>
Expand Down Expand Up @@ -281,19 +194,7 @@ private static String unsanitizeDn(final String sanitizedDn) {
}

/**
* Base64 encodes a DN and wraps it in angled brackets to indicate the value is base64 and not a raw DN.
*
* @param rawValue The value to encode
* @return A string containing a wrapped, encoded value.
*/
private static String base64Encode(final String rawValue) {
final String base64String = Base64.getEncoder().encodeToString(rawValue.getBytes(StandardCharsets.UTF_8));
final String wrappedEncodedValue = LT + base64String + GT;
return wrappedEncodedValue;
}

/**
* Performs the reverse of ${@link #base64Encode(String)}.
* Performs the reverse of Base64 encoding
*
* @param encodedValue the encoded value to decode.
* @return The original, decoded string.
Expand All @@ -314,7 +215,7 @@ private static boolean isValidChainFormat(final String rawProxiedEntitiesChain)
}

/**
* Check if a value has been encoded by ${@link #base64Encode(String)}, and therefore needs to be decoded.
* Check if a value has been encoded by Base64 encoding and therefore needs to be decoded.
*
* @param token the value to check
* @return true if the value is encoded, false otherwise.
Expand All @@ -332,14 +233,4 @@ private static boolean isBase64Encoded(final String token) {
private static boolean isWrappedInAngleBrackets(final String string) {
return string.startsWith(LT) && string.endsWith(GT);
}

/**
* Check if a string contains only pure ascii characters.
*
* @param stringWithUnknownCharacters - the string to check
* @return true if string can be encoded as ascii. false otherwise.
*/
private static boolean isPureAscii(final String stringWithUnknownCharacters) {
return StandardCharsets.US_ASCII.newEncoder().canEncode(stringWithUnknownCharacters);
}
}
Loading

0 comments on commit 1bc264b

Please sign in to comment.