Skip to content
Draft
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 @@ -20,6 +20,7 @@ public class ApacheHttpClient5FactoryBuilder
private TlsUpgrade tlsUpgrade = TlsUpgrade.AUTOMATIC;
private int maxConnectionsTotal = DefaultApacheHttpClient5Factory.DEFAULT_MAX_CONNECTIONS_TOTAL;
private int maxConnectionsPerRoute = DefaultApacheHttpClient5Factory.DEFAULT_MAX_CONNECTIONS_PER_ROUTE;
private boolean csrfTokenInterceptorEnabled = false;

/**
* Enum to control the automatic TLS upgrade feature for insecure connections.
Expand Down Expand Up @@ -145,6 +146,27 @@ public ApacheHttpClient5FactoryBuilder maxConnectionsPerRoute( final int maxConn
return this;
}

/**
* Enables the {@link CsrfTokenInterceptor} on {@link HttpClient} instances created by the to-be-built
* {@link ApacheHttpClient5Factory}.
* <p>
* When enabled, the interceptor automatically fetches a CSRF token via a HEAD request before every mutating HTTP
* request (POST, PUT, PATCH, DELETE) that does not already carry an {@code x-csrf-token} header. This is required
* when communicating with OData services that enforce CSRF protection.
* <p>
* By default, the CSRF token interceptor is <b>disabled</b>. Enable it when the built client will be used to call
* OData services. For general-purpose HTTP clients or REST/OpenAPI services that do not require CSRF protection,
* leave this disabled to avoid unnecessary HEAD requests.
*
* @return This builder.
*/
@Nonnull
public ApacheHttpClient5FactoryBuilder withCsrfTokenInterceptor()
{
this.csrfTokenInterceptorEnabled = true;
return this;
}

