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 @@ -4,6 +4,8 @@
import java.net.URI;
import java.net.URISyntaxException;

import javax.annotation.Nonnull;

import org.apache.hc.client5.http.config.Configurable;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
Expand All @@ -27,7 +29,7 @@
* and it will append the url configured in the destination.
*/
@Slf4j
class ApacheHttpClient5Wrapper extends CloseableHttpClient implements Configurable
class ApacheHttpClient5Wrapper extends CloseableHttpClient implements Configurable, UriQueryMerger
{
private final CloseableHttpClient httpClient;
@Getter( AccessLevel.PACKAGE )
Expand Down Expand Up @@ -127,4 +129,15 @@ public RequestConfig getConfig()
{
return requestConfig;
}

@Nonnull
@Override
public URI mergeRequestUri( @Nonnull final URI requestUri )
{
final UriPathMerger merger = new UriPathMerger();
URI merged;
merged = merger.merge(destination.getUri(), requestUri);
final String queryString = String.join("&", QueryParamGetter.getQueryParameters(destination));
return merger.merge(merged, URI.create("/?" + queryString));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.sap.cloud.sdk.cloudplatform.connectivity;

import java.io.IOException;
import java.net.URI;
import java.util.Set;

import javax.annotation.Nonnull;

import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.classic.methods.HttpHead;
import org.apache.hc.core5.http.EntityDetails;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpRequestInterceptor;
import org.apache.hc.core5.http.protocol.HttpContext;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
class CsrfTokenInterceptor implements HttpRequestInterceptor
{
static final String X_CSRF_TOKEN_HEADER_KEY = "x-csrf-token";
private static final String X_CSRF_TOKEN_FETCH_VALUE = "fetch";

private static final Set<String> MUTATING_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
private static final String NON_PRINTABLE_CHARS = "[^ -~]";

@Nonnull
private final HttpClient httpClient;

@Override
public
void
process( @Nonnull final HttpRequest request, final EntityDetails entityDetails, final HttpContext context )
throws HttpException,
IOException
{
if( !MUTATING_METHODS.contains(request.getMethod().toUpperCase()) ) {
return;
}

if( request.containsHeader(X_CSRF_TOKEN_HEADER_KEY) ) {
log.debug("CSRF token already present in request, skipping retrieval.");
return;
}

final URI requestUri;
try {
requestUri = request.getUri();
}
catch( final Exception e ) {
log.debug("Failed to determine request URI for CSRF token fetch, skipping.", e);
return;
}

final URI csrfFetchUri = deriveServiceRootUri(requestUri);
final HttpHead headRequest = new HttpHead(csrfFetchUri);
headRequest.addHeader(X_CSRF_TOKEN_HEADER_KEY, X_CSRF_TOKEN_FETCH_VALUE);

try {
final String token = httpClient.execute(headRequest, response -> {
final Header header = response.getFirstHeader(X_CSRF_TOKEN_HEADER_KEY);
if( header == null || header.getValue() == null ) {
log
.debug(
"Target system did not respond with a {} header. "
+ "The subsequent request may fail if a CSRF token is required.",
X_CSRF_TOKEN_HEADER_KEY);
return null;
}
return header.getValue().replaceAll(NON_PRINTABLE_CHARS, "");
});

if( token != null ) {
log.debug("Successfully retrieved CSRF token, adding to request.");
request.addHeader(X_CSRF_TOKEN_HEADER_KEY, token);
}
}
catch( final Exception e ) {
log
.debug(
"CSRF token retrieval failed: the HEAD request was not successful. "
+ "The subsequent request may fail if a CSRF token is required.",
e);
}
}

/**
* Returns the request methods for which this interceptor will attempt to fetch a CSRF token. Used for testing.
*/
static Set<String> getMutatingMethods()
{
return MUTATING_METHODS;
}

/**
* Derives the service root URI from the full request URI by truncating the path at the first OData resource
* segment. This matches the HC4 behavior where the CSRF token HEAD request was always sent to the service path root
* rather than the specific resource path.
* <p>
* The service root is identified as the path up to and including the trailing slash before the first resource
* segment. Example: {@code http://host/service/$batch} → {@code http://host/service/},
* {@code http://host/service/Entity} → {@code http://host/service/}
*/
@Nonnull
static URI deriveServiceRootUri( @Nonnull final URI requestUri )
{
final String path = requestUri.getRawPath();
// Service root is everything up to and including the trailing slash before the first resource segment.
// Find the last '/' that is followed by at least one more character (i.e., there is a resource segment).
final int lastSlash = path.lastIndexOf('/');
// If the path ends with '/' already (e.g. "/service/"), use it as-is.
// Otherwise, strip the last segment (e.g. "/service/Entity" -> "/service/", "/service/$batch" -> "/service/").
final String servicePath =
(lastSlash >= 0 && lastSlash < path.length() - 1) ? path.substring(0, lastSlash + 1) : path;
try {
return new URI(requestUri.getScheme(), requestUri.getAuthority(), servicePath, null, null);
}
catch( final Exception e ) {
log.debug("Failed to derive service root URI, falling back to full request URI.", e);
return requestUri;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,12 @@ private CloseableHttpClient buildHttpClient(
builder.addRequestInterceptorFirst(requestInterceptor);
}

return builder.build();
final CloseableHttpClient[] holder = new CloseableHttpClient[1];
builder
.addRequestInterceptorLast(
( req, entity, ctx ) -> new CsrfTokenInterceptor(holder[0]).process(req, entity, ctx));
holder[0] = builder.build();
return holder[0];
}

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

import java.net.URI;

import javax.annotation.Nonnull;

/**
* Interface to resolve the request URI for a given request. Used to determine the destination-contributed query
* parameters so that next-link pagination can strip duplicate parameters.
*/
@FunctionalInterface
public interface UriQueryMerger
{
/**
* Returns the fully-merged request URI for the given relative request URI. Merges the destination base URL,
* destination URL query parameters, and destination property query parameters into the URI.
*
* @param requestUri
* The relative request URI to merge.
* @return The merged request URI.
*/
@Nonnull
URI mergeRequestUri( @Nonnull URI requestUri );
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package com.sap.cloud.sdk.cloudplatform.connectivity;

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.ok;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.io.IOException;

import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.classic.methods.HttpDelete;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.classic.methods.HttpHead;
import org.apache.hc.client5.http.classic.methods.HttpPatch;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.classic.methods.HttpPut;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.io.HttpClientResponseHandler;
import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
import org.apache.hc.core5.http.message.BasicHeader;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

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

import lombok.SneakyThrows;

@WireMockTest
@SuppressWarnings( "unchecked" )
class CsrfTokenInterceptorTest
{
private static final String CSRF_TOKEN = "test-csrf-token";
private static final String SERVICE_ROOT = "/service/";
private static final String REQUEST_PATH = "/service/entity";

private HttpClient mockHttpClient;
private CsrfTokenInterceptor sut;

@BeforeEach
void setup()
{
mockHttpClient = mock(HttpClient.class);
sut = new CsrfTokenInterceptor(mockHttpClient);
}

@ParameterizedTest
@MethodSource( "mutatingMethods" )
@SneakyThrows
void tokenIsFetchedAndAddedForMutatingMethods( final HttpRequest request )
{
final ClassicHttpResponse headResponse = new BasicClassicHttpResponse(200);
headResponse.addHeader(new BasicHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY, CSRF_TOKEN));

when(mockHttpClient.execute(any(HttpHead.class), any(HttpClientResponseHandler.class)))
.thenAnswer(inv -> ((HttpClientResponseHandler<?>) inv.getArgument(1)).handleResponse(headResponse));

sut.process(request, null, null);

assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY).getValue())
.isEqualTo(CSRF_TOKEN);
}

static HttpRequest[] mutatingMethods()
{
return new HttpRequest[] {
new HttpPost(REQUEST_PATH),
new HttpPut(REQUEST_PATH),
new HttpPatch(REQUEST_PATH),
new HttpDelete(REQUEST_PATH) };
}

@Test
@SneakyThrows
void tokenIsNotFetchedForGetRequest()
{
final HttpGet request = new HttpGet(REQUEST_PATH);

sut.process(request, null, null);

verify(mockHttpClient, never()).execute(any(), any(HttpClientResponseHandler.class));
assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY)).isNull();
}

@Test
@SneakyThrows
void tokenIsNotFetchedForHeadRequest()
{
final HttpHead request = new HttpHead(REQUEST_PATH);

sut.process(request, null, null);

verify(mockHttpClient, never()).execute(any(), any(HttpClientResponseHandler.class));
assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY)).isNull();
}

