feat(http): ApacheOutboundHttpClientFactory with memoization and startup validation
Adds ApacheOutboundHttpClientFactory (Apache HttpClient 5) that memoizes CloseableHttpClient instances keyed on effective TLS + timeout config, and OutboundHttpConfig (@ConfigurationProperties) that validates trusted CA paths at startup and exposes OutboundHttpClientFactory as a Spring bean. TRUST_ALL mode disables both cert validation (TrustAllManager in SslContextBuilder) and hostname verification (NoopHostnameVerifier on SSLConnectionSocketFactoryBuilder). WireMock HTTPS integration test covers trust-all bypass, system-default PKIX rejection, and client memoization. OIDC audit: OidcProviderHelper and OidcTokenExchanger use Nimbus SDK's own HTTP layer (DefaultResourceRetriever for JWKS, HTTPRequest.send() for token exchange) plus the bespoke InsecureTlsHelper for TLS skip-verify; neither uses OutboundHttpClientFactory. Retrofit deferred to a separate follow-up per plan §20. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -144,6 +144,12 @@
|
||||
<artifactId>awaitility</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.wiremock</groupId>
|
||||
<artifactId>wiremock-standalone</artifactId>
|
||||
<version>3.9.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.cameleer.server.app.http;
|
||||
|
||||
import com.cameleer.server.core.http.OutboundHttpClientFactory;
|
||||
import com.cameleer.server.core.http.OutboundHttpProperties;
|
||||
import com.cameleer.server.core.http.OutboundHttpRequestContext;
|
||||
import com.cameleer.server.core.http.TrustMode;
|
||||
import org.apache.hc.client5.http.config.ConnectionConfig;
|
||||
import org.apache.hc.client5.http.config.RequestConfig;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
|
||||
import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
|
||||
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder;
|
||||
import org.apache.hc.core5.util.Timeout;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class ApacheOutboundHttpClientFactory implements OutboundHttpClientFactory {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApacheOutboundHttpClientFactory.class);
|
||||
|
||||
private final OutboundHttpProperties systemProps;
|
||||
private final SslContextBuilder sslBuilder;
|
||||
private final ConcurrentMap<CacheKey, CloseableHttpClient> clients = new ConcurrentHashMap<>();
|
||||
|
||||
public ApacheOutboundHttpClientFactory(OutboundHttpProperties systemProps, SslContextBuilder sslBuilder) {
|
||||
this.systemProps = systemProps;
|
||||
this.sslBuilder = sslBuilder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CloseableHttpClient clientFor(OutboundHttpRequestContext ctx) {
|
||||
CacheKey key = CacheKey.of(systemProps, ctx);
|
||||
return clients.computeIfAbsent(key, k -> build(ctx));
|
||||
}
|
||||
|
||||
private CloseableHttpClient build(OutboundHttpRequestContext ctx) {
|
||||
try {
|
||||
var sslContext = sslBuilder.build(systemProps, ctx);
|
||||
boolean trustAll = systemProps.trustAll() || ctx.trustMode() == TrustMode.TRUST_ALL;
|
||||
var sslFactoryBuilder = SSLConnectionSocketFactoryBuilder.create()
|
||||
.setSslContext(sslContext);
|
||||
if (trustAll) {
|
||||
sslFactoryBuilder.setHostnameVerifier(NoopHostnameVerifier.INSTANCE);
|
||||
}
|
||||
var sslFactory = sslFactoryBuilder.build();
|
||||
var connMgr = PoolingHttpClientConnectionManagerBuilder.create()
|
||||
.setSSLSocketFactory(sslFactory)
|
||||
.setDefaultConnectionConfig(ConnectionConfig.custom()
|
||||
.setConnectTimeout(Timeout.of(
|
||||
ctx.connectTimeout() != null ? ctx.connectTimeout() : systemProps.defaultConnectTimeout()))
|
||||
.setSocketTimeout(Timeout.of(
|
||||
ctx.readTimeout() != null ? ctx.readTimeout() : systemProps.defaultReadTimeout()))
|
||||
.build())
|
||||
.build();
|
||||
log.debug("Built outbound HTTP client: trustMode={}, caPaths={}",
|
||||
ctx.trustMode(), ctx.trustedCaPemPaths());
|
||||
return HttpClients.custom()
|
||||
.setConnectionManager(connMgr)
|
||||
.setDefaultRequestConfig(RequestConfig.custom().build())
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to build outbound HTTP client", e);
|
||||
}
|
||||
}
|
||||
|
||||
private record CacheKey(
|
||||
boolean trustAll,
|
||||
List<String> caPaths,
|
||||
TrustMode mode,
|
||||
Duration connect,
|
||||
Duration read
|
||||
) {
|
||||
static CacheKey of(OutboundHttpProperties sp, OutboundHttpRequestContext ctx) {
|
||||
List<String> mergedPaths = Stream.concat(
|
||||
sp.trustedCaPemPaths().stream(),
|
||||
ctx.trustedCaPemPaths().stream()
|
||||
).toList();
|
||||
return new CacheKey(
|
||||
sp.trustAll(),
|
||||
List.copyOf(mergedPaths),
|
||||
ctx.trustMode(),
|
||||
ctx.connectTimeout() != null ? ctx.connectTimeout() : sp.defaultConnectTimeout(),
|
||||
ctx.readTimeout() != null ? ctx.readTimeout() : sp.defaultReadTimeout()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.cameleer.server.app.http.config;
|
||||
|
||||
import com.cameleer.server.app.http.ApacheOutboundHttpClientFactory;
|
||||
import com.cameleer.server.app.http.SslContextBuilder;
|
||||
import com.cameleer.server.core.http.OutboundHttpClientFactory;
|
||||
import com.cameleer.server.core.http.OutboundHttpProperties;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "cameleer.server.outbound-http")
|
||||
public class OutboundHttpConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OutboundHttpConfig.class);
|
||||
|
||||
private boolean trustAll = false;
|
||||
private List<String> trustedCaPemPaths = List.of();
|
||||
private long defaultConnectTimeoutMs = 2000;
|
||||
private long defaultReadTimeoutMs = 5000;
|
||||
private String proxyUrl;
|
||||
private String proxyUsername;
|
||||
private String proxyPassword;
|
||||
|
||||
public void setTrustAll(boolean trustAll) { this.trustAll = trustAll; }
|
||||
public void setTrustedCaPemPaths(List<String> paths) { this.trustedCaPemPaths = paths == null ? List.of() : List.copyOf(paths); }
|
||||
public void setDefaultConnectTimeoutMs(long v) { this.defaultConnectTimeoutMs = v; }
|
||||
public void setDefaultReadTimeoutMs(long v) { this.defaultReadTimeoutMs = v; }
|
||||
public void setProxyUrl(String v) { this.proxyUrl = v; }
|
||||
public void setProxyUsername(String v) { this.proxyUsername = v; }
|
||||
public void setProxyPassword(String v) { this.proxyPassword = v; }
|
||||
|
||||
@PostConstruct
|
||||
void validate() {
|
||||
if (trustAll) {
|
||||
log.warn("cameleer.server.outbound-http.trust-all is ON - all outbound HTTPS cert validation is DISABLED. Do not use in production.");
|
||||
}
|
||||
for (String p : trustedCaPemPaths) {
|
||||
if (!Files.exists(Path.of(p))) {
|
||||
throw new IllegalStateException("Configured trusted CA PEM path does not exist: " + p);
|
||||
}
|
||||
log.info("Outbound HTTP: trusting additional CA from {}", p);
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OutboundHttpProperties outboundHttpProperties() {
|
||||
return new OutboundHttpProperties(
|
||||
trustAll,
|
||||
trustedCaPemPaths,
|
||||
Duration.ofMillis(defaultConnectTimeoutMs),
|
||||
Duration.ofMillis(defaultReadTimeoutMs),
|
||||
proxyUrl,
|
||||
proxyUsername,
|
||||
proxyPassword
|
||||
);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SslContextBuilder sslContextBuilder() {
|
||||
return new SslContextBuilder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OutboundHttpClientFactory outboundHttpClientFactory(OutboundHttpProperties props, SslContextBuilder builder) {
|
||||
return new ApacheOutboundHttpClientFactory(props, builder);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,14 @@ cameleer:
|
||||
jwkseturi: ${CAMELEER_SERVER_SECURITY_OIDC_JWKSETURI:}
|
||||
audience: ${CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE:}
|
||||
tlsskipverify: ${CAMELEER_SERVER_SECURITY_OIDC_TLSSKIPVERIFY:false}
|
||||
outbound-http:
|
||||
trust-all: false
|
||||
trusted-ca-pem-paths: []
|
||||
default-connect-timeout-ms: 2000
|
||||
default-read-timeout-ms: 5000
|
||||
# proxy-url:
|
||||
# proxy-username:
|
||||
# proxy-password:
|
||||
clickhouse:
|
||||
url: ${CAMELEER_SERVER_CLICKHOUSE_URL:jdbc:clickhouse://localhost:8123/cameleer}
|
||||
username: ${CAMELEER_SERVER_CLICKHOUSE_USERNAME:default}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.cameleer.server.app.http;
|
||||
|
||||
import com.cameleer.server.core.http.OutboundHttpProperties;
|
||||
import com.cameleer.server.core.http.OutboundHttpRequestContext;
|
||||
import com.cameleer.server.core.http.TrustMode;
|
||||
import com.github.tomakehurst.wiremock.WireMockServer;
|
||||
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.core5.http.io.entity.EntityUtils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ApacheOutboundHttpClientFactoryIT {
|
||||
|
||||
private WireMockServer wm;
|
||||
private ApacheOutboundHttpClientFactory factory;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
wm = new WireMockServer(WireMockConfiguration.options()
|
||||
.httpDisabled(true).dynamicHttpsPort());
|
||||
wm.start();
|
||||
wm.stubFor(get("/ping").willReturn(ok("pong")));
|
||||
|
||||
OutboundHttpProperties props = new OutboundHttpProperties(
|
||||
false, List.of(), Duration.ofSeconds(2), Duration.ofSeconds(5), null, null, null);
|
||||
factory = new ApacheOutboundHttpClientFactory(props, new SslContextBuilder());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
wm.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void trustAllBypassesWiremockSelfSignedCert() throws Exception {
|
||||
CloseableHttpClient client = factory.clientFor(
|
||||
new OutboundHttpRequestContext(TrustMode.TRUST_ALL, List.of(), null, null));
|
||||
try (var resp = client.execute(new HttpGet("https://localhost:" + wm.httpsPort() + "/ping"))) {
|
||||
assertThat(resp.getCode()).isEqualTo(200);
|
||||
assertThat(EntityUtils.toString(resp.getEntity())).isEqualTo("pong");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemDefaultRejectsSelfSignedCert() {
|
||||
CloseableHttpClient client = factory.clientFor(OutboundHttpRequestContext.systemDefault());
|
||||
assertThatThrownBy(() -> client.execute(new HttpGet("https://localhost:" + wm.httpsPort() + "/ping")))
|
||||
.hasMessageContaining("PKIX");
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientsAreMemoizedByContext() {
|
||||
CloseableHttpClient c1 = factory.clientFor(OutboundHttpRequestContext.systemDefault());
|
||||
CloseableHttpClient c2 = factory.clientFor(OutboundHttpRequestContext.systemDefault());
|
||||
assertThat(c1).isSameAs(c2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user