vyommani commented on code in PR #1266:
URL: https://github.com/apache/ranger/pull/1266#discussion_r4183404519
##########
security-admin/src/main/java/org/apache/ranger/biz/ServiceMgr.java:
##########
@@ -460,6 +446,301 @@ Long parseLong(String str) {
}
}
+ private void enforceSavedServiceEndpointConfigs(RangerService service)
throws Exception {
+ if
(!Boolean.parseBoolean(PropertiesUtil.getProperty(PROP_SERVICE_CONFIG_VALIDATE_REQUIRE_SAVED_SERVICE,
"false"))) {
+ return;
+ }
+
+ String serviceName = service.getName();
+ if (StringUtils.isBlank(serviceName)) {
+ return;
+ }
+
+ RangerService savedService = svcDBStore.getServiceByName(serviceName);
+ if (savedService == null) {
+ throw new Exception("Configuration validation requires a saved
service: " + serviceName);
+ }
+
+ if (StringUtils.isNotBlank(service.getType()) &&
StringUtils.isNotBlank(savedService.getType())
+ && !StringUtils.equals(service.getType(),
savedService.getType())) {
+ throw new Exception("Configuration validation service type does
not match saved service: " + serviceName);
+ }
+
+ Map<String, String> savedConfigs =
rangerSvcService.getConfigsWithDecryptedPassword(savedService);
+ if (savedConfigs == null) {
+ savedConfigs = Collections.emptyMap();
+ }
+
+ for (Map.Entry<String, String> entry :
service.getConfigs().entrySet()) {
+ String configKey = entry.getKey();
+ String requestValue = entry.getValue();
+ if (requestValue == null || requestValue.trim().isEmpty()) {
+ continue;
+ }
+
+ String savedValue = savedConfigs.get(configKey);
+ boolean savedIsEndpoint = savedValue != null &&
isConnectionEndpoint(savedValue);
+ boolean requestIsEndpoint = isConnectionEndpoint(requestValue);
+
+ if (!savedIsEndpoint && !requestIsEndpoint) {
Review Comment:
**[bug]** The pin compares a property only when `isConnectionEndpoint` is
true (`://`, `jdbc:` plus `@`, or a host:port whose port is entirely numeric).
HBase's real connection target is `hbase.zookeeper.quorum`, a comma-separated
host list with no ports (`hbase.zookeeper.property.clientPort` is a separate
integer; the service def default for the quorum is empty). Neither the saved
nor the request value is classified as an endpoint, so this branch `continue`s
and a `validateConfig` body can replace `zk1.example.com,zk2.example.com` with
`10.0.0.5` or `evil.example.com`. `HBaseClient` copies every non-internal
config into `HBaseConfiguration` and `HBaseAdmin.available` dials that quorum.
The blocked-host scan does not save this case either (Issue 1). Blank request
values are skipped just above (line 477). `HBaseConfiguration.create()` then
keeps hbase-default.xml's quorum of `localhost`, so omitting the property still
opens a socket to a blocked host after both checks have passed.
The separate port property is not pinned, so the port can change even when the
host list is left alone. Solr `solr.zookeeper.quorum` values that end in a
chroot (`host:2181/solr`) fail the numeric-port test the same way.
**Suggestion:** Pin the properties the service implementation actually
dials, including port-less host lists and the companion port field, or treat a
saved endpoint that the request omits or blanks as a mismatch. Do not let the
client fall through to framework defaults that were never in the validated map.
##########
security-admin/src/main/java/org/apache/ranger/biz/ServiceMgr.java:
##########
@@ -460,6 +446,301 @@ Long parseLong(String str) {
}
}
+ private void enforceSavedServiceEndpointConfigs(RangerService service)
throws Exception {
+ if
(!Boolean.parseBoolean(PropertiesUtil.getProperty(PROP_SERVICE_CONFIG_VALIDATE_REQUIRE_SAVED_SERVICE,
"false"))) {
+ return;
+ }
+
+ String serviceName = service.getName();
+ if (StringUtils.isBlank(serviceName)) {
+ return;
+ }
+
+ RangerService savedService = svcDBStore.getServiceByName(serviceName);
+ if (savedService == null) {
+ throw new Exception("Configuration validation requires a saved
service: " + serviceName);
+ }
+
+ if (StringUtils.isNotBlank(service.getType()) &&
StringUtils.isNotBlank(savedService.getType())
+ && !StringUtils.equals(service.getType(),
savedService.getType())) {
+ throw new Exception("Configuration validation service type does
not match saved service: " + serviceName);
+ }
+
+ Map<String, String> savedConfigs =
rangerSvcService.getConfigsWithDecryptedPassword(savedService);
+ if (savedConfigs == null) {
+ savedConfigs = Collections.emptyMap();
+ }
+
+ for (Map.Entry<String, String> entry :
service.getConfigs().entrySet()) {
+ String configKey = entry.getKey();
+ String requestValue = entry.getValue();
+ if (requestValue == null || requestValue.trim().isEmpty()) {
+ continue;
+ }
+
+ String savedValue = savedConfigs.get(configKey);
+ boolean savedIsEndpoint = savedValue != null &&
isConnectionEndpoint(savedValue);
+ boolean requestIsEndpoint = isConnectionEndpoint(requestValue);
+
+ if (!savedIsEndpoint && !requestIsEndpoint) {
+ continue;
+ }
+
+ if (savedValue == null) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": endpoint property is not present in saved service
configuration");
+ }
+
+ if (!savedIsEndpoint) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": endpoint must match the saved service configuration");
+ }
+
+ ConnectionEndpointPin savedPin =
parseConnectionEndpointPin(configKey, savedValue);
+ ConnectionEndpointPin requestPin =
parseConnectionEndpointPin(configKey, requestValue);
+ if (!requestPin.matches(savedPin)) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": endpoint must match the saved service configuration");
+ }
+ }
+ }
+
+ private static boolean isConnectionEndpoint(String value) {
+ if (value == null) {
+ return false;
+ }
+
+ String trimmed = value.trim();
+ return looksLikeConnectionUrl(trimmed) || looksLikeJdbcAtHost(trimmed)
|| looksLikeHostPortEndpoint(trimmed);
+ }
+
+ private static boolean looksLikeConnectionUrl(String value) {
+ return value != null && value.contains("://");
+ }
+
+ private static boolean looksLikeJdbcAtHost(String value) {
+ if (StringUtils.isBlank(value) || looksLikeConnectionUrl(value)) {
+ return false;
+ }
+
+ return value.startsWith("jdbc:") && value.contains("@");
+ }
+
+ private static boolean looksLikeHostPortEndpoint(String value) {
+ if (StringUtils.isBlank(value) || looksLikeConnectionUrl(value) ||
value.contains("@")) {
+ return false;
+ }
+
+ if (value.contains(",")) {
+ for (String segment : value.split(",")) {
+ if (!looksLikeHostPortSegment(segment.trim())) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ return looksLikeHostPortSegment(value.trim());
+ }
+
+ private static boolean looksLikeHostPortSegment(String segment) {
+ if (StringUtils.isBlank(segment)) {
+ return false;
+ }
+
+ if (segment.startsWith("[")) {
+ int closeBracket = segment.indexOf(']');
+ if (closeBracket <= 0) {
+ return false;
+ }
+
+ if (closeBracket + 1 >= segment.length() ||
segment.charAt(closeBracket + 1) != ':') {
+ return false;
+ }
+
+ return StringUtils.isNumeric(segment.substring(closeBracket + 2));
+ }
+
+ int colon = segment.lastIndexOf(':');
+ if (colon <= 0 || colon >= segment.length() - 1) {
+ return false;
+ }
+
+ return StringUtils.isNumeric(segment.substring(colon + 1));
+ }
+
+ private static ConnectionEndpointPin parseConnectionEndpointPin(String
configKey, String rawValue) throws Exception {
+ String trimmed = rawValue.trim();
+ if (looksLikeConnectionUrl(trimmed)) {
+ return parseConnectionUrlPin(configKey, trimmed);
+ }
+
+ if (looksLikeJdbcAtHost(trimmed)) {
+ int at = trimmed.lastIndexOf('@');
+ String afterAt = trimmed.substring(at + 1);
+ if (afterAt.startsWith("//")) {
+ return parseConnectionUrlPin(configKey, "jdbc://" +
afterAt.substring(2));
+ }
+
+ List<String> authorityHostPorts =
parseCommaSeparatedHostPorts(afterAt);
+ if (authorityHostPorts.isEmpty()) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": connection URL cannot be parsed securely for endpoint pinning");
+ }
+
+ return new ConnectionEndpointPin("jdbc-at", null,
authorityHostPorts, "");
+ }
+
+ if (looksLikeHostPortEndpoint(trimmed)) {
+ List<String> authorityHostPorts =
parseCommaSeparatedHostPorts(trimmed);
+ if (authorityHostPorts.isEmpty()) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": connection URL cannot be parsed securely for endpoint pinning");
+ }
+
+ return new ConnectionEndpointPin("", null, authorityHostPorts, "");
+ }
+
+ throw new Exception("Invalid value for configuration " + configKey +
": connection URL cannot be parsed securely for endpoint pinning");
+ }
+
+ private static ConnectionEndpointPin parseConnectionUrlPin(String
configKey, String rawValue) throws Exception {
+ String trimmed = rawValue.trim();
+ int schemeSep = trimmed.indexOf("://");
+ if (schemeSep < 0) {
+ throw new Exception("Invalid value for configuration " + configKey
+ ": connection URL cannot be parsed securely for endpoint pinning");
+ }
+
+ String scheme = trimmed.substring(0,
schemeSep).toLowerCase(Locale.ROOT);
+ String remainder = trimmed.substring(schemeSep + 3);
+
+ String path = "";
+ String authority = remainder;
+ int pathStart = remainder.indexOf('/');
+ if (pathStart >= 0) {
+ authority = remainder.substring(0, pathStart);
+ path =
normalizePathForPinning(remainder.substring(pathStart));
+ }
+
+ String userInfo = null;
+ String hostPortPart = authority;
+ if (authority.contains("@")) {
+ int at = authority.lastIndexOf('@');
+ userInfo = authority.substring(0, at);
+ hostPortPart = authority.substring(at + 1);
+ }
+
+ List<String> authorityHostPorts =
parseCommaSeparatedHostPorts(hostPortPart);
+ if (authorityHostPorts.isEmpty() && StringUtils.isBlank(hostPortPart))
{
+ if (StringUtils.isBlank(path) || "/".equals(path)) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": connection URL cannot be parsed securely for endpoint pinning");
+ }
+ } else if (authorityHostPorts.isEmpty()) {
+ throw new Exception("Invalid value for configuration " + configKey
+ ": connection URL cannot be parsed securely for endpoint pinning");
+ }
+
+ return new ConnectionEndpointPin(scheme, userInfo, authorityHostPorts,
path);
+ }
+
+ private static String normalizePathForPinning(String path) {
+ if (path == null) {
+ return "";
+ }
+ int cut = path.length();
+ for (int i = 0; i < path.length(); i++) {
+ char c = path.charAt(i);
+ if (c == '?' || c == '#') {
+ cut = i;
+ break;
+ }
+ }
+ return path.substring(0, cut);
+ }
+
+ private static List<String> parseCommaSeparatedHostPorts(String
hostPortPart) {
+ List<String> ret = new ArrayList<>();
+ if (StringUtils.isBlank(hostPortPart)) {
+ return ret;
+ }
+
+ for (String segment : hostPortPart.split(",")) {
+ String normalized = normalizeHostPortSegment(segment.trim());
+ if (StringUtils.isNotBlank(normalized)) {
+ ret.add(normalized);
+ }
+ }
+
+ return ret;
+ }
+
+ private static String normalizeHostPortSegment(String segment) {
+ if (StringUtils.isBlank(segment)) {
+ return segment;
+ }
+
+ if (segment.startsWith("[")) {
+ int closeBracket = segment.indexOf(']');
+ if (closeBracket > 0) {
+ String host = segment.substring(1,
closeBracket).toLowerCase(Locale.ROOT);
+ String portSuffix = closeBracket + 1 < segment.length() ?
segment.substring(closeBracket + 1) : "";
+ return host + portSuffix;
+ }
+ }
+
+ int colonCount = StringUtils.countMatches(segment, ":");
+ if (colonCount == 1) {
+ int colon = segment.indexOf(':');
+ return segment.substring(0, colon).toLowerCase(Locale.ROOT) +
segment.substring(colon);
+ }
+
+ return segment.toLowerCase(Locale.ROOT);
+ }
+
+ private static final class ConnectionEndpointPin {
+ private final String scheme;
+ private final String userInfo;
+ private final List<String> authorityHostPorts;
+ private final String path;
+
+ private ConnectionEndpointPin(String scheme, String userInfo,
List<String> authorityHostPorts, String path) {
+ this.scheme = scheme;
+ this.userInfo = StringUtils.isBlank(userInfo) ? null :
userInfo;
+ this.authorityHostPorts = authorityHostPorts == null ?
Collections.emptyList() : authorityHostPorts;
+ this.path = path == null ? "" : path;
+ }
+
+ private boolean matches(ConnectionEndpointPin saved) {
+ return Objects.equals(scheme, saved.scheme)
+ && Objects.equals(userInfo, saved.userInfo)
+ && Objects.equals(path, saved.path)
+ && authorityHostPorts.equals(saved.authorityHostPorts);
+ }
+ }
+
+ private static void validateOutboundServiceConfigs(Map<String, String>
configs) throws Exception {
+ if (configs == null) {
+ return;
+ }
+
+ for (Map.Entry<String, String> entry : configs.entrySet()) {
+ String configValue = entry.getValue();
+ if (configValue != null && !configValue.trim().isEmpty()) {
+ String userInfo = extractUserInfo(configValue);
+ if (userInfoContainsBlockedHost(userInfo)) {
+ throw new Exception("Invalid value for configuration " +
entry.getKey() + ": userinfo contains blocked host (blocked host detected)");
+ }
+
+ String host = extractHost(configValue);
Review Comment:
**[bug]** `validateOutboundServiceConfigs` is what both `validateConfig` and
`lookupResource` now rely on, but it only judges one host per value. For a
comma-separated authority, `java.net.URI.getHost()` is null
(`good.example.com:2181,127.0.0.1:2181`,
`jdbc:mysql://good.example.com:3306,127.0.0.1:3306/db`,
`zk.example.com,127.0.0.1`). `extractHost` then keeps the authority only up to
the first colon, or the whole authority when there is no colon, and
`isBlockedHost` either sees just `good.example.com` or a string that does not
resolve (it fails open). The keyword fallback does not run once that host
string is non-null, so the `127.0.0.1` / `localhost` later in the list is never
checked. `HdfsClient` splits `fs.default.name` on commas after this check and
copies each piece into `dfs.namenode.rpc-address.*`, and the same shape is what
ZooKeeper connect strings and multi-host JDBC URLs use. A host-less scheme such
as `file:///...` also parses with a null host and none of the blocked
keywords, so it is allowed and HDFS `FileSystem.get` will use it.
`lookupResource` and `validateConfig` share this helper, so they agree with
each other and both disagree with the clients.
**Suggestion:** Split comma-separated authorities and run the blocked-host
check on every host; reject the value if any host cannot be parsed. Reject
host-less schemes the clients will still open (`file:`), or require an explicit
host. Add a regression next to `ssrfBypassConfigValues` for a blocked host in
the second position and for `file:///`.
##########
security-admin/src/main/java/org/apache/ranger/biz/ServiceMgr.java:
##########
@@ -460,6 +446,301 @@ Long parseLong(String str) {
}
}
+ private void enforceSavedServiceEndpointConfigs(RangerService service)
throws Exception {
+ if
(!Boolean.parseBoolean(PropertiesUtil.getProperty(PROP_SERVICE_CONFIG_VALIDATE_REQUIRE_SAVED_SERVICE,
"false"))) {
+ return;
+ }
+
+ String serviceName = service.getName();
+ if (StringUtils.isBlank(serviceName)) {
Review Comment:
**[bug]** With `ranger.serviceconfig.validate.require.saved.service` true, a
blank service name returns immediately, so neither the saved-service
requirement nor the endpoint pin runs. The service-manager UI always posts the
form name (`ServiceForm.getServiceConfigsToSave` sets `name` from the form), so
Test Connection while creating a service (name filled in, row not saved yet)
now fails with "requires a saved service". An API caller who simply omits
`name` skips the new gate and `validateConfig` still connects to the request
body, subject only to the blocked-host scan. The property text limits the
control to a named service, but the name is chosen by the caller, so a blank
name is a bypass, not a create-only exception. A named update of an already
stored service is the only path the pin actually covers. Callers who can
`validateConfig` can also update the service first, so persisting the URL and
then testing it also defeats the pin; the blank-name path avoids even that
write.
**Suggestion:** When the flag is on, reject a blank name as well, or ignore
request endpoints and test the stored service. If create-time Test Connection
must keep working, give that path an explicit unsaved opt-in instead of
treating a missing name as skip.
##########
security-admin/src/main/java/org/apache/ranger/biz/ServiceMgr.java:
##########
@@ -460,6 +446,301 @@ Long parseLong(String str) {
}
}
+ private void enforceSavedServiceEndpointConfigs(RangerService service)
throws Exception {
+ if
(!Boolean.parseBoolean(PropertiesUtil.getProperty(PROP_SERVICE_CONFIG_VALIDATE_REQUIRE_SAVED_SERVICE,
"false"))) {
+ return;
+ }
+
+ String serviceName = service.getName();
+ if (StringUtils.isBlank(serviceName)) {
+ return;
+ }
+
+ RangerService savedService = svcDBStore.getServiceByName(serviceName);
+ if (savedService == null) {
+ throw new Exception("Configuration validation requires a saved
service: " + serviceName);
+ }
+
+ if (StringUtils.isNotBlank(service.getType()) &&
StringUtils.isNotBlank(savedService.getType())
+ && !StringUtils.equals(service.getType(),
savedService.getType())) {
+ throw new Exception("Configuration validation service type does
not match saved service: " + serviceName);
+ }
+
+ Map<String, String> savedConfigs =
rangerSvcService.getConfigsWithDecryptedPassword(savedService);
+ if (savedConfigs == null) {
+ savedConfigs = Collections.emptyMap();
+ }
+
+ for (Map.Entry<String, String> entry :
service.getConfigs().entrySet()) {
+ String configKey = entry.getKey();
+ String requestValue = entry.getValue();
+ if (requestValue == null || requestValue.trim().isEmpty()) {
+ continue;
+ }
+
+ String savedValue = savedConfigs.get(configKey);
+ boolean savedIsEndpoint = savedValue != null &&
isConnectionEndpoint(savedValue);
+ boolean requestIsEndpoint = isConnectionEndpoint(requestValue);
+
+ if (!savedIsEndpoint && !requestIsEndpoint) {
+ continue;
+ }
+
+ if (savedValue == null) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": endpoint property is not present in saved service
configuration");
+ }
+
+ if (!savedIsEndpoint) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": endpoint must match the saved service configuration");
+ }
+
+ ConnectionEndpointPin savedPin =
parseConnectionEndpointPin(configKey, savedValue);
+ ConnectionEndpointPin requestPin =
parseConnectionEndpointPin(configKey, requestValue);
+ if (!requestPin.matches(savedPin)) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": endpoint must match the saved service configuration");
+ }
+ }
+ }
+
+ private static boolean isConnectionEndpoint(String value) {
+ if (value == null) {
+ return false;
+ }
+
+ String trimmed = value.trim();
+ return looksLikeConnectionUrl(trimmed) || looksLikeJdbcAtHost(trimmed)
|| looksLikeHostPortEndpoint(trimmed);
+ }
+
+ private static boolean looksLikeConnectionUrl(String value) {
+ return value != null && value.contains("://");
+ }
+
+ private static boolean looksLikeJdbcAtHost(String value) {
+ if (StringUtils.isBlank(value) || looksLikeConnectionUrl(value)) {
+ return false;
+ }
+
+ return value.startsWith("jdbc:") && value.contains("@");
+ }
+
+ private static boolean looksLikeHostPortEndpoint(String value) {
+ if (StringUtils.isBlank(value) || looksLikeConnectionUrl(value) ||
value.contains("@")) {
+ return false;
+ }
+
+ if (value.contains(",")) {
+ for (String segment : value.split(",")) {
+ if (!looksLikeHostPortSegment(segment.trim())) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ return looksLikeHostPortSegment(value.trim());
+ }
+
+ private static boolean looksLikeHostPortSegment(String segment) {
+ if (StringUtils.isBlank(segment)) {
+ return false;
+ }
+
+ if (segment.startsWith("[")) {
+ int closeBracket = segment.indexOf(']');
+ if (closeBracket <= 0) {
+ return false;
+ }
+
+ if (closeBracket + 1 >= segment.length() ||
segment.charAt(closeBracket + 1) != ':') {
+ return false;
+ }
+
+ return StringUtils.isNumeric(segment.substring(closeBracket + 2));
+ }
+
+ int colon = segment.lastIndexOf(':');
+ if (colon <= 0 || colon >= segment.length() - 1) {
+ return false;
+ }
+
+ return StringUtils.isNumeric(segment.substring(colon + 1));
+ }
+
+ private static ConnectionEndpointPin parseConnectionEndpointPin(String
configKey, String rawValue) throws Exception {
+ String trimmed = rawValue.trim();
+ if (looksLikeConnectionUrl(trimmed)) {
+ return parseConnectionUrlPin(configKey, trimmed);
+ }
+
+ if (looksLikeJdbcAtHost(trimmed)) {
+ int at = trimmed.lastIndexOf('@');
+ String afterAt = trimmed.substring(at + 1);
+ if (afterAt.startsWith("//")) {
+ return parseConnectionUrlPin(configKey, "jdbc://" +
afterAt.substring(2));
+ }
+
+ List<String> authorityHostPorts =
parseCommaSeparatedHostPorts(afterAt);
+ if (authorityHostPorts.isEmpty()) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": connection URL cannot be parsed securely for endpoint pinning");
+ }
+
+ return new ConnectionEndpointPin("jdbc-at", null,
authorityHostPorts, "");
+ }
+
+ if (looksLikeHostPortEndpoint(trimmed)) {
+ List<String> authorityHostPorts =
parseCommaSeparatedHostPorts(trimmed);
+ if (authorityHostPorts.isEmpty()) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": connection URL cannot be parsed securely for endpoint pinning");
+ }
+
+ return new ConnectionEndpointPin("", null, authorityHostPorts, "");
+ }
+
+ throw new Exception("Invalid value for configuration " + configKey +
": connection URL cannot be parsed securely for endpoint pinning");
+ }
+
+ private static ConnectionEndpointPin parseConnectionUrlPin(String
configKey, String rawValue) throws Exception {
+ String trimmed = rawValue.trim();
+ int schemeSep = trimmed.indexOf("://");
+ if (schemeSep < 0) {
+ throw new Exception("Invalid value for configuration " + configKey
+ ": connection URL cannot be parsed securely for endpoint pinning");
+ }
+
+ String scheme = trimmed.substring(0,
schemeSep).toLowerCase(Locale.ROOT);
+ String remainder = trimmed.substring(schemeSep + 3);
+
+ String path = "";
+ String authority = remainder;
+ int pathStart = remainder.indexOf('/');
+ if (pathStart >= 0) {
+ authority = remainder.substring(0, pathStart);
+ path =
normalizePathForPinning(remainder.substring(pathStart));
+ }
+
+ String userInfo = null;
+ String hostPortPart = authority;
+ if (authority.contains("@")) {
+ int at = authority.lastIndexOf('@');
+ userInfo = authority.substring(0, at);
+ hostPortPart = authority.substring(at + 1);
+ }
+
+ List<String> authorityHostPorts =
parseCommaSeparatedHostPorts(hostPortPart);
+ if (authorityHostPorts.isEmpty() && StringUtils.isBlank(hostPortPart))
{
+ if (StringUtils.isBlank(path) || "/".equals(path)) {
+ throw new Exception("Invalid value for configuration " +
configKey + ": connection URL cannot be parsed securely for endpoint pinning");
+ }
+ } else if (authorityHostPorts.isEmpty()) {
+ throw new Exception("Invalid value for configuration " + configKey
+ ": connection URL cannot be parsed securely for endpoint pinning");
+ }
+
+ return new ConnectionEndpointPin(scheme, userInfo, authorityHostPorts,
path);
+ }
+
+ private static String normalizePathForPinning(String path) {
+ if (path == null) {
+ return "";
+ }
+ int cut = path.length();
+ for (int i = 0; i < path.length(); i++) {
+ char c = path.charAt(i);
+ if (c == '?' || c == '#') {
+ cut = i;
+ break;
+ }
+ }
+ return path.substring(0, cut);
+ }
+
+ private static List<String> parseCommaSeparatedHostPorts(String
hostPortPart) {
+ List<String> ret = new ArrayList<>();
+ if (StringUtils.isBlank(hostPortPart)) {
+ return ret;
+ }
+
+ for (String segment : hostPortPart.split(",")) {
+ String normalized = normalizeHostPortSegment(segment.trim());
+ if (StringUtils.isNotBlank(normalized)) {
+ ret.add(normalized);
+ }
+ }
+
+ return ret;
+ }
+
+ private static String normalizeHostPortSegment(String segment) {
+ if (StringUtils.isBlank(segment)) {
+ return segment;
+ }
+
+ if (segment.startsWith("[")) {
+ int closeBracket = segment.indexOf(']');
+ if (closeBracket > 0) {
+ String host = segment.substring(1,
closeBracket).toLowerCase(Locale.ROOT);
+ String portSuffix = closeBracket + 1 < segment.length() ?
segment.substring(closeBracket + 1) : "";
+ return host + portSuffix;
+ }
+ }
+
+ int colonCount = StringUtils.countMatches(segment, ":");
+ if (colonCount == 1) {
+ int colon = segment.indexOf(':');
+ return segment.substring(0, colon).toLowerCase(Locale.ROOT) +
segment.substring(colon);
+ }
+
+ return segment.toLowerCase(Locale.ROOT);
+ }
+
+ private static final class ConnectionEndpointPin {
+ private final String scheme;
+ private final String userInfo;
+ private final List<String> authorityHostPorts;
+ private final String path;
+
+ private ConnectionEndpointPin(String scheme, String userInfo,
List<String> authorityHostPorts, String path) {
+ this.scheme = scheme;
+ this.userInfo = StringUtils.isBlank(userInfo) ? null :
userInfo;
+ this.authorityHostPorts = authorityHostPorts == null ?
Collections.emptyList() : authorityHostPorts;
+ this.path = path == null ? "" : path;
+ }
+
+ private boolean matches(ConnectionEndpointPin saved) {
+ return Objects.equals(scheme, saved.scheme)
+ && Objects.equals(userInfo, saved.userInfo)
+ && Objects.equals(path, saved.path)
+ && authorityHostPorts.equals(saved.authorityHostPorts);
+ }
+ }
+
+ private static void validateOutboundServiceConfigs(Map<String, String>
configs) throws Exception {
+ if (configs == null) {
+ return;
+ }
+
+ for (Map.Entry<String, String> entry : configs.entrySet()) {
+ String configValue = entry.getValue();
+ if (configValue != null && !configValue.trim().isEmpty()) {
+ String userInfo = extractUserInfo(configValue);
+ if (userInfoContainsBlockedHost(userInfo)) {
+ throw new Exception("Invalid value for configuration " +
entry.getKey() + ": userinfo contains blocked host (blocked host detected)");
+ }
+
+ String host = extractHost(configValue);
+ if (host != null) {
+ if (isBlockedHost(host)) {
Review Comment:
**[suggestion]** The host check never sees redirect targets. `KnoxClient`
builds a Jersey client and calls `request().get()` without turning
follow-redirects off, so a 302 from an allowed host to `127.0.0.1` or a
link-local address is still fetched. The same pattern exists for any other
service client that follows redirects. Separately, `normalizePathForPinning`
drops `?` and `#`, so with the saved-service flag on, two URLs that differ only
by query string (JDBC driver properties, for example) still match.
**Suggestion:** Disable redirect following on these outbound calls, or run
the blocked-host check on each `Location`. Include the query string in the pin,
or reject query and fragment differences instead of ignoring them.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]