@Test
@SneakyThrows
void tokenIsNotFetchedWhenAlreadyPresent()
{
final HttpPost request = new HttpPost(REQUEST_PATH);
request.addHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY, "existing-token");

sut.process(request, null, null);

verify(mockHttpClient, never()).execute(any(), any(HttpClientResponseHandler.class));
assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY).getValue())
.isEqualTo("existing-token");
}

@Test
@SneakyThrows
void requestProceedsWithoutTokenWhenServerReturnsNoHeader( final WireMockRuntimeInfo wm )
{
wm.getWireMock().register(head(urlEqualTo(SERVICE_ROOT)).willReturn(ok()));

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

final HttpPost request = new HttpPost(REQUEST_PATH);

assertThatCode(() -> interceptor.process(request, null, null)).doesNotThrowAnyException();
assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY)).isNull();

wm.getWireMock().verifyThat(headRequestedFor(urlEqualTo(SERVICE_ROOT)));
}

@Test
@SneakyThrows
void requestProceedsWithoutTokenWhenHeadThrowsIOException()
{
when(mockHttpClient.execute(any(HttpHead.class), any(HttpClientResponseHandler.class)))
.thenThrow(new IOException("Connection refused"));

final HttpPost request = new HttpPost(REQUEST_PATH);

assertThatCode(() -> sut.process(request, null, null)).doesNotThrowAnyException();
assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY)).isNull();
}

@Test
@SneakyThrows
void tokenIsFetchedViaRealHttpClientWithWireMock( final WireMockRuntimeInfo wm )
{
wm
.getWireMock()
.register(
head(urlEqualTo(SERVICE_ROOT))
.willReturn(ok().withHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY, CSRF_TOKEN)));

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

final HttpPost request = new HttpPost(REQUEST_PATH);
interceptor.process(request, null, null);

assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY).getValue())
.isEqualTo(CSRF_TOKEN);
}
}
Loading
Loading