From 0579b326bee877cb2cc017f8c383a966c9e780e9 Mon Sep 17 00:00:00 2001 From: Biju Kunjummen Date: Fri, 7 Jul 2017 12:21:44 -0700 Subject: [PATCH] Support for re-writing Location Headers (#1863) * Support for re-writing Location Headers * Added additional constructor for Route, removed auto-configuration of LocationRewriteFilter * Polished docs, more tests --- .../main/asciidoc/spring-cloud-netflix.adoc | 26 +++ .../cloud/netflix/zuul/filters/Route.java | 8 + .../zuul/filters/SimpleRouteLocator.java | 3 +- .../netflix/zuul/filters/ZuulProperties.java | 3 +- .../filters/post/LocationRewriteFilter.java | 160 ++++++++++++++++ ...LocationRewriteFilterIntegrationTests.java | 109 +++++++++++ .../post/LocationRewriteFilterTests.java | 178 ++++++++++++++++++ 7 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilter.java create mode 100644 spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilterIntegrationTests.java create mode 100644 spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilterTests.java diff --git a/docs/src/main/asciidoc/spring-cloud-netflix.adoc b/docs/src/main/asciidoc/spring-cloud-netflix.adoc index 99a1bc65..dd81ce0d 100644 --- a/docs/src/main/asciidoc/spring-cloud-netflix.adoc +++ b/docs/src/main/asciidoc/spring-cloud-netflix.adoc @@ -1864,6 +1864,32 @@ class MyFallbackProvider implements ZuulFallbackProvider { } ---- +[[zuul-redirect-location-rewrite]] +=== Rewriting `Location` header + +If Zuul is fronting a web application then there may be a need to re-write the `Location` header when the web application redirects through a http status code of 3XX, otherwise the browser will end up redirecting to the web application's url instead of the Zuul url. +A `LocationRewriteFilter` Zuul filter can be configured to re-write the Location header to the Zuul's url, it also adds back the stripped global and route specific prefixes. The filter can be added the following way via a Spring Configuration file: + +[source,java] +---- +import org.springframework.cloud.netflix.zuul.filters.post.LocationRewriteFilter; +... + +@Configuration +@EnableZuulProxy +public class ZuulConfig { + @Bean + public LocationRewriteFilter locationRewriteFilter() { + return new LocationRewriteFilter(); + } +} +---- + +[WARNING] +==== +Use this filter with caution though, the filter acts on the `Location` header of ALL 3XX response codes which may not be appropriate in all scenarios, say if the user is redirecting to an external URL. +==== + [[zuul-developer-guide]] === Zuul Developer Guide diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/Route.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/Route.java index 63c0d7d7..a9e95e8d 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/Route.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/Route.java @@ -42,6 +42,12 @@ public class Route { } } } + + public Route(String id, String path, String location, String prefix, + Boolean retryable, Set ignoredHeaders, boolean prefixStripped) { + this(id, path, location, prefix, retryable, ignoredHeaders); + this.prefixStripped = prefixStripped; + } private String id; @@ -58,6 +64,8 @@ public class Route { private Set sensitiveHeaders = new LinkedHashSet<>(); private boolean customSensitiveHeaders; + + private boolean prefixStripped = true; public boolean isCustomSensitiveHeaders() { return this.customSensitiveHeaders; diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/SimpleRouteLocator.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/SimpleRouteLocator.java index 28ccdf85..d5798b1e 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/SimpleRouteLocator.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/SimpleRouteLocator.java @@ -151,7 +151,8 @@ public class SimpleRouteLocator implements RouteLocator, Ordered { } return new Route(route.getId(), targetPath, route.getLocation(), prefix, retryable, - route.isCustomSensitiveHeaders() ? route.getSensitiveHeaders() : null); + route.isCustomSensitiveHeaders() ? route.getSensitiveHeaders() : null, + route.isStripPrefix()); } /** diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java index 0f1d3f34..d25ac04a 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java @@ -304,7 +304,8 @@ public class ZuulProperties { public Route getRoute(String prefix) { return new Route(this.id, this.path, getLocation(), prefix, this.retryable, - isCustomSensitiveHeaders() ? this.sensitiveHeaders : null); + isCustomSensitiveHeaders() ? this.sensitiveHeaders : null, + this.stripPrefix); } public void setSensitiveHeaders(Set headers) { diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilter.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilter.java new file mode 100644 index 00000000..1cbdf4ae --- /dev/null +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilter.java @@ -0,0 +1,160 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.springframework.cloud.netflix.zuul.filters.post; + +import com.netflix.util.Pair; +import com.netflix.zuul.ZuulFilter; +import com.netflix.zuul.context.RequestContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.netflix.zuul.filters.Route; +import org.springframework.cloud.netflix.zuul.filters.RouteLocator; +import org.springframework.cloud.netflix.zuul.filters.ZuulProperties; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UrlPathHelper; + +import java.net.URI; + +import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.POST_TYPE; +import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.SEND_RESPONSE_FILTER_ORDER; + +/** + * {@link ZuulFilter} Responsible for rewriting the Location header to be the Zuul URL + * + * @author Biju Kunjummen + */ +public class LocationRewriteFilter extends ZuulFilter { + + private final UrlPathHelper urlPathHelper = new UrlPathHelper(); + + @Autowired + private ZuulProperties zuulProperties; + + @Autowired + private RouteLocator routeLocator; + + private static final String LOCATION_HEADER = "Location"; + + public LocationRewriteFilter() { + } + + public LocationRewriteFilter(ZuulProperties zuulProperties, + RouteLocator routeLocator) { + this.routeLocator = routeLocator; + this.zuulProperties = zuulProperties; + } + + @Override + public String filterType() { + return POST_TYPE; + } + + @Override + public int filterOrder() { + return SEND_RESPONSE_FILTER_ORDER - 100; + } + + @Override + public boolean shouldFilter() { + RequestContext ctx = RequestContext.getCurrentContext(); + int statusCode = ctx.getResponseStatusCode(); + return HttpStatus.valueOf(statusCode).is3xxRedirection(); + } + + @Override + public Object run() { + RequestContext ctx = RequestContext.getCurrentContext(); + Route route = routeLocator.getMatchingRoute( + urlPathHelper.getPathWithinApplication(ctx.getRequest())); + + if (route != null) { + Pair lh = locationHeader(ctx); + if (lh != null) { + String location = lh.second(); + URI originalRequestUri = UriComponentsBuilder + .fromHttpRequest(new ServletServerHttpRequest(ctx.getRequest())) + .build().toUri(); + + UriComponentsBuilder redirectedUriBuilder = UriComponentsBuilder + .fromUriString(location); + + UriComponents redirectedUriComps = redirectedUriBuilder.build(); + + String newPath = getRestoredPath(this.zuulProperties, route, + redirectedUriComps); + + String modifiedLocation = redirectedUriBuilder + .scheme(originalRequestUri.getScheme()) + .host(originalRequestUri.getHost()) + .port(originalRequestUri.getPort()).replacePath(newPath).build() + .toUriString(); + + lh.setSecond(modifiedLocation); + } + } + return null; + } + + private String getRestoredPath(ZuulProperties zuulProperties, Route route, + UriComponents redirectedUriComps) { + StringBuilder path = new StringBuilder(); + String redirectedPathWithoutGlobal = downstreamHasGlobalPrefix(zuulProperties) + ? redirectedUriComps.getPath() + .substring(("/" + zuulProperties.getPrefix()).length()) + : redirectedUriComps.getPath(); + + if (downstreamHasGlobalPrefix(zuulProperties)) { + path.append("/" + zuulProperties.getPrefix()); + } + else { + path.append(zuulHasGlobalPrefix(zuulProperties) + ? "/" + zuulProperties.getPrefix() : ""); + } + + path.append(downstreamHasRoutePrefix(route) ? "" : "/" + route.getPrefix()) + .append(redirectedPathWithoutGlobal); + + return path.toString(); + } + + private boolean downstreamHasGlobalPrefix(ZuulProperties zuulProperties) { + return (!zuulProperties.isStripPrefix() + && StringUtils.hasText(zuulProperties.getPrefix())); + } + + private boolean zuulHasGlobalPrefix(ZuulProperties zuulProperties) { + return StringUtils.hasText(zuulProperties.getPrefix()); + } + + private boolean downstreamHasRoutePrefix(Route route) { + return (!route.isPrefixStripped() && StringUtils.hasText(route.getPrefix())); + } + + private Pair locationHeader(RequestContext ctx) { + if (ctx.getZuulResponseHeaders() != null) { + for (Pair pair : ctx.getZuulResponseHeaders()) { + if (pair.first().equals(LOCATION_HEADER)) { + return pair; + } + } + } + return null; + } +} diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilterIntegrationTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilterIntegrationTests.java new file mode 100644 index 00000000..5715b544 --- /dev/null +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilterIntegrationTests.java @@ -0,0 +1,109 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.springframework.cloud.netflix.zuul.filters.post; + +import com.netflix.loadbalancer.Server; +import com.netflix.loadbalancer.ServerList; +import com.netflix.zuul.context.RequestContext; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.embedded.LocalServerPort; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.cloud.netflix.ribbon.RibbonClient; +import org.springframework.cloud.netflix.ribbon.StaticServerList; +import org.springframework.cloud.netflix.zuul.EnableZuulProxy; +import org.springframework.context.annotation.Bean; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Biju Kunjummen + */ + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "zuul.routes.aservice.path:/service/**", "zuul.routes.aservice.strip-prefix:true", + "eureka.client.enabled:false" }) +@DirtiesContext +public class LocationRewriteFilterIntegrationTests { + + @LocalServerPort + private int port; + + @Before + public void before() { + RequestContext context = new RequestContext(); + RequestContext.testSetCurrentContext(context); + } + + @Test + public void testWithRedirectPrefixStripped() { + String url = "http://localhost:" + port + "/service/redirectingUri"; + ResponseEntity response = new TestRestTemplate().getForEntity(url, + String.class); + List locationHeaders = response.getHeaders().get("Location"); + + assertThat(locationHeaders).hasSize(1); + String locationHeader = locationHeaders.get(0); + assertThat(locationHeader).withFailMessage("Location should have prefix") + .isEqualTo( + String.format("http://localhost:%d/service/redirectedUri", port)); + + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EnableZuulProxy + @Controller + @RibbonClient(name = "aservice", configuration = RibbonConfig.class) + protected static class Config { + + @RequestMapping("/redirectingUri") + public String redirect1() { + return "redirect:/redirectedUri"; + } + + @Bean + public LocationRewriteFilter locationRewriteFilter() { + return new LocationRewriteFilter(); + } + + } + + public static class RibbonConfig { + @LocalServerPort + private int port; + + @Bean + public ServerList ribbonServerList() { + return new StaticServerList<>(new Server("localhost", this.port)); + } + + } +} diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilterTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilterTests.java new file mode 100644 index 00000000..c29febe7 --- /dev/null +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/post/LocationRewriteFilterTests.java @@ -0,0 +1,178 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.springframework.cloud.netflix.zuul.filters.post; + +import com.netflix.util.Pair; +import com.netflix.zuul.context.RequestContext; +import org.junit.Before; +import org.junit.Test; +import org.springframework.cloud.netflix.zuul.filters.Route; +import org.springframework.cloud.netflix.zuul.filters.RouteLocator; +import org.springframework.cloud.netflix.zuul.filters.ZuulProperties; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author Biju Kunjummen + */ + +public class LocationRewriteFilterTests { + + private final String ZUUL_HOST = "myzuul.com"; + private final String ZUUL_SCHEME = "https"; + private final int ZUUL_PORT = 8443; + private final String ZUUL_BASE_URL = String.format("%s://%s:%d", ZUUL_SCHEME, + ZUUL_HOST, ZUUL_PORT); + + private final String SERVER_HOST = "someserver.com"; + private final String SERVER_SCHEME = "http"; + private final int SERVER_PORT = 8564; + private final String SERVER_BASE_URL = String.format("%s://%s:%d", SERVER_SCHEME, + SERVER_HOST, SERVER_PORT); + + @Before + public void before() { + RequestContext context = new RequestContext(); + RequestContext.testSetCurrentContext(context); + } + + @Test + public void shouldRewriteLocationHeadersWithRoutePrefix() { + RequestContext context = RequestContext.getCurrentContext(); + ZuulProperties zuulProperties = new ZuulProperties(); + LocationRewriteFilter filter = setFilterUpWith(context, zuulProperties, + new Route("service1", "/redirectingUri", "service1", "prefix", false, + Collections.EMPTY_SET, true), + "/prefix/redirectingUri", "/redirectedUri;someparam?param1=abc"); + filter.run(); + assertThat(getLocationHeader(context).second()).isEqualTo(String + .format("%s/prefix/redirectedUri;someparam?param1=abc", ZUUL_BASE_URL)); + } + + @Test + public void shouldBeUntouchedIfNoRoutesFound() { + RequestContext context = RequestContext.getCurrentContext(); + ZuulProperties zuulProperties = new ZuulProperties(); + LocationRewriteFilter filter = setFilterUpWith(context, zuulProperties, null, + "/prefix/redirectingUri", "/redirectedUri;someparam?param1=abc"); + filter.run(); + assertThat(getLocationHeader(context).second()).isEqualTo( + String.format("%s/redirectedUri;someparam?param1=abc", SERVER_BASE_URL)); + } + + @Test + public void shouldRewriteLocationHeadersIfPrefixIsNotStripped() { + RequestContext context = RequestContext.getCurrentContext(); + ZuulProperties zuulProperties = new ZuulProperties(); + LocationRewriteFilter filter = setFilterUpWith(context, zuulProperties, + new Route("service1", "/something/redirectingUri", "service1", "prefix", + false, Collections.EMPTY_SET, false), + "/prefix/redirectingUri", + "/something/redirectedUri;someparam?param1=abc"); + filter.run(); + assertThat(getLocationHeader(context).second()).isEqualTo(String.format( + "%s/something/redirectedUri;someparam?param1=abc", ZUUL_BASE_URL)); + } + + @Test + public void shouldRewriteLocationHeadersIfPrefixIsEmpty() { + RequestContext context = RequestContext.getCurrentContext(); + ZuulProperties zuulProperties = new ZuulProperties(); + LocationRewriteFilter filter = setFilterUpWith(context, zuulProperties, + new Route("service1", "/something/redirectingUri", "service1", "", false, + Collections.EMPTY_SET, true), + "/redirectingUri", "/something/redirectedUri;someparam?param1=abc"); + filter.run(); + assertThat(getLocationHeader(context).second()).isEqualTo(String.format( + "%s/something/redirectedUri;someparam?param1=abc", ZUUL_BASE_URL)); + } + + @Test + public void shouldAddBackGlobalPrefixIfPresent() { + RequestContext context = RequestContext.getCurrentContext(); + ZuulProperties zuulProperties = new ZuulProperties(); + zuulProperties.setPrefix("global"); + zuulProperties.setStripPrefix(true); + LocationRewriteFilter filter = setFilterUpWith(context, zuulProperties, + new Route("service1", "/something/redirectingUri", "service1", "prefix", + false, Collections.EMPTY_SET, true), + "/global/prefix/redirectingUri", + "/something/redirectedUri;someparam?param1=abc"); + filter.run(); + assertThat(getLocationHeader(context).second()).isEqualTo(String.format( + "%s/global/prefix/something/redirectedUri;someparam?param1=abc", + ZUUL_BASE_URL)); + } + + @Test + public void shouldNotAddBackGlobalPrefixIfNotStripped() { + RequestContext context = RequestContext.getCurrentContext(); + ZuulProperties zuulProperties = new ZuulProperties(); + zuulProperties.setPrefix("global"); + zuulProperties.setStripPrefix(false); + LocationRewriteFilter filter = setFilterUpWith(context, zuulProperties, + new Route("service1", "/something/redirectingUri", "service1", "prefix", + false, Collections.EMPTY_SET, true), + "/global/prefix/redirectingUri", + "/global/something/redirectedUri;someparam?param1=abc"); + filter.run(); + assertThat(getLocationHeader(context).second()).isEqualTo(String.format( + "%s/global/prefix/something/redirectedUri;someparam?param1=abc", + ZUUL_BASE_URL)); + } + + private LocationRewriteFilter setFilterUpWith(RequestContext context, + ZuulProperties zuulProperties, Route route, String toZuulRequestUri, + String redirectedUri) { + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + httpServletRequest.setRequestURI(toZuulRequestUri); + httpServletRequest.setServerName(ZUUL_HOST); + httpServletRequest.setScheme(ZUUL_SCHEME); + httpServletRequest.setServerPort(ZUUL_PORT); + context.setRequest(httpServletRequest); + + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + context.getZuulResponseHeaders().add(new Pair<>("Location", + String.format("%s%s", SERVER_BASE_URL, redirectedUri))); + context.setResponse(httpServletResponse); + + RouteLocator routeLocator = mock(RouteLocator.class); + when(routeLocator.getMatchingRoute(toZuulRequestUri)).thenReturn(route); + LocationRewriteFilter filter = new LocationRewriteFilter(zuulProperties, + routeLocator); + + return filter; + } + + private Pair getLocationHeader(RequestContext ctx) { + if (ctx.getZuulResponseHeaders() != null) { + for (Pair pair : ctx.getZuulResponseHeaders()) { + if (pair.first().equals("Location")) { + return pair; + } + } + } + return null; + } +}