diff --git a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/RedirectHandler.java b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/RedirectHandler.java index 4cc2d4c9..543109ae 100644 --- a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/RedirectHandler.java +++ b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/RedirectHandler.java @@ -29,12 +29,13 @@ */ public class RedirectHandler implements Interceptor { @Nonnull private final RedirectHandlerOption mRedirectOption; + @Nullable private final java.net.ProxySelector mProxySelector; /** * Initialize using default redirect options, default IShouldRedirect and max redirect value */ public RedirectHandler() { - this(null); + this(null, null); } /** @@ -42,11 +43,24 @@ public RedirectHandler() { * @param redirectOption pass instance of redirect options to be used */ public RedirectHandler(@Nullable final RedirectHandlerOption redirectOption) { + this(redirectOption, null); + } + + /** + * Initialize using custom redirect options and proxy selector. + * @param redirectOption pass instance of redirect options to be used + * @param proxySelector The ProxySelector to use for determining proxy configuration, or null to use the system default + */ + public RedirectHandler( + @Nullable final RedirectHandlerOption redirectOption, + @Nullable final java.net.ProxySelector proxySelector) { if (redirectOption == null) { this.mRedirectOption = new RedirectHandlerOption(); } else { this.mRedirectOption = redirectOption; } + this.mProxySelector = + proxySelector != null ? proxySelector : java.net.ProxySelector.getDefault(); } boolean isRedirected( @@ -81,7 +95,10 @@ boolean isRedirected( return false; } - Request getRedirect(final Request request, final Response userResponse) + Request getRedirect( + final Request request, + final Response userResponse, + final RedirectHandlerOption redirectOption) throws ProtocolException { String location = userResponse.header("Location"); if (location == null || location.length() == 0) return null; @@ -95,32 +112,29 @@ Request getRedirect(final Request request, final Response userResponse) location = request.url() + location; } - HttpUrl requestUrl = userResponse.request().url(); + HttpUrl requestUrl = request.url(); - HttpUrl locationUrl = userResponse.request().url().resolve(location); + HttpUrl locationUrl = request.url().resolve(location); // Don't follow redirects to unsupported protocols. if (locationUrl == null) return null; // Most redirects don't include a request body. - Request.Builder requestBuilder = userResponse.request().newBuilder(); - - // When redirecting across hosts, drop all authentication headers. This - // is potentially annoying to the application layer since they have no - // way to retain them. - boolean sameScheme = locationUrl.scheme().equalsIgnoreCase(requestUrl.scheme()); - boolean sameHost = - locationUrl.host().toString().equalsIgnoreCase(requestUrl.host().toString()); - if (!sameScheme || !sameHost) { - requestBuilder.removeHeader("Authorization"); - } + Request.Builder requestBuilder = userResponse.request().newBuilder().url(locationUrl); + + // Scrub sensitive headers before following the redirect + java.util.function.Function proxyResolver = + RedirectHandlerOption.getProxyResolver(mProxySelector); + redirectOption + .scrubSensitiveHeaders() + .scrubHeaders(requestBuilder, requestUrl, proxyResolver); // Response status code 303 See Other then POST changes to GET if (userResponse.code() == HTTP_SEE_OTHER) { requestBuilder.method("GET", null); } - return requestBuilder.url(locationUrl).build(); + return requestBuilder.build(); } // Intercept request and response made to network @@ -163,7 +177,8 @@ Request getRedirect(final Request request, final Response userResponse) isRedirected(request, response, requestsCount, redirectOption) && redirectOption.shouldRedirect().shouldRedirect(response); - final Request followup = shouldRedirect ? getRedirect(request, response) : null; + final Request followup = + shouldRedirect ? getRedirect(request, response, redirectOption) : null; if (followup != null) { response.close(); request = followup; diff --git a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/RedirectHandlerOption.java b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/RedirectHandlerOption.java index e3b18d30..d81e58b5 100644 --- a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/RedirectHandlerOption.java +++ b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/RedirectHandlerOption.java @@ -5,6 +5,16 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; +import okhttp3.HttpUrl; +import okhttp3.Request; + +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.URI; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + /** * Options to be passed to the redirect middleware. */ @@ -28,11 +38,63 @@ public class RedirectHandlerOption implements RequestOption { */ @Nonnull public static final IShouldRedirect DEFAULT_SHOULD_REDIRECT = response -> true; + @Nonnull private final IScrubSensitiveHeaders scrubSensitiveHeaders; + + /** + * Functional interface for scrubbing sensitive headers during redirects. + */ + @FunctionalInterface + public interface IScrubSensitiveHeaders { + /** + * Scrubs sensitive headers from the request before following a redirect. + * @param requestBuilder The request builder to modify + * @param originalUrl The original request URL + * @param proxyResolver A function that returns the proxy for a given destination, or null if no proxy applies + */ + void scrubHeaders( + @Nonnull Request.Builder requestBuilder, + @Nonnull HttpUrl originalUrl, + @Nullable Function proxyResolver); + } + + /** + * The default implementation for scrubbing sensitive headers during redirects. + * This method removes Authorization and Cookie headers when the host, scheme, or port changes, + * and removes Proxy-Authorization headers when no proxy is configured or the proxy is bypassed for the new URL. + */ + @Nonnull public static final IScrubSensitiveHeaders DEFAULT_SCRUB_SENSITIVE_HEADERS = + (requestBuilder, originalUrl, proxyResolver) -> { + Objects.requireNonNull(requestBuilder, "parameter requestBuilder cannot be null"); + Objects.requireNonNull(originalUrl, "parameter originalUrl cannot be null"); + + // Get the new URL from the request builder + HttpUrl newUrl = requestBuilder.build().url(); + Objects.requireNonNull(newUrl, "The request URL cannot be null"); + + // Remove Authorization and Cookie headers if the request's scheme, host, or port + // changes + boolean isDifferentOrigin = + !newUrl.host().equalsIgnoreCase(originalUrl.host()) + || !newUrl.scheme().equalsIgnoreCase(originalUrl.scheme()) + || newUrl.port() != originalUrl.port(); + if (isDifferentOrigin) { + requestBuilder.removeHeader("Authorization"); + requestBuilder.removeHeader("Cookie"); + } + + // Remove Proxy-Authorization if no proxy is configured or the URL is bypassed + boolean isProxyInactive = + proxyResolver == null || proxyResolver.apply(newUrl) == null; + if (isProxyInactive) { + requestBuilder.removeHeader("Proxy-Authorization"); + } + }; + /** * Create default instance of redirect options, with default values of max redirects and should redirect */ public RedirectHandlerOption() { - this(DEFAULT_MAX_REDIRECTS, DEFAULT_SHOULD_REDIRECT); + this(DEFAULT_MAX_REDIRECTS, DEFAULT_SHOULD_REDIRECT, DEFAULT_SCRUB_SENSITIVE_HEADERS); } /** @@ -41,6 +103,19 @@ public RedirectHandlerOption() { * @param shouldRedirect Should redirect callback called before every redirect */ public RedirectHandlerOption(int maxRedirects, @Nullable final IShouldRedirect shouldRedirect) { + this(maxRedirects, shouldRedirect, DEFAULT_SCRUB_SENSITIVE_HEADERS); + } + + /** + * Create an instance with provided values + * @param maxRedirects Max redirects to occur + * @param shouldRedirect Should redirect callback called before every redirect + * @param scrubSensitiveHeaders Callback to scrub sensitive headers during redirects + */ + public RedirectHandlerOption( + int maxRedirects, + @Nullable final IShouldRedirect shouldRedirect, + @Nullable final IScrubSensitiveHeaders scrubSensitiveHeaders) { if (maxRedirects < 0) throw new IllegalArgumentException("Max redirects cannot be negative"); if (maxRedirects > MAX_REDIRECTS) @@ -48,6 +123,10 @@ public RedirectHandlerOption(int maxRedirects, @Nullable final IShouldRedirect s this.maxRedirects = maxRedirects; this.shouldRedirect = shouldRedirect != null ? shouldRedirect : DEFAULT_SHOULD_REDIRECT; + this.scrubSensitiveHeaders = + scrubSensitiveHeaders != null + ? scrubSensitiveHeaders + : DEFAULT_SCRUB_SENSITIVE_HEADERS; } /** @@ -66,6 +145,40 @@ public int maxRedirects() { return this.shouldRedirect; } + /** + * Gets the callback for scrubbing sensitive headers during redirects. + * @return scrub sensitive headers callback + */ + @Nonnull public IScrubSensitiveHeaders scrubSensitiveHeaders() { + return this.scrubSensitiveHeaders; + } + + /** + * Helper method to get a proxy resolver from a ProxySelector. + * @param proxySelector The ProxySelector to use, or null if no proxy is configured + * @return A function that resolves proxies for a given HttpUrl, or null if no proxy selector is provided + */ + @Nullable public static Function getProxyResolver( + @Nullable final ProxySelector proxySelector) { + if (proxySelector == null) { + return null; + } + return url -> { + try { + URI uri = new URI(url.scheme(), null, url.host(), url.port(), null, null, null); + List proxies = proxySelector.select(uri); + if (proxies != null && !proxies.isEmpty()) { + Proxy proxy = proxies.get(0); + // Return null for DIRECT proxies (no proxy) + return proxy.type() == Proxy.Type.DIRECT ? null : proxy; + } + return null; + } catch (Exception e) { + return null; + } + }; + } + /** {@inheritDoc} */ @SuppressWarnings("unchecked") @Override diff --git a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/RedirectHandlerTests.java b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/RedirectHandlerTests.java index e72a3faf..59277d80 100644 --- a/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/RedirectHandlerTests.java +++ b/components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/RedirectHandlerTests.java @@ -1,6 +1,9 @@ package com.microsoft.kiota.http.middleware; +import static org.junit.Assert.assertNull; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.*; import com.microsoft.kiota.http.KiotaClientFactory; import com.microsoft.kiota.http.middleware.options.RedirectHandlerOption; @@ -8,9 +11,13 @@ import okhttp3.*; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.Test; +import java.net.*; +import java.util.Collections; + @SuppressWarnings("resource") public class RedirectHandlerTests { @@ -54,4 +61,327 @@ void redirectsCanBeDisabled() throws Exception { assertEquals(301, response.code()); } + + @Test + void crossHostRedirectStripsAuthHeaders() throws Exception { + Request original = + new Request.Builder() + .url("http://trusted.example.com/api") + .addHeader("Authorization", "Bearer token") + .addHeader("Cookie", "session=SECRET") + .addHeader("Proxy-Authorization", "Basic ") + .build(); + Response redirect = + new Response.Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Found") + .header("Location", "http://evil.attacker.com/steal") + .body(ResponseBody.create("", MediaType.parse("text/plain"))) + .build(); + + RedirectHandlerOption option = new RedirectHandlerOption(); + Request result = new RedirectHandler().getRedirect(original, redirect, option); + assertNotNull(result); + assertEquals("evil.attacker.com", result.url().host()); + assertNull(result.header("Authorization")); // stripped (good) + assertNull(result.header("Cookie")); // stripped (good) + assertNull(result.header("Proxy-Authorization")); // stripped because no proxy (good) + } + + @Test + void endToEndProof() throws Exception { + var evil = new MockWebServer(); + evil.start(); + evil.enqueue(new MockResponse().setResponseCode(200)); + var trusted = new MockWebServer(); + trusted.start(); + trusted.enqueue( + new MockResponse().setResponseCode(302).setHeader("Location", evil.url("/steal"))); + OkHttpClient client = + KiotaClientFactory.create(new Interceptor[] {new RedirectHandler()}).build(); + client.newCall( + new Request.Builder() + .url(trusted.url("/api")) + .addHeader("Cookie", "session=SECRET") + .build()) + .execute(); + trusted.takeRequest(); + RecordedRequest captured = evil.takeRequest(); + assertNull(captured.getHeader("Cookie")); + evil.shutdown(); + trusted.shutdown(); + } + + @Test + void sameHostRedirectKeepsAllHeaders() throws Exception { + Request original = + new Request.Builder() + .url("http://trusted.example.com/api") + .addHeader("Authorization", "Bearer token") + .addHeader("Cookie", "session=SECRET") + .addHeader("Proxy-Authorization", "Basic ") + .build(); + Response redirect = + new Response.Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Found") + .header("Location", "http://trusted.example.com/other") + .body(ResponseBody.create("", MediaType.parse("text/plain"))) + .build(); + + // Setup proxy selector + ProxySelector proxySelector = mock(ProxySelector.class); + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("proxy.example.com", 8080)); + when(proxySelector.select(any(URI.class))).thenReturn(Collections.singletonList(proxy)); + + RedirectHandlerOption option = new RedirectHandlerOption(); + Request result = + new RedirectHandler(option, proxySelector).getRedirect(original, redirect, option); + + assertNotNull(result); + assertEquals("trusted.example.com", result.url().host()); + assertNotNull(result.header("Authorization")); // kept (same host) + assertNotNull(result.header("Cookie")); // kept (same host) + assertNotNull(result.header("Proxy-Authorization")); // kept (proxy is active) + } + + @Test + void crossHostRedirectWithProxyKeepsProxyAuth() throws Exception { + Request original = + new Request.Builder() + .url("http://trusted.example.com/api") + .addHeader("Authorization", "Bearer token") + .addHeader("Cookie", "session=SECRET") + .addHeader("Proxy-Authorization", "Basic ") + .build(); + Response redirect = + new Response.Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Found") + .header("Location", "http://other.example.com/api") + .body(ResponseBody.create("", MediaType.parse("text/plain"))) + .build(); + + // Setup proxy selector with active proxy + ProxySelector proxySelector = mock(ProxySelector.class); + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("proxy.example.com", 8080)); + when(proxySelector.select(any(URI.class))).thenReturn(Collections.singletonList(proxy)); + + RedirectHandlerOption option = new RedirectHandlerOption(); + Request result = + new RedirectHandler(option, proxySelector).getRedirect(original, redirect, option); + + assertNotNull(result); + assertEquals("other.example.com", result.url().host()); + assertNull(result.header("Authorization")); // stripped (different host) + assertNull(result.header("Cookie")); // stripped (different host) + assertNotNull( + result.header("Proxy-Authorization")); // KEPT because proxy is still active (good) + } + + @Test + void crossHostRedirectWithDirectProxyStripsProxyAuth() throws Exception { + Request original = + new Request.Builder() + .url("http://trusted.example.com/api") + .addHeader("Authorization", "Bearer token") + .addHeader("Cookie", "session=SECRET") + .addHeader("Proxy-Authorization", "Basic ") + .build(); + Response redirect = + new Response.Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Found") + .header("Location", "http://other.example.com/api") + .body(ResponseBody.create("", MediaType.parse("text/plain"))) + .build(); + + // Setup proxy selector with DIRECT proxy (no proxy) + ProxySelector proxySelector = mock(ProxySelector.class); + when(proxySelector.select(any(URI.class))) + .thenReturn(Collections.singletonList(Proxy.NO_PROXY)); + + RedirectHandlerOption option = new RedirectHandlerOption(); + Request result = + new RedirectHandler(option, proxySelector).getRedirect(original, redirect, option); + + assertNotNull(result); + assertEquals("other.example.com", result.url().host()); + assertNull(result.header("Authorization")); // stripped (different host) + assertNull(result.header("Cookie")); // stripped (different host) + assertNull( + result.header( + "Proxy-Authorization")); // stripped because proxy is DIRECT/inactive (good) + } + + @Test + void schemeChangeStripsAuthHeaders() throws Exception { + Request original = + new Request.Builder() + .url("https://trusted.example.com/api") + .addHeader("Authorization", "Bearer token") + .addHeader("Cookie", "session=SECRET") + .addHeader("Proxy-Authorization", "Basic ") + .build(); + Response redirect = + new Response.Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Found") + .header( + "Location", + "http://trusted.example.com/other") // HTTPS -> HTTP (same host) + .body(ResponseBody.create("", MediaType.parse("text/plain"))) + .build(); + + RedirectHandlerOption option = new RedirectHandlerOption(); + Request result = new RedirectHandler().getRedirect(original, redirect, option); + + assertNotNull(result); + assertEquals("trusted.example.com", result.url().host()); + assertEquals("http", result.url().scheme()); + assertNull(result.header("Authorization")); // stripped (scheme changed) + assertNull(result.header("Cookie")); // stripped (scheme changed) + assertNull(result.header("Proxy-Authorization")); // stripped (no proxy) + } + + @Test + void redirectWithDifferentPortRemovesAuthAndCookie() throws Exception { + Request original = + new Request.Builder() + .url("http://example.org:8080/foo") + .addHeader("Authorization", "Bearer token") + .addHeader("Cookie", "session=SECRET") + .build(); + Response redirect = + new Response.Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(301) + .message("Moved Permanently") + .header("Location", "http://example.org:9090/bar") + .body(ResponseBody.create("", MediaType.parse("text/plain"))) + .build(); + + RedirectHandlerOption option = new RedirectHandlerOption(); + Request result = new RedirectHandler().getRedirect(original, redirect, option); + + assertNotNull(result); + assertEquals("example.org", result.url().host()); + assertEquals(9090, result.url().port()); + assertNull(result.header("Authorization")); // stripped (port changed) + assertNull(result.header("Cookie")); // stripped (port changed) + } + + @Test + void redirectWithSamePortKeepsAuthAndCookie() throws Exception { + Request original = + new Request.Builder() + .url("http://example.org:8080/foo") + .addHeader("Authorization", "Bearer token") + .addHeader("Cookie", "session=SECRET") + .build(); + Response redirect = + new Response.Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Found") + .header("Location", "http://example.org:8080/bar") + .body(ResponseBody.create("", MediaType.parse("text/plain"))) + .build(); + + RedirectHandlerOption option = new RedirectHandlerOption(); + Request result = new RedirectHandler().getRedirect(original, redirect, option); + + assertNotNull(result); + assertEquals("example.org", result.url().host()); + assertEquals(8080, result.url().port()); + assertNotNull(result.header("Authorization")); // kept (same port) + assertNotNull(result.header("Cookie")); // kept (same port) + } + + @Test + void customScrubberIsUsed() throws Exception { + Request original = + new Request.Builder() + .url("http://trusted.example.com/api") + .addHeader("Authorization", "Bearer token") + .addHeader("Cookie", "session=SECRET") + .build(); + Response redirect = + new Response.Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Found") + .header("Location", "http://other.example.com/api") + .body(ResponseBody.create("", MediaType.parse("text/plain"))) + .build(); + + // Custom scrubber that never removes headers + RedirectHandlerOption.IScrubSensitiveHeaders customScrubber = + (requestBuilder, originalUrl, proxyResolver) -> { + // Don't remove any headers + }; + + RedirectHandlerOption option = new RedirectHandlerOption(5, null, customScrubber); + Request result = new RedirectHandler().getRedirect(original, redirect, option); + + assertNotNull(result); + assertEquals("other.example.com", result.url().host()); + assertNotNull(result.header("Authorization")); // KEPT by custom scrubber + assertNotNull(result.header("Cookie")); // KEPT by custom scrubber + } + + @Test + void customScrubberRemovesCustomHeaders() throws Exception { + Request original = + new Request.Builder() + .url("http://trusted.example.com/api") + .addHeader("Authorization", "Bearer token") + .addHeader("X-Custom-Secret", "my-secret-value") + .addHeader("X-Api-Key", "key-12345") + .addHeader("X-Safe-Header", "keep-me") + .build(); + Response redirect = + new Response.Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Found") + .header("Location", "http://other.example.com/api") + .body(ResponseBody.create("", MediaType.parse("text/plain"))) + .build(); + + // Custom scrubber that removes custom headers in addition to the defaults + RedirectHandlerOption.IScrubSensitiveHeaders customScrubber = + (requestBuilder, originalUrl, proxyResolver) -> { + // Apply default scrubbing first + RedirectHandlerOption.DEFAULT_SCRUB_SENSITIVE_HEADERS.scrubHeaders( + requestBuilder, originalUrl, proxyResolver); + // Also remove application-specific sensitive headers + requestBuilder.removeHeader("X-Custom-Secret"); + requestBuilder.removeHeader("X-Api-Key"); + }; + + RedirectHandlerOption option = new RedirectHandlerOption(5, null, customScrubber); + Request result = new RedirectHandler().getRedirect(original, redirect, option); + + assertNotNull(result); + assertEquals("other.example.com", result.url().host()); + assertNull(result.header("Authorization")); // stripped by default scrubber + assertNull(result.header("X-Custom-Secret")); // stripped by custom scrubber + assertNull(result.header("X-Api-Key")); // stripped by custom scrubber + assertNotNull(result.header("X-Safe-Header")); // kept (not in scrub list) + } }