diff --git a/google-http-client/src/main/java/com/google/api/client/http/HttpRequest.java b/google-http-client/src/main/java/com/google/api/client/http/HttpRequest.java index 78f15d868..e8b67d88a 100644 --- a/google-http-client/src/main/java/com/google/api/client/http/HttpRequest.java +++ b/google-http-client/src/main/java/com/google/api/client/http/HttpRequest.java @@ -29,6 +29,7 @@ import io.opencensus.trace.Tracer; import java.io.IOException; import java.io.InputStream; +import java.net.URL; import java.util.Properties; import java.util.concurrent.Callable; import java.util.concurrent.Executor; @@ -860,6 +861,10 @@ public HttpResponse execute() throws IOException { Preconditions.checkNotNull(requestMethod); Preconditions.checkNotNull(url); + final String originalScheme = url.getScheme(); + final String originalHost = url.getHost(); + final int originalPort = url.getPort(); + Span span = tracer .spanBuilder(OpenCensusUtils.SPAN_NAME_HTTP_REQUEST_EXECUTE) @@ -879,6 +884,11 @@ public HttpResponse execute() throws IOException { if (executeInterceptor != null) { executeInterceptor.intercept(this); } + // Prevent credential leak on cross-origin redirects + if (!isSameOrigin(originalScheme, originalHost, originalPort, url.getScheme(), url.getHost(), url.getPort())) { + headers.setAuthorization((String) null); + headers.setCookie((String) null); + } // build low-level HTTP request String urlString = url.build(); addSpanAttribute(span, HttpTraceAttributeConstants.HTTP_METHOD, requestMethod); @@ -1180,7 +1190,25 @@ public boolean handleRedirect(int statusCode, HttpHeaders responseHeaders) { && HttpStatusCodes.isRedirect(statusCode) && redirectLocation != null) { // resolve the redirect location relative to the current location - setUrl(new GenericUrl(url.toURL(redirectLocation), useRawRedirectUrls)); + URL newURL = url.toURL(redirectLocation); + + // Check if redirecting to a different origin + String oldScheme = url.getScheme(); + String oldHost = url.getHost(); + int oldPort = url.getPort(); + + String newScheme = newURL.getProtocol(); + String newHost = newURL.getHost(); + int newPort = newURL.getPort(); + + int oldEffectivePort = getEffectivePort(oldScheme, oldPort); + int newEffectivePort = getEffectivePort(newScheme, newPort); + + boolean sameOrigin = (oldScheme == null ? newScheme == null : oldScheme.equalsIgnoreCase(newScheme)) + && (oldHost == null ? newHost == null : oldHost.equalsIgnoreCase(newHost)) + && (oldEffectivePort == newEffectivePort); + + setUrl(new GenericUrl(newURL, useRawRedirectUrls)); // on 303 change method to GET if (statusCode == HttpStatusCodes.STATUS_CODE_SEE_OTHER) { setRequestMethod(HttpMethods.GET); @@ -1194,11 +1222,39 @@ public boolean handleRedirect(int statusCode, HttpHeaders responseHeaders) { headers.setIfModifiedSince((String) null); headers.setIfUnmodifiedSince((String) null); headers.setIfRange((String) null); + + // remove Cookie header if redirect is cross-origin + if (!sameOrigin) { + headers.setCookie((String) null); + } return true; } return false; } + private static int getEffectivePort(String scheme, int port) { + if (port != -1) { + return port; + } + if ("http".equalsIgnoreCase(scheme)) { + return 80; + } + if ("https".equalsIgnoreCase(scheme)) { + return 443; + } + return -1; + } + + private static boolean isSameOrigin( + String scheme1, String host1, int port1, + String scheme2, String host2, int port2) { + int effectivePort1 = getEffectivePort(scheme1, port1); + int effectivePort2 = getEffectivePort(scheme2, port2); + return (scheme1 == null ? scheme2 == null : scheme1.equalsIgnoreCase(scheme2)) + && (host1 == null ? host2 == null : host1.equalsIgnoreCase(host2)) + && (effectivePort1 == effectivePort2); + } + /** * Returns the sleeper. * diff --git a/google-http-client/src/test/java/com/google/api/client/http/HttpRequestTest.java b/google-http-client/src/test/java/com/google/api/client/http/HttpRequestTest.java index 085b9f563..b6e6a1baf 100644 --- a/google-http-client/src/test/java/com/google/api/client/http/HttpRequestTest.java +++ b/google-http-client/src/test/java/com/google/api/client/http/HttpRequestTest.java @@ -21,6 +21,7 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import com.google.api.client.http.BasicAuthentication; import com.google.api.client.testing.http.HttpTesting; import com.google.api.client.testing.http.MockHttpTransport; import com.google.api.client.testing.http.MockHttpUnsuccessfulResponseHandler; @@ -1327,4 +1328,154 @@ public void testVersion_matchesAcceptablePatterns() throws Exception { String.format("the loaded version '%s' did not match the acceptable pattern", version), version.matches(acceptableVersionPattern)); } + + @Test + public void testHandleRedirect_crossOriginCookieRemoval() throws IOException { + // 1. Same-origin redirect (http://some.org/a/b -> http://some.org/a/z) + { + HttpTransport transport = new MockHttpTransport(); + HttpRequest req = transport.createRequestFactory().buildGetRequest(new GenericUrl("http://some.org/a/b")); + req.getHeaders().setCookie("foo=bar"); + HttpHeaders responseHeaders = new HttpHeaders().setLocation("http://some.org/a/z"); + req.handleRedirect(HttpStatusCodes.STATUS_CODE_SEE_OTHER, responseHeaders); + assertEquals("foo=bar", req.getHeaders().getCookie()); + assertEquals("http://some.org/a/z", req.getUrl().toString()); + } + + // 2. Cross-origin redirect due to host change (http://some.org/a/b -> http://other.org/c) + { + HttpTransport transport = new MockHttpTransport(); + HttpRequest req = transport.createRequestFactory().buildGetRequest(new GenericUrl("http://some.org/a/b")); + req.getHeaders().setCookie("foo=bar"); + HttpHeaders responseHeaders = new HttpHeaders().setLocation("http://other.org/c"); + req.handleRedirect(HttpStatusCodes.STATUS_CODE_SEE_OTHER, responseHeaders); + assertNull(req.getHeaders().getCookie()); + assertEquals("http://other.org/c", req.getUrl().toString()); + } + + // 3. Cross-origin redirect due to scheme change (https://some.org/a/b -> http://some.org/a/z) + { + HttpTransport transport = new MockHttpTransport(); + HttpRequest req = transport.createRequestFactory().buildGetRequest(new GenericUrl("https://some.org/a/b")); + req.getHeaders().setCookie("foo=bar"); + HttpHeaders responseHeaders = new HttpHeaders().setLocation("http://some.org/a/z"); + req.handleRedirect(HttpStatusCodes.STATUS_CODE_SEE_OTHER, responseHeaders); + assertNull(req.getHeaders().getCookie()); + assertEquals("http://some.org/a/z", req.getUrl().toString()); + } + + // 4. Cross-origin redirect due to port change (http://some.org/a/b -> http://some.org:8080/a/z) + { + HttpTransport transport = new MockHttpTransport(); + HttpRequest req = transport.createRequestFactory().buildGetRequest(new GenericUrl("http://some.org/a/b")); + req.getHeaders().setCookie("foo=bar"); + HttpHeaders responseHeaders = new HttpHeaders().setLocation("http://some.org:8080/a/z"); + req.handleRedirect(HttpStatusCodes.STATUS_CODE_SEE_OTHER, responseHeaders); + assertNull(req.getHeaders().getCookie()); + assertEquals("http://some.org:8080/a/z", req.getUrl().toString()); + } + + // 5. Same-origin redirect with implicit/explicit default ports matching (http://some.org/a/b -> http://some.org:80/a/z) + { + HttpTransport transport = new MockHttpTransport(); + HttpRequest req = transport.createRequestFactory().buildGetRequest(new GenericUrl("http://some.org/a/b")); + req.getHeaders().setCookie("foo=bar"); + HttpHeaders responseHeaders = new HttpHeaders().setLocation("http://some.org:80/a/z"); + req.handleRedirect(HttpStatusCodes.STATUS_CODE_SEE_OTHER, responseHeaders); + assertEquals("foo=bar", req.getHeaders().getCookie()); + assertEquals("http://some.org:80/a/z", req.getUrl().toString()); + } + } + + @Test + public void testExecute_crossOriginRedirectCredentialLeakPrevention() throws Exception { + final List recordedRequests = Lists.newArrayList(); + + HttpTransport transport = new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, final String url) { + MockLowLevelHttpRequest req = new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + recordedRequests.add(this); + MockLowLevelHttpResponse resp = new MockLowLevelHttpResponse(); + if (recordedRequests.size() == 1) { + resp.setStatusCode(302); + resp.addHeader("Location", "https://untrusted-target.com/path"); + } else { + resp.setStatusCode(200); + } + return resp; + } + }; + return req; + } + }; + + HttpRequest req = transport.createRequestFactory().buildGetRequest(new GenericUrl("https://example.com/start")); + req.setInterceptor(new BasicAuthentication("myuser", "mypass")); + req.getHeaders().setCookie("mycookie=val"); + + HttpResponse response = req.execute(); + assertEquals(200, response.getStatusCode()); + assertEquals(2, recordedRequests.size()); + + // First request (https://example.com/start) + MockLowLevelHttpRequest firstReq = recordedRequests.get(0); + assertTrue(firstReq.getUrl().contains("example.com")); + assertNotNull(firstReq.getFirstHeaderValue("Authorization")); + assertEquals("mycookie=val", firstReq.getFirstHeaderValue("Cookie")); + + // Second request (https://untrusted-target.com/path) - redirect to cross-origin + MockLowLevelHttpRequest secondReq = recordedRequests.get(1); + assertTrue(secondReq.getUrl().contains("untrusted-target.com")); + assertNull(secondReq.getFirstHeaderValue("Authorization")); + assertNull(secondReq.getFirstHeaderValue("Cookie")); + } + + @Test + public void testExecute_sameOriginRedirectCredentialLeakPrevention() throws Exception { + final List recordedRequests = Lists.newArrayList(); + + HttpTransport transport = new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, final String url) { + MockLowLevelHttpRequest req = new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + recordedRequests.add(this); + MockLowLevelHttpResponse resp = new MockLowLevelHttpResponse(); + if (recordedRequests.size() == 1) { + resp.setStatusCode(302); + resp.addHeader("Location", "https://example.com/redirect-path"); + } else { + resp.setStatusCode(200); + } + return resp; + } + }; + return req; + } + }; + + HttpRequest req = transport.createRequestFactory().buildGetRequest(new GenericUrl("https://example.com/start")); + req.setInterceptor(new BasicAuthentication("myuser", "mypass")); + req.getHeaders().setCookie("mycookie=val"); + + HttpResponse response = req.execute(); + assertEquals(200, response.getStatusCode()); + assertEquals(2, recordedRequests.size()); + + // First request + MockLowLevelHttpRequest firstReq = recordedRequests.get(0); + assertTrue(firstReq.getUrl().contains("example.com/start")); + assertNotNull(firstReq.getFirstHeaderValue("Authorization")); + assertEquals("mycookie=val", firstReq.getFirstHeaderValue("Cookie")); + + // Second request - redirect same-origin + MockLowLevelHttpRequest secondReq = recordedRequests.get(1); + assertTrue(secondReq.getUrl().contains("example.com/redirect-path")); + assertNotNull(secondReq.getFirstHeaderValue("Authorization")); + assertEquals("mycookie=val", secondReq.getFirstHeaderValue("Cookie")); + } }