diff --git a/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/JerseyClientHandler.java b/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/JerseyClientHandler.java index c43cede5d6..3d4a513c08 100644 --- a/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/JerseyClientHandler.java +++ b/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/JerseyClientHandler.java @@ -19,12 +19,18 @@ import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; +import javax.ws.rs.HttpMethod; import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import org.glassfish.jersey.client.ClientProperties; @@ -125,6 +131,7 @@ protected void notifyResponse() { } else { ClientRequest newReq = new ClientRequest(jerseyRequest); newReq.setUri(newUri); + restrictRedirectRequest(newReq, cr); connector.execute(newReq, redirectUriHistory, responseAvailable); } } catch (IllegalArgumentException e) { @@ -217,4 +224,62 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exc super.userEventTriggered(ctx, evt); } } + + /* + * RFC 9110 Section 15.4 + * https://httpwg.org/specs/rfc9110.html#rfc.section.15.4 + */ + private void restrictRedirectRequest(ClientRequest newRequest, ClientResponse response) { + final MultivaluedMap headers = newRequest.getHeaders(); + final Boolean keepMethod = newRequest.resolveProperty(NettyClientProperties.PRESERVE_METHOD_ON_REDIRECT, Boolean.TRUE); + + if (Boolean.FALSE.equals(keepMethod) && newRequest.getMethod().equals(HttpMethod.POST)) { + switch (response.getStatus()) { + case 301 /* MOVED PERMANENTLY */: + case 302 /* FOUND */: + removeContentHeaders(headers); + newRequest.setMethod(HttpMethod.GET); + newRequest.setEntity(null); + break; + } + } + + for (final Iterator>> it = headers.entrySet().iterator(); it.hasNext(); ) { + final Map.Entry> entry = it.next(); + if (ProxyHeaders.INSTANCE.test(entry.getKey())) { + it.remove(); + } + } + + headers.remove(HttpHeaders.IF_MATCH); + headers.remove(HttpHeaders.IF_NONE_MATCH); + headers.remove(HttpHeaders.IF_MODIFIED_SINCE); + headers.remove(HttpHeaders.IF_UNMODIFIED_SINCE); + headers.remove(HttpHeaders.AUTHORIZATION); + headers.remove(HttpHeaderNames.REFERER.toString()); + headers.remove(HttpHeaders.COOKIE); + } + + private void removeContentHeaders(MultivaluedMap headers) { + for (final Iterator>> it = headers.entrySet().iterator(); it.hasNext(); ) { + final Map.Entry> entry = it.next(); + final String lowName = entry.getKey().toLowerCase(Locale.ROOT); + if (lowName.startsWith("content-")) { + it.remove(); + } + } + headers.remove(HttpHeaders.LAST_MODIFIED); + headers.remove(HttpHeaderNames.TRANSFER_ENCODING.toString()); + } + + /* package */ static class ProxyHeaders implements Predicate { + static final ProxyHeaders INSTANCE = new ProxyHeaders(); + private static final String HOST = HttpHeaders.HOST.toLowerCase(Locale.ROOT); + + @Override + public boolean test(String headerName) { + String lowName = headerName.toLowerCase(Locale.ROOT); + return lowName.startsWith("proxy-") || lowName.equals(HOST); + } + } } diff --git a/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/NettyClientProperties.java b/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/NettyClientProperties.java index 186c6d4159..344749b855 100644 --- a/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/NettyClientProperties.java +++ b/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/NettyClientProperties.java @@ -28,11 +28,32 @@ public class NettyClientProperties { /** *

- * This property determines the maximum number of idle connections that will be simultaneously kept alive - * in total, rather than per destination. The default is 60. Specify 0 to disable. + * Sets the endpoint identification algorithm to HTTPS. + *

+ *

+ * The default value is {@code true} (for HTTPS uri scheme). *

+ *

+ * The name of the configuration property is {@value}. + *

+ * @since 2.35 + * @see javax.net.ssl.SSLParameters#setEndpointIdentificationAlgorithm(String) */ - public static final String MAX_CONNECTIONS_TOTAL = "jersey.config.client.maxTotalConnections"; + public static final String ENABLE_SSL_HOSTNAME_VERIFICATION = "jersey.config.client.tls.enableHostnameVerification"; + + /** + *

+ * Filter the HTTP headers for requests (CONNECT) towards the proxy except for PROXY-prefixed and HOST headers when {@code true}. + *

+ *

+ * The default value is {@code true} and the headers are filtered out. + *

+ *

+ * The name of the configuration property is {@value}. + *

+ * @since 2.41 + */ + public static final String FILTER_HEADERS_FOR_PROXY = "jersey.config.client.filter.headers.proxy"; /** *

@@ -56,18 +77,11 @@ public class NettyClientProperties { /** *

- * Sets the endpoint identification algorithm to HTTPS. - *

- *

- * The default value is {@code true} (for HTTPS uri scheme). - *

- *

- * The name of the configuration property is {@value}. + * This property determines the maximum number of idle connections that will be simultaneously kept alive + * in total, rather than per destination. The default is 60. Specify 0 to disable. *

- * @since 2.35 - * @see javax.net.ssl.SSLParameters#setEndpointIdentificationAlgorithm(String) */ - public static final String ENABLE_SSL_HOSTNAME_VERIFICATION = "jersey.config.client.tls.enableHostnameVerification"; + public static final String MAX_CONNECTIONS_TOTAL = "jersey.config.client.maxTotalConnections"; /** * The maximal number of redirects during single request. @@ -82,4 +96,20 @@ public class NettyClientProperties { * @see org.glassfish.jersey.netty.connector.internal.RedirectException */ public static final String MAX_REDIRECTS = "jersey.config.client.NettyConnectorProvider.maxRedirects"; + + /** + *

+ * Sets the HTTP POST method to be preserved on HTTP status 301 (MOVED PERMANENTLY) or status 302 (FOUND) when {@code true} + * or redirected as GET when {@code false}. + *

+ *

+ * The default value is {@code true} and the HTTP POST request is not redirected as GET. + *

+ *

+ * The name of the configuration property is {@value}. + *

+ * @since 2.41 + */ + public static final String PRESERVE_METHOD_ON_REDIRECT = "jersey.config.client.redirect.preserve.method"; + } diff --git a/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/NettyConnector.java b/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/NettyConnector.java index e6cb98c3e9..6629d334b7 100644 --- a/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/NettyConnector.java +++ b/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/NettyConnector.java @@ -376,7 +376,7 @@ protected void initChannel(SocketChannel ch) throws Exception { } // headers - setHeaders(jerseyRequest, nettyRequest.headers()); + setHeaders(jerseyRequest, nettyRequest.headers(), false); // host header - http 1.1 if (!nettyRequest.headers().contains(HttpHeaderNames.HOST)) { @@ -538,7 +538,8 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exc private static ProxyHandler createProxyHandler(ClientRequest jerseyRequest, SocketAddress proxyAddr, String userName, String password, long connectTimeout) { - HttpHeaders httpHeaders = setHeaders(jerseyRequest, new DefaultHttpHeaders()); + final Boolean filter = jerseyRequest.resolveProperty(NettyClientProperties.FILTER_HEADERS_FOR_PROXY, Boolean.TRUE); + HttpHeaders httpHeaders = setHeaders(jerseyRequest, new DefaultHttpHeaders(), Boolean.TRUE.equals(filter)); ProxyHandler proxy = userName == null ? new HttpProxyHandler(proxyAddr, httpHeaders) : new HttpProxyHandler(proxyAddr, userName, password, httpHeaders); @@ -549,9 +550,11 @@ private static ProxyHandler createProxyHandler(ClientRequest jerseyRequest, Sock return proxy; } - private static HttpHeaders setHeaders(ClientRequest jerseyRequest, HttpHeaders headers) { + private static HttpHeaders setHeaders(ClientRequest jerseyRequest, HttpHeaders headers, boolean proxyOnly) { for (final Map.Entry> e : jerseyRequest.getStringHeaders().entrySet()) { - headers.add(e.getKey(), e.getValue()); + if (!proxyOnly || JerseyClientHandler.ProxyHeaders.INSTANCE.test(e.getKey())) { + headers.add(e.getKey(), e.getValue()); + } } return headers; } diff --git a/connectors/netty-connector/src/test/java/org/glassfish/jersey/netty/connector/RedirectHeadersTest.java b/connectors/netty-connector/src/test/java/org/glassfish/jersey/netty/connector/RedirectHeadersTest.java new file mode 100644 index 0000000000..dc8e48c935 --- /dev/null +++ b/connectors/netty-connector/src/test/java/org/glassfish/jersey/netty/connector/RedirectHeadersTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.glassfish.jersey.netty.connector; + +import io.netty.handler.codec.http.HttpHeaderNames; +import org.glassfish.jersey.client.ClientConfig; +import org.glassfish.jersey.client.ClientProperties; +import org.glassfish.jersey.logging.LoggingFeature; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.test.JerseyTest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.Application; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import java.net.URI; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +public class RedirectHeadersTest extends JerseyTest { + + private static final Logger LOGGER = Logger.getLogger(RedirectHeadersTest.class.getName()); + private static final String TEST_URL = "http://localhost:%d/test"; + private static final AtomicReference TEST_URL_REF = new AtomicReference<>(); + private static final String ENTITY = "entity"; + + @BeforeEach + public void before() { + final String url = String.format(TEST_URL, getPort()); + TEST_URL_REF.set(url); + } + + @Path("/test") + public static class RedirectResource { + @GET + public String get(@QueryParam("value") String value) { + return "GET" + value; + } + + @POST + public String echo(@QueryParam("value") String value, String entity) { + return entity + value; + } + + @GET + @Path("headers2") + public String headers(@Context HttpHeaders headers) { + String encoding = headers.getHeaderString(HttpHeaders.CONTENT_ENCODING); + String auth = headers.getHeaderString(HttpHeaderNames.PROXY_AUTHORIZATION.toString()); + return encoding + ":" + auth; + } + + @POST + @Path("301") + public Response redirect301(String entity) { + return Response.status(Response.Status.MOVED_PERMANENTLY) + .header(HttpHeaders.LOCATION, URI.create(TEST_URL_REF.get() + "?value=301")) + .build(); + } + + @POST + @Path("302") + public Response redirect302(String entity) { + return Response.status(Response.Status.FOUND) + .header(HttpHeaders.LOCATION, URI.create(TEST_URL_REF.get() + "?value=302")) + .build(); + } + + @POST + @Path("307") + public Response redirect307(String entity) { + return Response.status(Response.Status.TEMPORARY_REDIRECT) + .header(HttpHeaders.LOCATION, URI.create(TEST_URL_REF.get() + "?value=307")) + .build(); + } + + @POST + @Path("308") + public Response redirectHeaders(String entity) { + return Response.status(308) + .header(HttpHeaders.LOCATION, URI.create(TEST_URL_REF.get() + "?value=308")) + .build(); + } + + + @POST + @Path("headers1") + public Response redirect308(String whatever) { + return Response.status(301).header(HttpHeaders.LOCATION, URI.create(TEST_URL_REF.get() + "/headers2")).build(); + } + } + + @Override + protected Application configure() { + ResourceConfig config = new ResourceConfig(RedirectResource.class); + config.register(new LoggingFeature(LOGGER, LoggingFeature.Verbosity.PAYLOAD_ANY)); + return config; + } + + @Override + protected void configureClient(ClientConfig config) { + config.property(ClientProperties.FOLLOW_REDIRECTS, true); + config.connectorProvider(new NettyConnectorProvider()); + } + + @Test + void testPost() { + testPost("301"); + testPost("302"); + testPost("307"); + testPost("308"); + } + + @Test + void testGet() { + Assertions.assertEquals("GET301", testGet("301")); + Assertions.assertEquals("GET302", testGet("302")); + Assertions.assertEquals(ENTITY + "307", testGet("307")); + Assertions.assertEquals(ENTITY + "308", testGet("308")); + } + + @Test + void testHeaders() { + MultivaluedMap headers = new MultivaluedHashMap<>(); + headers.add(HttpHeaders.CONTENT_ENCODING, "gzip"); + headers.add(HttpHeaderNames.PROXY_AUTHORIZATION.toString(), "basic aGVsbG86d29ybGQ="); + try (Response response = target("test") + .property(NettyClientProperties.PRESERVE_METHOD_ON_REDIRECT, false) + .path("headers1").request().headers(headers).post(Entity.entity(ENTITY, MediaType.TEXT_PLAIN_TYPE))) { + Assertions.assertEquals(200, response.getStatus()); + Assertions.assertEquals("null:null", response.readEntity(String.class)); + } + + } + + void testPost(String status) { + try (Response response = target("test").path(status).request().post(Entity.entity(ENTITY, MediaType.TEXT_PLAIN_TYPE))) { + Assertions.assertEquals(200, response.getStatus()); + Assertions.assertEquals(ENTITY + status, response.readEntity(String.class)); + } + } + + String testGet(String status) { + try (Response response = target("test") + .property(NettyClientProperties.PRESERVE_METHOD_ON_REDIRECT, false) + .path(status).request().post(Entity.entity(ENTITY, MediaType.TEXT_PLAIN_TYPE))) { + Assertions.assertEquals(200, response.getStatus()); + return response.readEntity(String.class); + } + } +} diff --git a/docs/src/main/docbook/appendix-properties.xml b/docs/src/main/docbook/appendix-properties.xml index e31e66dd66..a5c652ac57 100644 --- a/docs/src/main/docbook/appendix-properties.xml +++ b/docs/src/main/docbook/appendix-properties.xml @@ -1244,8 +1244,8 @@ If supported by Jackson provider, the default value can differ for each Jackson version. For instance, - Jackson 14 does not support this setting and the default value is Integer#MAX_VALUE, - Jackson 15.0 has the default value 5_000_000, Jackson 15.2 has the default value 20_000_000. + Jackson 2.14 does not support this setting and the default value is Integer#MAX_VALUE, + Jackson 2.15.0 has the default value 5_000_000, Jackson 2.15.2 has the default value 20_000_000. @@ -1990,6 +1990,19 @@ + + &jersey.netty.NettyClientProperties.FILTER_HEADERS_FOR_PROXY; + jersey.config.client.filter.headers.proxy + + + Filter the HTTP headers for requests (CONNECT) towards the proxy except for + PROXY-prefixed and HOST headers when &lit.true;. + + + The default value is &lit.true; and the headers are filtered out. + + + &jersey.netty.NettyClientProperties.IDLE_CONNECTION_PRUNE_TIMEOUT; jersey.config.client.idleConnectionPruneTimeout @@ -2036,6 +2049,19 @@ + + &jersey.netty.NettyClientProperties.PRESERVE_METHOD_ON_REDIRECT; + jersey.config.client.redirect.preserve.method + + + Sets the HTTP POST method to be preserved on HTTP status 301 (MOVED PERMANENTLY) or status 302 (FOUND) + when &lit.true; or redirected as GET when &lit.false;. + + + The default value is &lit.true; and the HTTP POST request is not redirected as GET. + + + diff --git a/docs/src/main/docbook/jersey.ent b/docs/src/main/docbook/jersey.ent index 93aabcfb87..25e6b9e260 100644 --- a/docs/src/main/docbook/jersey.ent +++ b/docs/src/main/docbook/jersey.ent @@ -552,10 +552,12 @@ SecurityEntityFilteringFeature"> SelectableEntityFilteringFeature"> NettyClientProperties" > +NettyClientProperties.FILTER_HEADERS_FOR_PROXY" > NettyClientProperties.IDLE_CONNECTION_PRUNE_TIMEOUT" > NettyClientProperties.MAX_CONNECTIONS" > NettyClientProperties.MAX_CONNECTIONS_TOTAL" > NettyClientProperties.MAX_REDIRECTS" > +NettyClientProperties.PRESERVE_METHOD_ON_REDIRECT" > NettyConnectorProvider"> ApplicationHandler"> @BackgroundScheduler"> diff --git a/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/connector/proxy/ProxySelectorTest.java b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/connector/proxy/ProxySelectorTest.java index cb714f3c32..5b2b6b498b 100644 --- a/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/connector/proxy/ProxySelectorTest.java +++ b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/connector/proxy/ProxySelectorTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2022 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2020, 2023 Oracle and/or its affiliates. All rights reserved. * Copyright (c) 2019 Banco do Brasil S/A. All rights reserved. * * This program and the accompanying materials are made available under the @@ -22,6 +22,7 @@ import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.handler.AbstractHandler; import org.glassfish.jersey.client.ClientConfig; +import org.glassfish.jersey.netty.connector.NettyClientProperties; import org.glassfish.jersey.netty.connector.NettyConnectorProvider; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; @@ -49,7 +50,7 @@ public class ProxySelectorTest { private static final String NO_PASS = "no-pass"; protected void configureClient(ClientConfig config) { - config.connectorProvider(new NettyConnectorProvider()); + config.connectorProvider(new NettyConnectorProvider()).property(NettyClientProperties.FILTER_HEADERS_FOR_PROXY, false); } @Test diff --git a/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/connector/proxy/ProxyTest.java b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/connector/proxy/ProxyTest.java index 6fcd46db24..7b64a90932 100644 --- a/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/connector/proxy/ProxyTest.java +++ b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/connector/proxy/ProxyTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2022 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2020, 2023 Oracle and/or its affiliates. All rights reserved. * Copyright (c) 2019 Banco do Brasil S/A. All rights reserved. * * This program and the accompanying materials are made available under the @@ -71,7 +71,7 @@ public class ProxyTest { private static final String PROXY_URI = "http://127.0.0.1:9997"; private static final String PROXY_USERNAME = "proxy-user"; private static final String PROXY_PASSWORD = "proxy-password"; - private static final String NO_PASS = "no-pass"; + private static final String PROXY_NO_PASS = "proxy-no-pass"; public static class ApacheConnectorProviderProxyTest extends ProxyTemplateTest { public ApacheConnectorProviderProxyTest() @@ -137,7 +137,7 @@ protected void configureClient(ClientConfig config) { @Test public void testGetNoPass() { client().property(ClientProperties.PROXY_URI, ProxyTest.PROXY_URI); - try (Response response = target("proxyTest").request().header(NO_PASS, 200).get()) { + try (Response response = target("proxyTest").request().header(PROXY_NO_PASS, 200).get()) { assertEquals(200, response.getStatus()); } } @@ -209,8 +209,8 @@ public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) { - if (request.getHeader(NO_PASS) != null) { - response.setStatus(Integer.parseInt(request.getHeader(NO_PASS))); + if (request.getHeader(PROXY_NO_PASS) != null) { + response.setStatus(Integer.parseInt(request.getHeader(PROXY_NO_PASS))); } else if (request.getHeader("Proxy-Authorization") != null) { String proxyAuthorization = request.getHeader("Proxy-Authorization"); String decoded = new String(Base64.getDecoder().decode(proxyAuthorization.substring(6).getBytes()),