diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java index 27bfa1e1..69fcb1cd 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2023 the original author or authors. + * Copyright 2013-2025 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. @@ -21,6 +21,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -40,11 +41,14 @@ import org.springframework.http.HttpInputMessage; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.util.StreamUtils; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.servlet.function.ServerRequest; import org.springframework.web.servlet.support.RequestContextUtils; import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; import static org.springframework.web.servlet.function.RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE; @@ -268,6 +272,17 @@ public abstract class MvcUtils { urls.add(url); } + public static MultiValueMap encodeQueryParams(MultiValueMap params) { + MultiValueMap encodedQueryParams = new LinkedMultiValueMap<>(params.size()); + for (Map.Entry> entry : params.entrySet()) { + for (String value : entry.getValue()) { + encodedQueryParams.add(UriUtils.encode(entry.getKey(), StandardCharsets.UTF_8), + UriUtils.encode(value, StandardCharsets.UTF_8)); + } + } + return CollectionUtils.unmodifiableMultiValueMap(encodedQueryParams); + } + private record ByteArrayInputMessage(ServerRequest request, ByteArrayInputStream body) implements HttpInputMessage { @Override diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ProxyExchangeHandlerFunction.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ProxyExchangeHandlerFunction.java index 0ea445fc..6923f2d6 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ProxyExchangeHandlerFunction.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ProxyExchangeHandlerFunction.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2023 the original author or authors. + * Copyright 2013-2025 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. @@ -37,6 +37,9 @@ import org.springframework.web.servlet.function.ServerRequest; import org.springframework.web.servlet.function.ServerResponse; import org.springframework.web.util.UriComponentsBuilder; +/** + * @author raccoonback + */ public class ProxyExchangeHandlerFunction implements HandlerFunction, ApplicationListener { @@ -84,14 +87,14 @@ public class ProxyExchangeHandlerFunction @Override public ServerResponse handle(ServerRequest serverRequest) { URI uri = uriResolver.apply(serverRequest); - boolean encoded = containsEncodedQuery(serverRequest.uri(), serverRequest.params()); + MultiValueMap params = MvcUtils.encodeQueryParams(serverRequest.params()); // @formatter:off URI url = UriComponentsBuilder.fromUri(serverRequest.uri()) .scheme(uri.getScheme()) .host(uri.getHost()) .port(uri.getPort()) - .replaceQueryParams(serverRequest.params()) - .build(encoded) + .replaceQueryParams(params) + .build(true) .toUri(); // @formatter:on @@ -131,29 +134,6 @@ public class ProxyExchangeHandlerFunction return filtered; } - private static boolean containsEncodedQuery(URI uri, MultiValueMap params) { - String rawQuery = uri.getRawQuery(); - boolean encoded = (rawQuery != null && rawQuery.contains("%")) - || (uri.getRawPath() != null && uri.getRawPath().contains("%")); - - // Verify if it is really fully encoded. Treat partial encoded as unencoded. - if (encoded) { - try { - UriComponentsBuilder.fromUri(uri).replaceQueryParams(params).build(true); - return true; - } - catch (IllegalArgumentException ignored) { - if (log.isTraceEnabled()) { - log.trace("Error in containsEncodedParts", ignored); - } - } - - return false; - } - - return false; - } - public interface URIResolver extends Function { } diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/handler/ProxyExchangeHandlerFunctionTest.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/handler/ProxyExchangeHandlerFunctionTest.java new file mode 100644 index 00000000..e8f650c7 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/handler/ProxyExchangeHandlerFunctionTest.java @@ -0,0 +1,143 @@ +/* + * Copyright 2025-2025 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 + * + * https://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.gateway.server.mvc.handler; + +import java.net.URI; +import java.util.Collections; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.cloud.gateway.server.mvc.common.AbstractProxyExchange; +import org.springframework.cloud.gateway.server.mvc.common.MvcUtils; +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; +import org.springframework.cloud.gateway.server.mvc.filter.HttpHeadersFilter.RequestHttpHeadersFilter; +import org.springframework.cloud.gateway.server.mvc.filter.HttpHeadersFilter.ResponseHttpHeadersFilter; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.web.servlet.function.ServerRequest; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author raccoonback + */ +class ProxyExchangeHandlerFunctionTest { + + @Test + void keepOriginalEncodingOfQueryParameter() { + TestProxyExchange proxyExchange = new TestProxyExchange(); + ProxyExchangeHandlerFunction function = new ProxyExchangeHandlerFunction(proxyExchange, new ObjectProvider<>() { + @Override + public RequestHttpHeadersFilter getObject() throws BeansException { + return null; + } + + @Override + public RequestHttpHeadersFilter getObject(Object... args) throws BeansException { + return null; + } + + @Override + public RequestHttpHeadersFilter getIfAvailable() throws BeansException { + return null; + } + + @Override + public RequestHttpHeadersFilter getIfUnique() throws BeansException { + return null; + } + + @Override + public Stream orderedStream() { + return Stream.of((httpHeaders, serverRequest) -> new HttpHeaders()); + } + + }, new ObjectProvider<>() { + + @Override + public ResponseHttpHeadersFilter getObject() throws BeansException { + return null; + } + + @Override + public ResponseHttpHeadersFilter getObject(Object... args) throws BeansException { + return null; + } + + @Override + public ResponseHttpHeadersFilter getIfAvailable() throws BeansException { + return null; + } + + @Override + public ResponseHttpHeadersFilter getIfUnique() throws BeansException { + return null; + } + + @Override + public Stream orderedStream() { + return Stream.of((httpHeaders, serverRequest) -> new HttpHeaders()); + + } + }); + + function.onApplicationEvent(null); + + MockHttpServletRequest servletRequest = MockMvcRequestBuilders + .get("http://localhost/é?foo=value1 value2&bar=value3=&qux=value4+") + .buildRequest(null); + servletRequest.setAttribute(MvcUtils.GATEWAY_REQUEST_URL_ATTR, URI.create("http://localhost:8080")); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + function.handle(request); + + URI uri = proxyExchange.getRequest().getUri(); + + assertThat(uri).hasToString("http://localhost:8080/%C3%A9?foo=value1%20value2&bar=value3%3D&qux=value4%2B") + .hasPath("/é") + .hasParameter("foo", "value1 value2") + .hasParameter("bar", "value3=") + .hasParameter("qux", "value4+"); + } + + private class TestProxyExchange extends AbstractProxyExchange { + + private Request request; + + protected TestProxyExchange() { + super(new GatewayMvcProperties()); + } + + @Override + public ServerResponse exchange(Request request) { + this.request = request; + + return ServerResponse.ok().build(); + } + + public Request getRequest() { + return request; + } + + } + +}