diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java index 12b329b94dd..11a6a5aa824 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java @@ -244,9 +244,10 @@ List prompts( OidcMetadataFetcher oidcMetadataFetcher( UrlContentCache contentCache, @Qualifier("trustingRestTemplate") RestTemplate trustingRestTemplate, - @Qualifier("nonTrustingRestTemplate") RestTemplate nonTrustingRestTemplate + @Qualifier("nonTrustingRestTemplate") RestTemplate nonTrustingRestTemplate, + @Qualifier("safeRestTemplate") RestTemplate safeRestTemplate ) { - return new OidcMetadataFetcher(contentCache, trustingRestTemplate, nonTrustingRestTemplate); + return new OidcMetadataFetcher(contentCache, trustingRestTemplate, nonTrustingRestTemplate, safeRestTemplate); } @Bean diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfiguration.java index 0e1e4f4f33b..058b8d1779c 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfiguration.java @@ -13,6 +13,7 @@ import org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKeySet; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.cloudfoundry.identity.uaa.util.PrivateNetworkGuard; import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; @@ -242,9 +243,20 @@ private boolean validateJwksUri() { if (!"https".equals(validateJwksUri.getScheme()) && !"http".equals(validateJwksUri.getScheme())) { throw new InvalidClientDetailsException("Invalid private_key_jwt: jwks_uri must be either using https or http"); } - if ("http".equals(validateJwksUri.getScheme()) && !validateJwksUri.getHost().endsWith("localhost")) { + if ("http".equals(validateJwksUri.getScheme()) && !"localhost".equals(validateJwksUri.getHost())) { throw new InvalidClientDetailsException("Invalid private_key_jwt: jwks_uri with http is not on localhost"); } + // Only apply the private-network check for HTTPS URIs — HTTP is already + // restricted to localhost above, and localhost resolves to a loopback address. + if ("https".equals(validateJwksUri.getScheme())) { + try { + PrivateNetworkGuard.assertPublic(validateJwksUri); + } catch (IllegalArgumentException e) { + throw new InvalidClientDetailsException(e.getMessage()); + } catch (java.net.UnknownHostException _) { + // Unresolvable host: allow through at validation time; the fetch will fail. + } + } return true; } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/RestTemplateConfig.java b/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/RestTemplateConfig.java index c4d0c72b11e..c1779102342 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/RestTemplateConfig.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/RestTemplateConfig.java @@ -36,6 +36,11 @@ public RestTemplate trustingRestTemplate() { return new RestTemplate(UaaHttpRequestUtils.createRequestFactory(true, timeout, timeout, this)); } + @Bean + public RestTemplate safeRestTemplate() { + return new RestTemplate(UaaHttpRequestUtils.createSafeRequestFactory(this)); + } + public static RestTemplateConfig createDefaults() { RestTemplateConfig restTemplateConfig = new RestTemplateConfig(); restTemplateConfig.timeout = 10000; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OidcMetadataFetcher.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OidcMetadataFetcher.java index a8bd8a0bbb6..ef64440f33b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OidcMetadataFetcher.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OidcMetadataFetcher.java @@ -33,14 +33,24 @@ public class OidcMetadataFetcher { private final UrlContentCache contentCache; private final RestTemplate trustingRestTemplate; private final RestTemplate nonTrustingRestTemplate; + private final RestTemplate safeRestTemplate; public OidcMetadataFetcher(UrlContentCache contentCache, RestTemplate trustingRestTemplate, RestTemplate nonTrustingRestTemplate + ) { + this(contentCache, trustingRestTemplate, nonTrustingRestTemplate, nonTrustingRestTemplate); + } + + public OidcMetadataFetcher(UrlContentCache contentCache, + RestTemplate trustingRestTemplate, + RestTemplate nonTrustingRestTemplate, + RestTemplate safeRestTemplate ) { this.contentCache = contentCache; this.trustingRestTemplate = trustingRestTemplate; this.nonTrustingRestTemplate = nonTrustingRestTemplate; + this.safeRestTemplate = safeRestTemplate; } public void fetchMetadataAndUpdateDefinition(OIDCIdentityProviderDefinition definition) throws OidcMetadataFetchingException { @@ -73,7 +83,9 @@ public JsonWebKeySet fetchWebKeySet(ClientJwtConfiguration clientJwt if (clientJwtConfiguration.getJwkSet() != null) { return clientJwtConfiguration.getJwkSet(); } else if (clientJwtConfiguration.getJwksUri() != null) { - byte[] rawContents = getJsonBody(clientJwtConfiguration.getJwksUri(), false, true, null); + String jwksUri = clientJwtConfiguration.getJwksUri(); + RestTemplate template = isLocalhost(jwksUri) ? nonTrustingRestTemplate : safeRestTemplate; + byte[] rawContents = getJsonBody(jwksUri, false, true, null, template); if (rawContents != null && rawContents.length > 0) { ClientJwtConfiguration clientKeys = ClientJwtConfiguration.parse(null, new String(rawContents, StandardCharsets.UTF_8)); if (clientKeys != null && clientKeys.getJwkSet() != null) { @@ -85,6 +97,11 @@ public JsonWebKeySet fetchWebKeySet(ClientJwtConfiguration clientJwt } private byte[] getJsonBody(String uri, boolean isSkipSslValidation, boolean isCached, String authorizationValue) { + return getJsonBody(uri, isSkipSslValidation, isCached, authorizationValue, + isSkipSslValidation ? trustingRestTemplate : nonTrustingRestTemplate); + } + + private byte[] getJsonBody(String uri, boolean isSkipSslValidation, boolean isCached, String authorizationValue, RestTemplate restTemplate) { MultiValueMap headers = new LinkedMultiValueMap<>(); if (authorizationValue != null) { headers.add("Authorization", authorizationValue); @@ -92,19 +109,14 @@ private byte[] getJsonBody(String uri, boolean isSkipSslValidation, boolean isCa headers.add("Accept", "application/json,application/jwk-set+json"); HttpEntity tokenKeyRequest = new HttpEntity<>(null, headers); if (isCached) { - return getCachedResponse(uri, isSkipSslValidation, HttpMethod.GET, tokenKeyRequest); + return contentCache.getUrlContent(uri, restTemplate, HttpMethod.GET, tokenKeyRequest); } else { - return getResponse(uri, isSkipSslValidation, HttpMethod.GET, tokenKeyRequest); + return getResponse(uri, HttpMethod.GET, tokenKeyRequest, restTemplate); } } - private byte[] getResponse(String uri, boolean isSkipSslValidation, HttpMethod method, HttpEntity header) { - ResponseEntity responseEntity; - if (isSkipSslValidation) { - responseEntity = trustingRestTemplate.exchange(uri, method, header, byte[].class); - } else { - responseEntity = nonTrustingRestTemplate.exchange(uri, method, header, byte[].class); - } + private byte[] getResponse(String uri, HttpMethod method, HttpEntity header, RestTemplate restTemplate) { + ResponseEntity responseEntity = restTemplate.exchange(uri, method, header, byte[].class); if (responseEntity.getStatusCode() == HttpStatus.OK) { return responseEntity.getBody(); } else { @@ -113,11 +125,12 @@ private byte[] getResponse(String uri, boolean isSkipSslValidation, HttpMethod m } } - private byte[] getCachedResponse(String uri, boolean isSkipSslValidation, HttpMethod method, HttpEntity header) { - if (isSkipSslValidation) { - return contentCache.getUrlContent(uri, trustingRestTemplate, method, header); - } else { - return contentCache.getUrlContent(uri, nonTrustingRestTemplate, method, header); + private static boolean isLocalhost(String uri) { + try { + String host = java.net.URI.create(uri).getHost(); + return "localhost".equals(host); + } catch (IllegalArgumentException e) { + return false; } } @@ -130,12 +143,8 @@ private String getClientAuthHeader(AbstractExternalOAuthIdentityProviderDefiniti } private OidcMetadata fetchMetadata(URL discoveryUrl, boolean shouldDoSslValidation) throws OidcMetadataFetchingException { - byte[] rawContents; - if (shouldDoSslValidation) { - rawContents = contentCache.getUrlContent(discoveryUrl.toString(), trustingRestTemplate); - } else { - rawContents = contentCache.getUrlContent(discoveryUrl.toString(), nonTrustingRestTemplate); - } + RestTemplate restTemplate = shouldDoSslValidation ? trustingRestTemplate : nonTrustingRestTemplate; + byte[] rawContents = contentCache.getUrlContent(discoveryUrl.toString(), restTemplate); try { return OBJECT_MAPPER.readValue(rawContents, OidcMetadata.class); } catch (JacksonException e) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/util/PrivateNetworkBlockingDnsResolver.java b/server/src/main/java/org/cloudfoundry/identity/uaa/util/PrivateNetworkBlockingDnsResolver.java new file mode 100644 index 00000000000..c060f5f8ff5 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/util/PrivateNetworkBlockingDnsResolver.java @@ -0,0 +1,43 @@ +package org.cloudfoundry.identity.uaa.util; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; + +/** + * DNS resolver that delegates to the system resolver and then rejects any address + * that falls in a private, loopback, link-local, or cloud-metadata range. + * + * Used as the DnsResolver for the RestTemplate that fetches client jwks_uri content, + * providing defense-in-depth against DNS rebinding after jwks_uri validation. + */ +public class PrivateNetworkBlockingDnsResolver implements DnsResolver { + + public static final PrivateNetworkBlockingDnsResolver INSTANCE = new PrivateNetworkBlockingDnsResolver(); + + private static final DnsResolver DELEGATE = SystemDefaultDnsResolver.INSTANCE; + + private PrivateNetworkBlockingDnsResolver() {} + + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + InetAddress[] addresses = DELEGATE.resolve(host); + InetAddress blocked = Arrays.stream(addresses) + .filter(PrivateNetworkGuard::isBlocked) + .findFirst() + .orElse(null); + if (blocked != null) { + throw new UnknownHostException( + "Host " + host + " resolves to a blocked address: " + blocked.getHostAddress()); + } + return addresses; + } + + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + return DELEGATE.resolveCanonicalHostname(host); + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/util/PrivateNetworkGuard.java b/server/src/main/java/org/cloudfoundry/identity/uaa/util/PrivateNetworkGuard.java new file mode 100644 index 00000000000..f63043aa0bf --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/util/PrivateNetworkGuard.java @@ -0,0 +1,99 @@ +package org.cloudfoundry.identity.uaa.util; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; + +/** + * Rejects hostnames that resolve to private, loopback, link-local, or cloud-metadata + * IP ranges. Used to prevent SSRF via operator-supplied fetch targets such as jwks_uri. + */ +public final class PrivateNetworkGuard { + + // AWS/GCP/Azure instance-metadata address + private static final byte[] METADATA_V4 = {(byte) 169, (byte) 254, (byte) 169, (byte) 254}; + // RFC 6598 — Carrier-grade NAT (100.64.0.0/10) + private static final int RFC6598_START = (100 << 24) | (64 << 16); + private static final int RFC6598_END = (100 << 24) | (127 << 16) | (255 << 8) | 255; + // IPv4-mapped IPv6 prefix: ::ffff:0:0/96 + private static final byte[] IPV4_MAPPED_PREFIX = {0,0, 0,0, 0,0, 0,0, 0,0, (byte)0xff,(byte)0xff}; + private PrivateNetworkGuard() {} + + /** + * Resolves all addresses for the host in {@code uri} and throws if any of them + * fall into a private, loopback, link-local, or well-known metadata range. + * + * @throws IllegalArgumentException if the host resolves to a blocked address + * @throws UnknownHostException if DNS resolution fails + */ + public static void assertPublic(URI uri) throws UnknownHostException { + String host = uri.getHost(); + if (host == null) { + throw new IllegalArgumentException("URI has no host: " + uri); + } + for (InetAddress addr : InetAddress.getAllByName(host)) { + if (isBlocked(addr)) { + throw new IllegalArgumentException( + "jwks_uri host resolves to a blocked (private/loopback/link-local) address: " + addr.getHostAddress()); + } + } + } + + /** + * Returns true if the address must be blocked as an outbound fetch target. + */ + public static boolean isBlocked(InetAddress addr) { + if (addr.isLoopbackAddress()) { + return true; + } + if (addr.isLinkLocalAddress()) { + return true; + } + if (addr.isSiteLocalAddress()) { + return true; + } + if (addr.isMulticastAddress()) { + return true; + } + byte[] raw = addr.getAddress(); + // 169.254.169.254 — cloud instance-metadata (IPv4) + if (raw.length == 4 && raw[0] == METADATA_V4[0] && raw[1] == METADATA_V4[1] + && raw[2] == METADATA_V4[2] && raw[3] == METADATA_V4[3]) { + return true; + } + // Unspecified / any-local (0.0.0.0 or ::) + if (addr.isAnyLocalAddress()) { + return true; + } + // IPv6 unique-local (fc00::/7) + if (raw.length == 16 && (raw[0] & 0xfe) == 0xfc) { + return true; + } + // RFC 6598 — carrier-grade NAT (100.64.0.0/10) + if (raw.length == 4) { + int ip = ((raw[0] & 0xff) << 24) | ((raw[1] & 0xff) << 16) | ((raw[2] & 0xff) << 8) | (raw[3] & 0xff); + if (ip >= RFC6598_START && ip <= RFC6598_END) { + return true; + } + } + // IPv4-mapped IPv6 (::ffff:x.y.z.w) — check the embedded IPv4 part + if (raw.length == 16) { + boolean isMapped = true; + for (int i = 0; i < IPV4_MAPPED_PREFIX.length; i++) { + if (raw[i] != IPV4_MAPPED_PREFIX[i]) { + isMapped = false; + break; + } + } + if (isMapped) { + byte[] v4 = {raw[12], raw[13], raw[14], raw[15]}; + try { + return isBlocked(java.net.InetAddress.getByAddress(v4)); + } catch (java.net.UnknownHostException ignored) { + return true; + } + } + } + return false; + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaHttpRequestUtils.java b/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaHttpRequestUtils.java index 69cd67cb75f..f01fe20e76e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaHttpRequestUtils.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaHttpRequestUtils.java @@ -23,6 +23,7 @@ import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; import org.apache.hc.client5.http.socket.ConnectionSocketFactory; import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; @@ -89,6 +90,45 @@ public static ClientHttpRequestFactory createRequestFactory(boolean skipSslValid return createRequestFactory(getClientBuilder(skipSslValidation, config), config.connectionRequestTimeoutInMs()); } + /** + * Creates a request factory whose DNS resolver blocks private/loopback/link-local + * addresses at connection time. Redirects are disabled to prevent SSRF via a + * redirect to a private IP literal that would bypass DNS-based blocking. + * Use this for outbound fetches to operator-supplied URLs (e.g. jwks_uri). + */ + public static ClientHttpRequestFactory createSafeRequestFactory(RestTemplateConfig restTemplateConfig) { + HttpClientConfig config = new HttpClientConfig(restTemplateConfig.maxTotal, restTemplateConfig.maxPerRoute, + restTemplateConfig.maxKeepAlive, restTemplateConfig.validateAfterInactivity, + restTemplateConfig.retryCount, restTemplateConfig.timeout, restTemplateConfig.timeout, + restTemplateConfig.timeout); + HttpClientBuilder builder = HttpClients.custom() + .useSystemProperties() + .setUserTokenHandler(NoopUserTokenHandler.INSTANCE) + .disableRedirectHandling(); + PoolingHttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create() + .setDnsResolver(PrivateNetworkBlockingDnsResolver.INSTANCE) + .build(); + cm.setMaxTotal(config.poolSize()); + cm.setDefaultMaxPerRoute(config.defaultMaxPerRoute()); + cm.setValidateAfterInactivity(TimeValue.of(config.validateAfterInactivity(), TimeUnit.MILLISECONDS)); + cm.setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(toTimeout(config.connectTimeoutInMs())) + .build()); + cm.setDefaultSocketConfig(SocketConfig.custom() + .setSoTimeout(toTimeout(config.readTimeoutInMs())) + .build()); + builder.setConnectionManager(cm); + if (config.maxKeepAlive() <= 0) { + builder.setConnectionReuseStrategy((_, _, _) -> false); + } else { + builder.setKeepAliveStrategy(new UaaConnectionKeepAliveStrategy(config.maxKeepAlive())); + } + if (config.retryCount() > 0) { + builder.setRetryStrategy(new UaaHttpRequestRetryHandler(config.retryCount())); + } + return createRequestFactory(builder, config.connectionRequestTimeoutInMs()); + } + public static ClientHttpRequestFactory createRequestFactory(boolean skipSslValidation, int connectTimeout, int readTimeout, RestTemplateConfig restTemplateConfig) { HttpClientConfig config = new HttpClientConfig(restTemplateConfig.maxTotal, restTemplateConfig.maxPerRoute, restTemplateConfig.maxKeepAlive, restTemplateConfig.validateAfterInactivity, restTemplateConfig.retryCount, connectTimeout, readTimeout, connectTimeout); return createRequestFactory(getClientBuilder(skipSslValidation, config), config.connectionRequestTimeoutInMs()); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java index aa3c282ddae..a8ff145577b 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java @@ -259,7 +259,7 @@ void simpleAddClientWithJwksUri() { map.put("authorized-grant-types", GRANT_TYPE_AUTHORIZATION_CODE); map.put("authorities", "uaa.none"); map.put("redirect-uri", "http://localhost/callback"); - map.put("jwks_uri", "https://localhost:8080/uaa"); + map.put("jwks_uri", "https://1.1.1.1/token_keys"); UaaClientDetails clientDetails = (UaaClientDetails) doSimpleTest(map, clientAdminBootstrap, multitenantJdbcClientDetailsService, clients); assertThat(clientDetails.getClientJwtConfig()).isNotNull(); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfigurationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfigurationTest.java index ca56c2e918f..a246a0ba850 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfigurationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfigurationTest.java @@ -31,7 +31,6 @@ class ClientJwtConfigurationTest { @Test void jwksValidity() { assertThat(ClientJwtConfiguration.parse("https://any.domain.net/openid/jwks-uri")).isNotNull(); - assertThat(ClientJwtConfiguration.parse("http://any.localhost/openid/jwks-uri")).isNotNull(); } @Test @@ -39,10 +38,27 @@ void jwksInvalid() { assertThatThrownBy(() -> ClientJwtConfiguration.parse("custom://any.domain.net/openid/jwks-uri", null)).asInstanceOf(InstanceOfAssertFactories.throwable(InvalidClientDetailsException.class)); assertThatThrownBy(() -> ClientJwtConfiguration.parse("test", null)).asInstanceOf(InstanceOfAssertFactories.throwable(InvalidClientDetailsException.class)); assertThatThrownBy(() -> ClientJwtConfiguration.parse("http://any.domain.net/openid/jwks-uri")).asInstanceOf(InstanceOfAssertFactories.throwable(InvalidClientDetailsException.class)); + assertThatThrownBy(() -> ClientJwtConfiguration.parse("http://any.localhost/openid/jwks-uri")).asInstanceOf(InstanceOfAssertFactories.throwable(InvalidClientDetailsException.class)); assertThatThrownBy(() -> ClientJwtConfiguration.parse("https://")).asInstanceOf(InstanceOfAssertFactories.throwable(InvalidClientDetailsException.class)); assertThatThrownBy(() -> ClientJwtConfiguration.parse("ftp://any.domain.net/openid/jwks-uri")).asInstanceOf(InstanceOfAssertFactories.throwable(InvalidClientDetailsException.class)); } + @Test + void jwksUri_privateIpIsRejected() { + assertThatThrownBy(() -> ClientJwtConfiguration.parse("https://192.168.1.1/jwks")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("blocked"); + assertThatThrownBy(() -> ClientJwtConfiguration.parse("https://10.0.0.1/jwks")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("blocked"); + assertThatThrownBy(() -> ClientJwtConfiguration.parse("https://169.254.169.254/latest/meta-data")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("blocked"); + assertThatThrownBy(() -> ClientJwtConfiguration.parse("https://127.0.0.1/jwks")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("blocked"); + } + @Test void jwkSetValidity() { assertThat(ClientJwtConfiguration.parse(jsonWebKey)).isNotNull(); @@ -63,7 +79,7 @@ void jwkSetInvalidSize() { @Test void hasConfiguration() { - assertThat(ClientJwtConfiguration.parse("https://any.domain.net/openid/jwks-uri").hasConfiguration()).isTrue(); + assertThat(ClientJwtConfiguration.parse("https://1.1.1.1/openid/jwks-uri").hasConfiguration()).isTrue(); assertThat(ClientJwtConfiguration.parse(null).hasConfiguration()).isFalse(); assertThat(new ClientJwtConfiguration().hasConfiguration()).isFalse(); assertThat(ClientJwtConfiguration.parse(jsonJwkSet).hasConfiguration()).isTrue(); @@ -75,7 +91,7 @@ void jwtCredentials() { ClientJwtConfiguration config = new ClientJwtConfiguration(ClientJwtCredential.parse("[{\"iss\":\"http://localhost:8080/uaa\",\"sub\":\"client_with_jwks_trust\"}]")); assertThat(config.getClientJwtCredentials()).hasSize(1); assertThat(config.hasConfiguration()).isTrue(); - ClientJwtConfiguration mergeConfig = ClientJwtConfiguration.merge(ClientJwtConfiguration.parse("https://any.domain.net/openid/jwks-uri"), config, false); + ClientJwtConfiguration mergeConfig = ClientJwtConfiguration.merge(ClientJwtConfiguration.parse("https://1.1.1.1/openid/jwks-uri"), config, false); assertThat(mergeConfig.getClientJwtCredentials()).isNotNull(); assertThat(mergeConfig.getJwksUri()).isNotNull(); assertThat(mergeConfig.getJwkSet()).isNull(); @@ -265,8 +281,8 @@ void configDeleteNull() { @Test void testHashCode() { - ClientJwtConfiguration key1 = ClientJwtConfiguration.parse("http://localhost:8080/uaa"); - ClientJwtConfiguration key2 = ClientJwtConfiguration.parse("http://localhost:8080/uaa"); + ClientJwtConfiguration key1 = ClientJwtConfiguration.parse("https://any.domain.net/openid/jwks-uri"); + ClientJwtConfiguration key2 = ClientJwtConfiguration.parse("https://any.domain.net/openid/jwks-uri"); assertThat(key2.hashCode()).isNotEqualTo(key1.hashCode()); assertThat(key1).hasSameHashCodeAs(key1); assertThat(key2).hasSameHashCodeAs(key2); @@ -275,7 +291,7 @@ void testHashCode() { @Test void equals() throws Exception { - ClientJwtConfiguration key1 = ClientJwtConfiguration.parse("http://localhost:8080/uaa"); + ClientJwtConfiguration key1 = ClientJwtConfiguration.parse("https://1.1.1.1/openid/jwks-uri"); ClientJwtConfiguration key2 = (ClientJwtConfiguration) key1.clone(); assertThat(key2).isEqualTo(key1); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/util/PrivateNetworkGuardTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/util/PrivateNetworkGuardTest.java new file mode 100644 index 00000000000..9561c88e3c3 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/util/PrivateNetworkGuardTest.java @@ -0,0 +1,77 @@ +package org.cloudfoundry.identity.uaa.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PrivateNetworkGuardTest { + + @ParameterizedTest + @ValueSource(strings = { + "127.0.0.1", // loopback + "::1", // IPv6 loopback + "10.0.0.1", // RFC-1918 class A + "172.16.0.1", // RFC-1918 class B + "192.168.1.1", // RFC-1918 class C + "169.254.1.1", // link-local + "169.254.169.254", // cloud metadata + "224.0.0.1", // multicast + "0.0.0.0", // unspecified IPv4 + "::", // unspecified IPv6 + "fc00::1", // IPv6 unique-local (fc00::/7) + "fd00::1", // IPv6 unique-local (fd00::/8, within fc00::/7) + "100.64.0.1", // RFC 6598 carrier-grade NAT start + "100.100.100.100", // RFC 6598 middle + "100.127.255.255", // RFC 6598 end + "::ffff:192.168.1.1", // IPv4-mapped IPv6 — private + "::ffff:10.0.0.1", // IPv4-mapped IPv6 — private class A + "::ffff:127.0.0.1", // IPv4-mapped IPv6 — loopback + }) + void blockedAddresses(String ip) throws UnknownHostException { + assertThat_isBlocked(ip, true); + } + + @ParameterizedTest + @ValueSource(strings = { + "1.1.1.1", + "8.8.8.8", + "93.184.216.34", + }) + void publicAddressesAreNotBlocked(String ip) throws UnknownHostException { + assertThat_isBlocked(ip, false); + } + + @Test + void assertPublic_rejectsPrivateUri() { + assertThatThrownBy(() -> PrivateNetworkGuard.assertPublic(URI.create("https://192.168.0.1/jwks"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("blocked"); + } + + @Test + void assertPublic_rejectsUriWithNoHost() { + assertThatThrownBy(() -> PrivateNetworkGuard.assertPublic(URI.create("/relative/path"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no host"); + } + + @Test + void assertPublic_acceptsPublicHost() { + assertThatNoException().isThrownBy( + () -> PrivateNetworkGuard.assertPublic(URI.create("https://1.1.1.1/jwks"))); + } + + private static void assertThat_isBlocked(String ip, boolean expected) throws UnknownHostException { + InetAddress addr = InetAddress.getByName(ip); + org.assertj.core.api.Assertions.assertThat(PrivateNetworkGuard.isBlocked(addr)) + .as("isBlocked(%s)", ip) + .isEqualTo(expected); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsServiceTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsServiceTests.java index 997d7c10357..0168ddb3124 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsServiceTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsServiceTests.java @@ -525,14 +525,14 @@ void updateClientJwt() { UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("newClientIdWithNoDetails"); service.addClientDetails(clientDetails); - service.addClientJwtConfig(clientDetails.getClientId(), "http://localhost:8080/uaa/token_keys", currentZoneId, true); + service.addClientJwtConfig(clientDetails.getClientId(), "https://1.1.1.1/token_keys", currentZoneId, true); Map map = jdbcTemplate.queryForMap(SELECT_SQL, "newClientIdWithNoDetails"); assertThat(map).containsEntry("client_id", "newClientIdWithNoDetails") .containsKey("client_jwt_config"); - assertThat((String) map.get("client_jwt_config")).isEqualTo("{\"jwks_uri\":\"http://localhost:8080/uaa/token_keys\"}"); + assertThat((String) map.get("client_jwt_config")).isEqualTo("{\"jwks_uri\":\"https://1.1.1.1/token_keys\"}"); } @Test diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/ClientAdminEndpointsIntegrationTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/ClientAdminEndpointsIntegrationTests.java index 0e885fa3fdf..61afd49f8ef 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/ClientAdminEndpointsIntegrationTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/ClientAdminEndpointsIntegrationTests.java @@ -13,7 +13,6 @@ *******************************************************************************/ package org.cloudfoundry.identity.uaa.integration; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; import org.cloudfoundry.identity.uaa.ServerRunningExtension; import org.cloudfoundry.identity.uaa.approval.Approval; @@ -617,7 +616,7 @@ void changeJwtConfig() { client.setResourceIds(Collections.singleton("foo")); ClientJwtChangeRequest def = new ClientJwtChangeRequest(null, null, null); - def.setJsonWebKeyUri("http://localhost:8080/uaa/token_key"); + def.setJsonWebKeyUri("https://1.1.1.1/token_keys"); def.setClientId("admin"); ResponseEntity result = serverRunning.getRestTemplate().exchange( @@ -634,7 +633,7 @@ void changeFederatedJwtConfig() { client.setResourceIds(Collections.singleton("foo")); - ClientJwtChangeRequest def = new ClientJwtChangeRequest("admin", "http://localhost:8080/uaa/token_key", null); + ClientJwtChangeRequest def = new ClientJwtChangeRequest("admin", "http://localhost:8080/uaa/token_keys", null); ResponseEntity result = serverRunning.getRestTemplate().exchange( serverRunning.getUrl("/oauth/clients/{client}/clientjwt"), HttpMethod.PUT, new HttpEntity<>(def, headers), Void.class, @@ -662,7 +661,7 @@ void changeJwtConfigNoAuthorization() { client.setResourceIds(Collections.singleton("foo")); ClientJwtChangeRequest def = new ClientJwtChangeRequest(null, null, null); - def.setJsonWebKeyUri("http://localhost:8080/uaa/token_key"); + def.setJsonWebKeyUri("https://login.example.com/token_keys"); def.setClientId("admin"); ResponseEntity result = serverRunning.getRestTemplate().exchange( diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcTests.java index 8f48a2ea560..d28cd7ab61d 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcTests.java @@ -1968,7 +1968,7 @@ void addNewClientJwtKeyUri() throws Exception { String id = generator.generate(); ClientDetails client = createClient(token, id, SECRET, Collections.singleton("client_credentials")); ClientJwtChangeRequest request = new ClientJwtChangeRequest(null, null, null); - request.setJsonWebKeyUri("http://localhost:8080/uaa/token_key"); + request.setJsonWebKeyUri("http://localhost:8080/uaa/token_keys"); request.setClientId("admin"); request.setChangeMode(ClientJwtChangeRequest.ChangeMode.ADD); MockHttpServletResponse response = mockMvc.perform(put("/oauth/clients/{client_id}/clientjwt", client.getClientId()) @@ -1998,7 +1998,7 @@ void addNewClientJwtKeyUriButInvalidChange() throws Exception { String id = generator.generate(); ClientDetails client = createClient(token, id, SECRET, Collections.singleton("client_credentials")); ClientJwtChangeRequest request = new ClientJwtChangeRequest(null, null, null); - request.setJsonWebKeyUri("http://localhost:8080/uaa/token_key"); + request.setJsonWebKeyUri("http://localhost:8080/uaa/token_keys"); request.setClientId("admin"); request.setChangeMode(ClientJwtChangeRequest.ChangeMode.ADD); MockHttpServletResponse response = mockMvc.perform(put("/oauth/clients/{client_id}/clientjwt", client.getClientId()) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcZonePathTests.java index b2b744d8708..17a604f99e5 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcZonePathTests.java @@ -2085,7 +2085,7 @@ void addNewClientJwtKeyUri() throws Exception { String id = generator.generate(); ClientDetails client = createClient(token, id, SECRET, Collections.singleton("client_credentials")); ClientJwtChangeRequest request = new ClientJwtChangeRequest(null, null, null); - request.setJsonWebKeyUri("http://localhost:8080/uaa/token_key"); + request.setJsonWebKeyUri("http://localhost:8080/uaa/token_keys"); request.setClientId("admin"); request.setChangeMode(ClientJwtChangeRequest.ChangeMode.ADD); MockHttpServletResponse response = mockMvc.perform(put("/oauth/clients/{client_id}/clientjwt", client.getClientId()) @@ -2115,7 +2115,7 @@ void addNewClientJwtKeyUriButInvalidChange() throws Exception { String id = generator.generate(); ClientDetails client = createClient(token, id, SECRET, Collections.singleton("client_credentials")); ClientJwtChangeRequest request = new ClientJwtChangeRequest(null, null, null); - request.setJsonWebKeyUri("http://localhost:8080/uaa/token_key"); + request.setJsonWebKeyUri("http://localhost:8080/uaa/token_keys"); request.setClientId("admin"); request.setChangeMode(ClientJwtChangeRequest.ChangeMode.ADD); MockHttpServletResponse response = mockMvc.perform(put("/oauth/clients/{client_id}/clientjwt", client.getClientId())