Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,10 @@ List<Prompt> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Comment thread
strehle marked this conversation as resolved.
} 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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Comment thread
strehle marked this conversation as resolved.

public static RestTemplateConfig createDefaults() {
RestTemplateConfig restTemplateConfig = new RestTemplateConfig();
restTemplateConfig.timeout = 10000;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -73,7 +83,9 @@ public JsonWebKeySet<JsonWebKey> 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);
Comment thread
strehle marked this conversation as resolved.
if (rawContents != null && rawContents.length > 0) {
ClientJwtConfiguration clientKeys = ClientJwtConfiguration.parse(null, new String(rawContents, StandardCharsets.UTF_8));
if (clientKeys != null && clientKeys.getJwkSet() != null) {
Expand All @@ -85,26 +97,26 @@ public JsonWebKeySet<JsonWebKey> 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<String, String> headers = new LinkedMultiValueMap<>();
if (authorizationValue != null) {
headers.add("Authorization", authorizationValue);
}
headers.add("Accept", "application/json,application/jwk-set+json");
HttpEntity<Object> 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<Object> header) {
ResponseEntity<byte[]> 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<Object> header, RestTemplate restTemplate) {
ResponseEntity<byte[]> responseEntity = restTemplate.exchange(uri, method, header, byte[].class);
if (responseEntity.getStatusCode() == HttpStatus.OK) {
return responseEntity.getBody();
} else {
Expand All @@ -113,11 +125,12 @@ private byte[] getResponse(String uri, boolean isSkipSslValidation, HttpMethod m
}
}

private byte[] getCachedResponse(String uri, boolean isSkipSslValidation, HttpMethod method, HttpEntity<Object> 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;
}
}

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
strehle marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Comment thread
strehle marked this conversation as resolved.
.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());
Comment thread
Copilot marked this conversation as resolved.
}

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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
strehle marked this conversation as resolved.
assertThat(clientDetails.getClientJwtConfig()).isNotNull();
}
Expand Down
Loading
Loading