/**
* Builds a new {@link ApacheHttpClient5Factory} instance with the previously configured parameters.
*
Expand All @@ -158,6 +180,7 @@ public ApacheHttpClient5Factory build()
maxConnectionsTotal,
maxConnectionsPerRoute,
null,
tlsUpgrade);
tlsUpgrade,
csrfTokenInterceptorEnabled);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,22 @@ class DefaultApacheHttpClient5Factory implements ApacheHttpClient5Factory
@Nonnull
private final ApacheHttpClient5FactoryBuilder.TlsUpgrade tlsUpgrade;

private final boolean csrfTokenInterceptorEnabled;

DefaultApacheHttpClient5Factory(
@Nonnull final Duration timeout,
final int maxConnectionsTotal,
final int maxConnectionsPerRoute,
@Nullable final HttpRequestInterceptor requestInterceptor,
@Nonnull final ApacheHttpClient5FactoryBuilder.TlsUpgrade tlsUpgrade )
@Nonnull final ApacheHttpClient5FactoryBuilder.TlsUpgrade tlsUpgrade,
final boolean csrfTokenInterceptorEnabled )
{
this.timeout = toTimeout(timeout);
this.maxConnectionsTotal = maxConnectionsTotal;
this.maxConnectionsPerRoute = maxConnectionsPerRoute;
this.requestInterceptor = requestInterceptor;
this.tlsUpgrade = tlsUpgrade;
this.csrfTokenInterceptorEnabled = csrfTokenInterceptorEnabled;
}

@Nonnull
Expand Down Expand Up @@ -105,12 +109,16 @@ private CloseableHttpClient buildHttpClient(
builder.addRequestInterceptorFirst(requestInterceptor);
}

final AtomicReference<CloseableHttpClient> holder = new AtomicReference<>();
builder
.addRequestInterceptorLast(
( req, entity, ctx ) -> new CsrfTokenInterceptor(holder.get()).process(req, entity, ctx));
holder.set(builder.build());
return holder.get();
if( csrfTokenInterceptorEnabled ) {
final AtomicReference<CloseableHttpClient> holder = new AtomicReference<>();
builder
.addRequestInterceptorLast(
( req, entity, ctx ) -> new CsrfTokenInterceptor(holder.get()).process(req, entity, ctx));
holder.set(builder.build());
return holder.get();
}

return builder.build();
}

@Nonnull
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,82 @@
package com.sap.cloud.sdk.cloudplatform.connectivity;

import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.head;
import static com.github.tomakehurst.wiremock.client.WireMock.headRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.noContent;
import static com.github.tomakehurst.wiremock.client.WireMock.ok;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.assertj.core.api.Assertions.assertThatNoException;

import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.junit.jupiter.api.Test;

import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;

import lombok.SneakyThrows;

@WireMockTest
class ApacheHttpClient5FactoryBuilderTest
{
private static final String SERVICE_PATH = "/service/";
private static final String RESOURCE_PATH = SERVICE_PATH + "Entity";
private static final String CSRF_TOKEN = "test-token";

@Test
void testBuilderContainsOptionalParametersOnly()
{
// make sure we can build a new factory instance without supplying any parameters
assertThatNoException().isThrownBy(() -> new ApacheHttpClient5FactoryBuilder().build());
}

@Test
@SneakyThrows
void csrfInterceptorIsDisabledByDefault( final WireMockRuntimeInfo wm )
{
wm.getWireMock().register(post(urlEqualTo(RESOURCE_PATH)).willReturn(noContent()));

final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build();
final HttpClient client = new ApacheHttpClient5FactoryBuilder().build().createHttpClient(destination);

client.execute(new HttpPost(RESOURCE_PATH), r -> null);

wm.getWireMock().verifyThat(1, postRequestedFor(urlEqualTo(RESOURCE_PATH)));
wm.getWireMock().verifyThat(0, headRequestedFor(anyUrl()));
}

@Test
@SneakyThrows
void csrfInterceptorIsEnabledWhenOptedIn( final WireMockRuntimeInfo wm )
{
wm
.getWireMock()
.register(
head(urlEqualTo(SERVICE_PATH))
.willReturn(ok().withHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY, CSRF_TOKEN)));
wm.getWireMock().register(post(urlEqualTo(RESOURCE_PATH)).willReturn(noContent()));

final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build();
final HttpClient client =
new ApacheHttpClient5FactoryBuilder().withCsrfTokenInterceptor().build().createHttpClient(destination);

client.execute(new HttpPost(RESOURCE_PATH), r -> null);

wm
.getWireMock()
.verifyThat(
1,
headRequestedFor(urlEqualTo(SERVICE_PATH))
.withHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY, equalTo("fetch")));
wm
.getWireMock()
.verifyThat(
1,
postRequestedFor(urlEqualTo(RESOURCE_PATH))
.withHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY, equalTo(CSRF_TOKEN)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ class DefaultApacheHttpClient5CacheTest
DefaultApacheHttpClient5Factory.DEFAULT_MAX_CONNECTIONS_TOTAL,
DefaultApacheHttpClient5Factory.DEFAULT_MAX_CONNECTIONS_PER_ROUTE,
null,
ApacheHttpClient5FactoryBuilder.TlsUpgrade.AUTOMATIC);
ApacheHttpClient5FactoryBuilder.TlsUpgrade.AUTOMATIC,
false);
private static final long NANOSECONDS_IN_MINUTE = 60_000_000_000L;
private static final Duration TEN_MINUTES = Duration.ofMinutes(10L);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ void setup()
MAX_CONNECTIONS,
MAX_CONNECTIONS_PER_ROUTE,
requestInterceptor,
AUTOMATIC);
AUTOMATIC,
false);
}

@Test
Expand All @@ -101,15 +102,17 @@ void testHttpClientUsesTimeout()
MAX_CONNECTIONS,
MAX_CONNECTIONS_PER_ROUTE,
requestInterceptor,
AUTOMATIC);
AUTOMATIC,
false);

final ApacheHttpClient5Factory factoryWithEnoughTimeout =
new DefaultApacheHttpClient5Factory(
Duration.ofSeconds(7L),
MAX_CONNECTIONS,
MAX_CONNECTIONS_PER_ROUTE,
requestInterceptor,
AUTOMATIC);
AUTOMATIC,
false);

final ClassicHttpRequest request = new HttpGet(WIRE_MOCK_SERVER.url("/timeout"));

Expand Down Expand Up @@ -138,7 +141,8 @@ void testHttpClientUsesMaxConnections()
1,
MAX_CONNECTIONS_PER_ROUTE,
requestInterceptor,
AUTOMATIC);
AUTOMATIC,
false);

final HttpClient client = sut.createHttpClient();
final ClassicHttpRequest firstRequest = new HttpGet(WIRE_MOCK_SERVER.url("/max-connections-1"));
Expand All @@ -161,7 +165,8 @@ void testHttpClientUsesMaxConnectionsPerRoute()
MAX_CONNECTIONS,
1,
requestInterceptor,
AUTOMATIC);
AUTOMATIC,
false);

final ClassicHttpRequest firstRequest = new HttpGet(WIRE_MOCK_SERVER.url("/max-connections-per-route"));
final ClassicHttpRequest secondRequest = new HttpGet(SECOND_WIRE_MOCK_SERVER.url("/max-connections-per-route"));
Expand Down
9 changes: 4 additions & 5 deletions datamodel/odata-client-apache-httpclient5/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
<groupId>com.sap.cloud.sdk.cloudplatform</groupId>
<artifactId>connectivity-apache-httpclient5</artifactId>
</dependency>
<dependency>
<groupId>com.sap.cloud.sdk.cloudplatform</groupId>
<artifactId>cloudplatform-connectivity</artifactId>
</dependency>
<dependency>
<groupId>io.vavr</groupId>
<artifactId>vavr</artifactId>
Expand Down Expand Up @@ -79,11 +83,6 @@
<artifactId>cloudplatform-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.sap.cloud.sdk.cloudplatform</groupId>
<artifactId>cloudplatform-connectivity</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.sap.cloud.sdk.datamodel.odata.client;

import javax.annotation.Nonnull;

import org.apache.hc.client5.http.classic.HttpClient;

import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Factory;
import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5FactoryBuilder;
import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestinationProperties;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

/**
* Accessor for {@link HttpClient} instances suitable for OData requests.
* <p>
* Unlike the general-purpose {@link com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor}, clients
* created here have a CSRF token interceptor enabled (see
* {@link com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5FactoryBuilder#withCsrfTokenInterceptor()}).
* The interceptor automatically fetches a CSRF token via a HEAD request before every mutating HTTP request (POST, PUT,
* PATCH, DELETE) that does not already carry an {@code x-csrf-token} header, which is required for OData services that
* enforce CSRF protection.
* <p>
* For non-OData use cases (REST, OpenAPI) use
* {@link com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor} instead to avoid unnecessary CSRF HEAD
* requests.
*
* @since 5.35.0
*/
@NoArgsConstructor( access = AccessLevel.PRIVATE )
public final class ODataApacheHttpClient5Accessor
{
private static final ApacheHttpClient5Factory FACTORY =
new ApacheHttpClient5FactoryBuilder().withCsrfTokenInterceptor().build();

/**
* Returns an {@link HttpClient} for the given destination with the CSRF token interceptor enabled.
*
* @param destination
* The destination to get the {@link HttpClient} for.
* @return An {@link HttpClient} configured for OData communication with the given destination.
*/
@Nonnull
public static HttpClient getHttpClient( @Nonnull final HttpDestinationProperties destination )
{
return FACTORY.createHttpClient(destination);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.sap.cloud.sdk.datamodel.odata.client;

import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.head;
import static com.github.tomakehurst.wiremock.client.WireMock.headRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.noContent;
import static com.github.tomakehurst.wiremock.client.WireMock.ok;
import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
import static org.assertj.core.api.Assertions.assertThat;

import org.apache.hc.client5.http.classic.HttpClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestAction;
import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead;
import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResult;
import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric;

@WireMockTest
class ODataApacheHttpClient5AccessorTest
{
private static final String SERVICE_PATH = "/service/";
private static final String ENTITY_SET = "Entities";
private static final String ACTION_NAME = "TestAction";
private static final String CSRF_TOKEN = "test-csrf-token";
private static final String X_CSRF_TOKEN = "x-csrf-token";

private HttpClient client;

@BeforeEach
void setup( final WireMockRuntimeInfo wm )
{
final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build();
client = ODataApacheHttpClient5Accessor.getHttpClient(destination);
}

@Test
void csrfTokenIsFetchedForMutatingODataRequests( final WireMockRuntimeInfo wm )
{
wm
.getWireMock()
.register(head(urlPathEqualTo(SERVICE_PATH)).willReturn(ok().withHeader(X_CSRF_TOKEN, CSRF_TOKEN)));
wm.getWireMock().register(post(urlPathEqualTo(SERVICE_PATH + ACTION_NAME)).willReturn(noContent()));

final ODataRequestAction request = new ODataRequestAction(SERVICE_PATH, ACTION_NAME, null, ODataProtocol.V4);
final ODataRequestResult result = request.execute(client);

assertThat(result).isNotNull();

wm
.getWireMock()
.verifyThat(1, headRequestedFor(urlPathEqualTo(SERVICE_PATH)).withHeader(X_CSRF_TOKEN, equalTo("fetch")));
wm
.getWireMock()
.verifyThat(
1,
postRequestedFor(urlPathEqualTo(SERVICE_PATH + ACTION_NAME))
.withHeader(X_CSRF_TOKEN, equalTo(CSRF_TOKEN)));
}

@Test
void noHeadRequestForReadRequests( final WireMockRuntimeInfo wm )
{
wm.getWireMock().register(get(urlPathEqualTo(SERVICE_PATH + ENTITY_SET)).willReturn(okJson("{\"value\":[]}")));

final ODataRequestRead request = new ODataRequestRead(SERVICE_PATH, ENTITY_SET, "", ODataProtocol.V4);
final ODataRequestResultGeneric result = request.execute(client);

assertThat(result).isNotNull();

wm.getWireMock().verifyThat(0, headRequestedFor(anyUrl()));
wm.getWireMock().verifyThat(1, getRequestedFor(urlPathEqualTo(SERVICE_PATH + ENTITY_SET)));
}
}
Loading
Loading