diff --git a/services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java b/services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java index f98111a26d..b4743676ce 100755 --- a/services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java +++ b/services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java @@ -39,6 +39,8 @@ import java.util.concurrent.TimeUnit; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.util.EntityUtils; +import static org.keycloak.utils.StringUtil.isBlank; + /** * The default {@link HttpClientFactory} for {@link HttpClientProvider HttpClientProvider's} used by Keycloak for outbound HTTP calls. *

@@ -63,6 +65,10 @@ public class DefaultHttpClientFactory implements HttpClientFactory { private static final Logger logger = Logger.getLogger(DefaultHttpClientFactory.class); private static final String configScope = "keycloak.connectionsHttpClient.default."; + private static final String HTTPS_PROXY = "https_proxy"; + private static final String HTTP_PROXY = "http_proxy"; + private static final String NO_PROXY = "no_proxy"; + private volatile CloseableHttpClient httpClient; private Config.Scope config; @@ -145,12 +151,27 @@ public class DefaultHttpClientFactory implements HttpClientFactory { String clientKeystore = config.get("client-keystore"); String clientKeystorePassword = config.get("client-keystore-password"); String clientPrivateKeyPassword = config.get("client-key-password"); - String[] proxyMappings = config.getArray("proxy-mappings"); boolean disableTrustManager = config.getBoolean("disable-trust-manager", false); boolean expectContinueEnabled = getBooleanConfigWithSysPropFallback("expect-continue-enabled", false); boolean resuseConnections = getBooleanConfigWithSysPropFallback("reuse-connections", true); + // optionally configure proxy mappings + // direct SPI config (e.g. via standalone.xml) takes precedence over env vars + // lower case env vars take precedence over upper case env vars + ProxyMappings proxyMappings = ProxyMappings.valueOf(config.getArray("proxy-mappings")); + if (proxyMappings == null || proxyMappings.isEmpty()) { + logger.debug("Trying to use proxy mapping from env vars"); + String httpProxy = getEnvVarValue(HTTPS_PROXY); + if (isBlank(httpProxy)) { + httpProxy = getEnvVarValue(HTTP_PROXY); + } + String noProxy = getEnvVarValue(NO_PROXY); + + logger.debugf("httpProxy: %s, noProxy: %s", httpProxy, noProxy); + proxyMappings = ProxyMappings.withFixedProxyMapping(httpProxy, noProxy); + } + HttpClientBuilder builder = new HttpClientBuilder(); builder.socketTimeout(socketTimeout, TimeUnit.MILLISECONDS) @@ -161,7 +182,7 @@ public class DefaultHttpClientFactory implements HttpClientFactory { .connectionTTL(connectionTTL, TimeUnit.MILLISECONDS) .maxConnectionIdleTime(maxConnectionIdleTime, TimeUnit.MILLISECONDS) .disableCookies(disableCookies) - .proxyMappings(ProxyMappings.valueOf(proxyMappings)) + .proxyMappings(proxyMappings) .expectContinueEnabled(expectContinueEnabled) .reuseConnections(resuseConnections); @@ -215,4 +236,12 @@ public class DefaultHttpClientFactory implements HttpClientFactory { return value != null ? value : defaultValue; } + private String getEnvVarValue(String name) { + String value = System.getenv(name.toLowerCase()); + if (isBlank(value)) { + value = System.getenv(name.toUpperCase()); + } + return value; + } + } diff --git a/services/src/main/java/org/keycloak/connections/httpclient/ProxyMappings.java b/services/src/main/java/org/keycloak/connections/httpclient/ProxyMappings.java index 3186567a84..4e2b46bcf4 100644 --- a/services/src/main/java/org/keycloak/connections/httpclient/ProxyMappings.java +++ b/services/src/main/java/org/keycloak/connections/httpclient/ProxyMappings.java @@ -21,6 +21,7 @@ import org.apache.http.auth.UsernamePasswordCredentials; import org.jboss.logging.Logger; import java.net.URI; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -30,6 +31,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import java.util.stream.Collectors; +import static org.keycloak.utils.StringUtil.isBlank; + /** * {@link ProxyMappings} describes an ordered mapping for hostname regex patterns to a {@link HttpHost} proxy. *

@@ -44,9 +47,11 @@ public class ProxyMappings { private static final ProxyMappings EMPTY_MAPPING = valueOf(Collections.emptyList()); + private static final String NO_PROXY_DELIMITER = ","; + private final List entries; - private static Map hostnameToProxyCache = new ConcurrentHashMap<>(); + private static final Map hostnameToProxyCache = new ConcurrentHashMap<>(); /** * Creates a {@link ProxyMappings} from the provided {@link ProxyMapping Entries}. @@ -93,6 +98,34 @@ public class ProxyMappings { return valueOf(Arrays.asList(proxyMappings)); } + /** + * Creates a new {@link ProxyMappings} from provided parameters representing the established {@code HTTP(S)_PROXY} + * and {@code NO_PROXY} environment variables. + * + * @param httpProxy a proxy used for all hosts except the ones specified in {@code noProxy} + * @param noProxy a list of hosts (separated by comma) that should not use proxy; + * all suffixes are matched too (e.g. redhat.com will also match access.redhat.com) + * @return + * @see https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/ + */ + public static ProxyMappings withFixedProxyMapping(String httpProxy, String noProxy) { + List proxyMappings = new ArrayList<>(); + + if (!isBlank(httpProxy)) { + // noProxy must be first as it's more specific than .* + if (!isBlank(noProxy)) { + for (String host : noProxy.split(NO_PROXY_DELIMITER)) { + // do not support regex in no_proxy + proxyMappings.add(new ProxyMapping(Pattern.compile("(?:.+\\.)?" + Pattern.quote(host)), null, null)); + } + } + + proxyMappings.add(ProxyMapping.valueOf(".*" + ProxyMapping.DELIMITER + httpProxy)); + } + + return proxyMappings.isEmpty() ? EMPTY_MAPPING : new ProxyMappings(proxyMappings); + } + public boolean isEmpty() { return this.entries.isEmpty(); diff --git a/services/src/test/java/org/keycloak/connections/httpclient/ProxyMappingsTest.java b/services/src/test/java/org/keycloak/connections/httpclient/ProxyMappingsTest.java index 60c095b8e5..25fa531180 100644 --- a/services/src/test/java/org/keycloak/connections/httpclient/ProxyMappingsTest.java +++ b/services/src/test/java/org/keycloak/connections/httpclient/ProxyMappingsTest.java @@ -29,6 +29,8 @@ import java.util.List; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; /** @@ -203,4 +205,48 @@ public class ProxyMappingsTest { ProxyMapping forSalesForce = proxyMappingsWithProxyAuthen.getProxyFor("login.salesforce.com"); assertThat(forSalesForce.getProxyHost().getHostName(), is("fallback")); } + + @Test + public void shouldReturnMappingForHttpProxy() { + ProxyMappings proxyMappings = ProxyMappings.withFixedProxyMapping("https://some-proxy.redhat.com:8080", null); + + ProxyMapping forGoogle = proxyMappings.getProxyFor("login.google.com"); + assertEquals("some-proxy.redhat.com", forGoogle.getProxyHost().getHostName()); + } + + @Test + public void shouldReturnMappingForHttpProxyWithNoProxy() { + ProxyMappings proxyMappings = ProxyMappings.withFixedProxyMapping("https://some-proxy.redhat.com:8080", "login.facebook.com"); + + assertEquals("some-proxy.redhat.com", proxyMappings.getProxyFor("login.google.com").getProxyHost().getHostName()); + assertEquals("some-proxy.redhat.com", proxyMappings.getProxyFor("facebook.com").getProxyHost().getHostName()); + + assertNull(proxyMappings.getProxyFor("login.facebook.com").getProxyHost()); + assertNull(proxyMappings.getProxyFor("auth.login.facebook.com").getProxyHost()); + } + + @Test + public void shouldReturnMappingForHttpProxyWithMultipleNoProxy() { + ProxyMappings proxyMappings = ProxyMappings.withFixedProxyMapping("https://some-proxy.redhat.com:8080", "login.facebook.com,corp.com"); + + assertEquals("some-proxy.redhat.com", proxyMappings.getProxyFor("login.google.com").getProxyHost().getHostName()); + assertEquals("some-proxy.redhat.com", proxyMappings.getProxyFor("facebook.com").getProxyHost().getHostName()); + + assertNull(proxyMappings.getProxyFor("login.facebook.com").getProxyHost()); + assertNull(proxyMappings.getProxyFor("auth.login.facebook.com").getProxyHost()); + assertNull(proxyMappings.getProxyFor("myapp.acme.corp.com").getProxyHost()); + } + + @Test + public void shouldReturnMappingForNoProxyWithInvalidChars() { + ProxyMappings proxyMappings = ProxyMappings.withFixedProxyMapping("https://some-proxy.redhat.com:8080", "[lj]ogin.facebook.com"); + + assertEquals("some-proxy.redhat.com", proxyMappings.getProxyFor("login.facebook.com").getProxyHost().getHostName()); + assertEquals("some-proxy.redhat.com", proxyMappings.getProxyFor("jogin.facebook.com").getProxyHost().getHostName()); + } + + @Test + public void shouldReturnEmptyMappingForEmptyHttpProxy() { + assertNull(ProxyMappings.withFixedProxyMapping(null, "facebook.com")); + } } \ No newline at end of file