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:
hsiegeln
2026-04-19 16:03:56 +02:00
parent 4922748599
commit 000e9d2847
5 changed files with 251 additions and 0 deletions

View File

@@ -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>

View File

@@ -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()
);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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}

View File

@@ -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);
}
}