diff --git a/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/ContinueProtocolHandler.java b/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/ContinueProtocolHandler.java index 213e0ef2586d..86111bc11b89 100644 --- a/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/ContinueProtocolHandler.java +++ b/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/ContinueProtocolHandler.java @@ -39,10 +39,14 @@ public String getName() @Override public boolean accept(Request request, Response response) { - boolean is100 = response.getStatus() == HttpStatus.CONTINUE_100; - boolean expect100 = request.getHeaders().contains(HttpHeader.EXPECT, HttpHeaderValue.CONTINUE.asString()); boolean handled100 = request.getAttributes().containsKey(ATTRIBUTE); - return (is100 || expect100) && !handled100; + if (handled100) + return false; + boolean is100 = response.getStatus() == HttpStatus.CONTINUE_100; + if (is100) + return true; + // Also handle non-100 responses, because we need to complete the request to complete the whole exchange. + return request.getHeaders().contains(HttpHeader.EXPECT, HttpHeaderValue.CONTINUE.asString()); } @Override diff --git a/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpConnection.java b/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpConnection.java index 160ca2649ffe..38723867bb2e 100644 --- a/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpConnection.java +++ b/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpConnection.java @@ -20,7 +20,6 @@ import org.eclipse.jetty.client.Authentication; import org.eclipse.jetty.client.AuthenticationStore; -import org.eclipse.jetty.client.BytesRequestContent; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpProxy; import org.eclipse.jetty.client.HttpRequestException; @@ -35,6 +34,7 @@ import org.eclipse.jetty.io.CyclicTimeouts; import org.eclipse.jetty.util.Attachable; import org.eclipse.jetty.util.NanoTime; +import org.eclipse.jetty.util.StringUtil; import org.eclipse.jetty.util.thread.AutoLock; import org.eclipse.jetty.util.thread.Scheduler; import org.slf4j.Logger; @@ -146,7 +146,7 @@ protected void normalizeRequest(HttpRequest request) // Make sure the path is there String path = request.getPath(); - if (path.trim().length() == 0) + if (StringUtil.isBlank(path)) { path = "/"; request.path(path); @@ -191,11 +191,7 @@ protected void normalizeRequest(HttpRequest request) // Add content headers. Request.Content content = request.getBody(); - if (content == null) - { - request.body(new BytesRequestContent()); - } - else + if (content != null) { if (!headers.contains(HttpHeader.CONTENT_TYPE)) { @@ -203,10 +199,7 @@ protected void normalizeRequest(HttpRequest request) if (contentType == null) contentType = getHttpClient().getDefaultRequestContentType(); if (contentType != null) - { - HttpField field = new HttpField(HttpHeader.CONTENT_TYPE, contentType); - request.addHeader(field); - } + request.addHeader(new HttpField(HttpHeader.CONTENT_TYPE, contentType)); } long contentLength = content.getLength(); if (contentLength >= 0) @@ -215,6 +208,9 @@ protected void normalizeRequest(HttpRequest request) request.addHeader(new HttpField.LongValueHttpField(HttpHeader.CONTENT_LENGTH, contentLength)); } } + // RFC 9110, section 10.1.1. + if (content == null || content.getLength() == 0) + request.headers(h -> h.remove(HttpHeader.EXPECT)); // Cookies. StringBuilder cookies = convertCookies(request.getCookies(), null); @@ -243,7 +239,7 @@ private StringBuilder convertCookies(List cookies, StringBuilder bui { if (builder == null) builder = new StringBuilder(); - if (builder.length() > 0) + if (!builder.isEmpty()) builder.append("; "); builder.append(cookie.getName()).append("=").append(cookie.getValue()); } diff --git a/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpSender.java b/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpSender.java index 43903c1437a7..55476b7f48ce 100644 --- a/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpSender.java +++ b/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpSender.java @@ -528,7 +528,7 @@ protected Action process() throws Throwable action.run(); // Read the request content. - chunk = content.read(); + chunk = content != null ? content.read() : Content.Chunk.EOF; } if (LOG.isDebugEnabled()) LOG.debug("Content {} for {}", chunk, request); @@ -539,6 +539,7 @@ protected Action process() throws Throwable { // No content after the headers, demand. demanded = true; + assert content != null; content.demand(this::succeeded); return Action.SCHEDULED; } diff --git a/jetty-core/jetty-proxy/src/main/java/org/eclipse/jetty/proxy/ProxyHandler.java b/jetty-core/jetty-proxy/src/main/java/org/eclipse/jetty/proxy/ProxyHandler.java index f72236241881..ae4540ca7107 100644 --- a/jetty-core/jetty-proxy/src/main/java/org/eclipse/jetty/proxy/ProxyHandler.java +++ b/jetty-core/jetty-proxy/src/main/java/org/eclipse/jetty/proxy/ProxyHandler.java @@ -393,11 +393,12 @@ protected void addForwardedHeader(Request clientToProxyRequest, org.eclipse.jett private boolean hasContent(Request clientToProxyRequest) { - if (clientToProxyRequest.getLength() > 0) + long contentLength = clientToProxyRequest.getLength(); + if (contentLength == 0) + return false; + if (contentLength > 0) return true; - HttpFields headers = clientToProxyRequest.getHeaders(); - return headers.get(HttpHeader.CONTENT_TYPE) != null || - headers.get(HttpHeader.TRANSFER_ENCODING) != null; + return clientToProxyRequest.getHeaders().get(HttpHeader.TRANSFER_ENCODING) != null; } private boolean expects100Continue(Request clientToProxyRequest) diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpChannelState.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpChannelState.java index e97331b573ae..5aa3287355e6 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpChannelState.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/internal/HttpChannelState.java @@ -1117,8 +1117,6 @@ public String toString() */ public static class ChannelResponse implements Response, Callback { - private static final CompletableFuture UNEXPECTED_100_CONTINUE = CompletableFuture.failedFuture(new IllegalStateException("100 not expected")); - private static final CompletableFuture COMMITTED_100_CONTINUE = CompletableFuture.failedFuture(new IllegalStateException("Committed")); private final ChannelRequest _request; private final ResponseHttpFields _httpFields; protected int _status; @@ -1408,12 +1406,14 @@ public CompletableFuture writeInterim(int status, HttpFields headers) if (status == HttpStatus.CONTINUE_100) { if (!httpChannelState._expects100Continue) - return UNEXPECTED_100_CONTINUE; + return CompletableFuture.failedFuture(new IllegalStateException("100 not expected")); + if (_request.getLength() == 0) + return CompletableFuture.completedFuture(null); httpChannelState._expects100Continue = false; } if (_httpFields.isCommitted()) - return status == HttpStatus.CONTINUE_100 ? COMMITTED_100_CONTINUE : CompletableFuture.failedFuture(new IllegalStateException("Committed")); + return CompletableFuture.failedFuture(new IllegalStateException("Committed")); if (_writeCallback != null) return CompletableFuture.failedFuture(new WritePendingException()); diff --git a/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/StringUtil.java b/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/StringUtil.java index 7d30336279e4..393500f4001c 100644 --- a/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/StringUtil.java +++ b/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/StringUtil.java @@ -657,21 +657,7 @@ public static int indexOfControlChars(String str) */ public static boolean isBlank(String str) { - if (str == null) - { - return true; - } - int len = str.length(); - for (int i = 0; i < len; i++) - { - if (!Character.isWhitespace(str.codePointAt(i))) - { - // found a non-whitespace, we can stop searching now - return false; - } - } - // only whitespace - return true; + return str == null || str.isBlank(); } /** @@ -727,21 +713,7 @@ public static int getLength(String s) */ public static boolean isNotBlank(String str) { - if (str == null) - { - return false; - } - int len = str.length(); - for (int i = 0; i < len; i++) - { - if (!Character.isWhitespace(str.codePointAt(i))) - { - // found a non-whitespace, we can stop searching now - return true; - } - } - // only whitespace - return false; + return !isBlank(str); } public static boolean isHex(String str, int offset, int length) diff --git a/jetty-ee10/jetty-ee10-proxy/src/main/java/org/eclipse/jetty/ee10/proxy/AbstractProxyServlet.java b/jetty-ee10/jetty-ee10-proxy/src/main/java/org/eclipse/jetty/ee10/proxy/AbstractProxyServlet.java index dd7db3f33a05..3bebd249a616 100644 --- a/jetty-ee10/jetty-ee10-proxy/src/main/java/org/eclipse/jetty/ee10/proxy/AbstractProxyServlet.java +++ b/jetty-ee10/jetty-ee10-proxy/src/main/java/org/eclipse/jetty/ee10/proxy/AbstractProxyServlet.java @@ -456,9 +456,12 @@ protected void onProxyRewriteFailed(HttpServletRequest clientRequest, HttpServle protected boolean hasContent(HttpServletRequest clientRequest) { - return clientRequest.getContentLength() > 0 || - clientRequest.getContentType() != null || - clientRequest.getHeader(HttpHeader.TRANSFER_ENCODING.asString()) != null; + long contentLength = clientRequest.getContentLengthLong(); + if (contentLength == 0) + return false; + if (contentLength > 0) + return true; + return clientRequest.getHeader(HttpHeader.TRANSFER_ENCODING.asString()) != null; } protected boolean expects100Continue(HttpServletRequest request) diff --git a/jetty-ee10/jetty-ee10-proxy/src/test/java/org/eclipse/jetty/ee10/proxy/ProxyServletTest.java b/jetty-ee10/jetty-ee10-proxy/src/test/java/org/eclipse/jetty/ee10/proxy/ProxyServletTest.java index ccd90c8a2753..f98ca0d4a350 100644 --- a/jetty-ee10/jetty-ee10-proxy/src/test/java/org/eclipse/jetty/ee10/proxy/ProxyServletTest.java +++ b/jetty-ee10/jetty-ee10-proxy/src/test/java/org/eclipse/jetty/ee10/proxy/ProxyServletTest.java @@ -20,7 +20,9 @@ import java.io.PrintWriter; import java.io.Writer; import java.net.ConnectException; +import java.net.InetSocketAddress; import java.nio.ByteBuffer; +import java.nio.channels.SocketChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -80,6 +82,7 @@ import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpScheme; import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpTester; import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.server.HttpConfiguration; import org.eclipse.jetty.server.HttpConnectionFactory; @@ -1703,4 +1706,80 @@ protected void service(HttpServletRequest request, HttpServletResponse response) assertEquals(HttpStatus.OK_200, response.getStatus()); } } + + @ParameterizedTest + @MethodSource("impls") + public void testExpect100ContinueContentLengthZero(Class proxyServletClass) throws Exception + { + testExpect100ContinueNoRequestContent(proxyServletClass, false); + } + + @ParameterizedTest + @MethodSource("impls") + public void testExpect100ContinueEmptyChunkedContent(Class proxyServletClass) throws Exception + { + testExpect100ContinueNoRequestContent(proxyServletClass, true); + } + + private void testExpect100ContinueNoRequestContent(Class proxyServletClass, boolean chunked) throws Exception + { + startServer(new HttpServlet() + { + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException + { + // Send the 100 Continue. + ServletInputStream input = request.getInputStream(); + // Echo the content. + IO.copy(input, response.getOutputStream()); + } + }); + startProxy(proxyServletClass); + + String authority = "localhost:" + serverConnector.getLocalPort(); + for (int i = 0; i < 50; i++) + { + try (SocketChannel client = SocketChannel.open(new InetSocketAddress("localhost", proxyConnector.getLocalPort()))) + { + String request; + if (chunked) + { + request = """ + POST http://$A/ HTTP/1.1 + Host: $A + Expect: 100-Continue + Transfer-Encoding: chunked + + 0 + + """; + } + else + { + request = """ + POST http://$A/ HTTP/1.1 + Host: $A + Expect: 100-Continue + Content-Length: 0 + + """; + } + request = request.replace("$A", authority); + client.write(StandardCharsets.UTF_8.encode(request)); + + HttpTester.Input input = HttpTester.from(client); + HttpTester.Response response1 = HttpTester.parseResponse(input); + if (chunked) + { + assertEquals(HttpStatus.CONTINUE_100, response1.getStatus()); + HttpTester.Response response2 = HttpTester.parseResponse(input); + assertEquals(HttpStatus.OK_200, response2.getStatus()); + } + else + { + assertEquals(HttpStatus.OK_200, response1.getStatus()); + } + } + } + } } diff --git a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-client-transports/src/test/java/org/eclipse/jetty/ee10/test/client/transport/HttpClientContinueTest.java b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-client-transports/src/test/java/org/eclipse/jetty/ee10/test/client/transport/HttpClientContinueTest.java index 11b33f6cc8b8..4626f0a7f5dc 100644 --- a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-client-transports/src/test/java/org/eclipse/jetty/ee10/test/client/transport/HttpClientContinueTest.java +++ b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-client-transports/src/test/java/org/eclipse/jetty/ee10/test/client/transport/HttpClientContinueTest.java @@ -22,6 +22,7 @@ import java.net.ServerSocket; import java.net.Socket; import java.nio.ByteBuffer; +import java.nio.channels.SocketChannel; import java.nio.charset.StandardCharsets; import java.util.EnumSet; import java.util.Random; @@ -45,11 +46,13 @@ import org.eclipse.jetty.client.Request; import org.eclipse.jetty.client.Response; import org.eclipse.jetty.client.Result; +import org.eclipse.jetty.client.StringRequestContent; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpHeaderValue; import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.HttpTester; +import org.eclipse.jetty.server.NetworkConnector; import org.eclipse.jetty.util.Callback; import org.eclipse.jetty.util.IO; import org.junit.jupiter.api.Test; @@ -163,7 +166,6 @@ protected void service(HttpServletRequest request, HttpServletResponse response) .body(content) .timeout(5, TimeUnit.SECONDS) .send(); - } assertNotNull(response); @@ -233,14 +235,14 @@ public long getLength() @MethodSource("transportsNoFCGI") public void testExpect100ContinueWithContentRespond417ExpectationFailed(Transport transport) throws Exception { - testExpect100ContinueWithContentRespondError(transport, 417); + testExpect100ContinueWithContentRespondError(transport, HttpStatus.EXPECTATION_FAILED_417); } @ParameterizedTest @MethodSource("transportsNoFCGI") public void testExpect100ContinueWithContentRespond413RequestEntityTooLarge(Transport transport) throws Exception { - testExpect100ContinueWithContentRespondError(transport, 413); + testExpect100ContinueWithContentRespondError(transport, HttpStatus.PAYLOAD_TOO_LARGE_413); } private void testExpect100ContinueWithContentRespondError(Transport transport, int error) throws Exception @@ -796,6 +798,64 @@ protected void service(HttpServletRequest request, HttpServletResponse response) assertTrue(clientLatch.await(5, TimeUnit.SECONDS)); } + @ParameterizedTest + @MethodSource("transportsNoFCGI") + public void testExpect100ContinueWithContentLengthZeroExpectIsRemoved(Transport transport) throws Exception + { + start(transport, new HttpServlet() + { + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) + { + assertEquals(0, request.getContentLengthLong()); + // The Expect header must have been removed by the client. + assertNull(request.getHeader(HttpHeader.EXPECT.asString())); + } + }); + + ContentResponse response = client.newRequest(newURI(transport)) + .headers(headers -> headers.put(HttpHeader.EXPECT, HttpHeaderValue.CONTINUE.asString())) + .body(new StringRequestContent("")) + .timeout(5, TimeUnit.SECONDS) + .send(); + + assertEquals(HttpStatus.OK_200, response.getStatus()); + } + + @Test + public void testExpect100ContinueWithContentLengthZero() throws Exception + { + startServer(Transport.HTTP, new HttpServlet() + { + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException + { + assertEquals(0, request.getContentLengthLong()); + assertNotNull(request.getHeader(HttpHeader.EXPECT.asString())); + + // Trigger the 100-Continue logic. + // The 100 continue will not be sent, since there is no request content. + ServletInputStream input = request.getInputStream(); + assertEquals(-1, input.read()); + } + }); + + try (SocketChannel client = SocketChannel.open(new InetSocketAddress("localhost", ((NetworkConnector)connector).getLocalPort()))) + { + String request = """ + GET / HTTP/1.1 + Host: localhost + Expect: 100-Continue + Content-Length: 0 + + """; + client.write(StandardCharsets.UTF_8.encode(request)); + + HttpTester.Response response = HttpTester.parseResponse(HttpTester.from(client)); + assertEquals(HttpStatus.OK_200, response.getStatus()); + } + } + @Test public void testExpect100ContinueWithTwoResponsesInOneRead() throws Exception { diff --git a/jetty-ee9/jetty-ee9-proxy/src/main/java/org/eclipse/jetty/ee9/proxy/AbstractProxyServlet.java b/jetty-ee9/jetty-ee9-proxy/src/main/java/org/eclipse/jetty/ee9/proxy/AbstractProxyServlet.java index c8a6cd738b99..2827ec39b540 100644 --- a/jetty-ee9/jetty-ee9-proxy/src/main/java/org/eclipse/jetty/ee9/proxy/AbstractProxyServlet.java +++ b/jetty-ee9/jetty-ee9-proxy/src/main/java/org/eclipse/jetty/ee9/proxy/AbstractProxyServlet.java @@ -459,9 +459,12 @@ protected void onProxyRewriteFailed(HttpServletRequest clientRequest, HttpServle protected boolean hasContent(HttpServletRequest clientRequest) { - return clientRequest.getContentLength() > 0 || - clientRequest.getContentType() != null || - clientRequest.getHeader(HttpHeader.TRANSFER_ENCODING.asString()) != null; + long contentLength = clientRequest.getContentLengthLong(); + if (contentLength == 0) + return false; + if (contentLength > 0) + return true; + return clientRequest.getHeader(HttpHeader.TRANSFER_ENCODING.asString()) != null; } protected boolean expects100Continue(HttpServletRequest request) diff --git a/jetty-ee9/jetty-ee9-proxy/src/test/java/org/eclipse/jetty/ee9/proxy/ProxyServletTest.java b/jetty-ee9/jetty-ee9-proxy/src/test/java/org/eclipse/jetty/ee9/proxy/ProxyServletTest.java index f1d5513df298..e8c48e7ca4f0 100644 --- a/jetty-ee9/jetty-ee9-proxy/src/test/java/org/eclipse/jetty/ee9/proxy/ProxyServletTest.java +++ b/jetty-ee9/jetty-ee9-proxy/src/test/java/org/eclipse/jetty/ee9/proxy/ProxyServletTest.java @@ -20,7 +20,9 @@ import java.io.PrintWriter; import java.io.Writer; import java.net.ConnectException; +import java.net.InetSocketAddress; import java.nio.ByteBuffer; +import java.nio.channels.SocketChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -79,6 +81,7 @@ import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpScheme; import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpTester; import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.server.HttpConfiguration; import org.eclipse.jetty.server.HttpConnectionFactory; @@ -1701,4 +1704,80 @@ protected void service(HttpServletRequest request, HttpServletResponse response) assertEquals(HttpStatus.OK_200, response.getStatus()); } } + + @ParameterizedTest + @MethodSource("impls") + public void testExpect100ContinueContentLengthZero(Class proxyServletClass) throws Exception + { + testExpect100ContinueNoRequestContent(proxyServletClass, false); + } + + @ParameterizedTest + @MethodSource("impls") + public void testExpect100ContinueEmptyChunkedContent(Class proxyServletClass) throws Exception + { + testExpect100ContinueNoRequestContent(proxyServletClass, true); + } + + private void testExpect100ContinueNoRequestContent(Class proxyServletClass, boolean chunked) throws Exception + { + startServer(new HttpServlet() + { + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException + { + // Send the 100 Continue. + ServletInputStream input = request.getInputStream(); + // Echo the content. + IO.copy(input, response.getOutputStream()); + } + }); + startProxy(proxyServletClass); + + String authority = "localhost:" + serverConnector.getLocalPort(); + for (int i = 0; i < 50; i++) + { + try (SocketChannel client = SocketChannel.open(new InetSocketAddress("localhost", proxyConnector.getLocalPort()))) + { + String request; + if (chunked) + { + request = """ + POST http://$A/ HTTP/1.1 + Host: $A + Expect: 100-Continue + Transfer-Encoding: chunked + + 0 + + """; + } + else + { + request = """ + POST http://$A/ HTTP/1.1 + Host: $A + Expect: 100-Continue + Content-Length: 0 + + """; + } + request = request.replace("$A", authority); + client.write(StandardCharsets.UTF_8.encode(request)); + + HttpTester.Input input = HttpTester.from(client); + HttpTester.Response response1 = HttpTester.parseResponse(input); + if (chunked) + { + assertEquals(HttpStatus.CONTINUE_100, response1.getStatus()); + HttpTester.Response response2 = HttpTester.parseResponse(input); + assertEquals(HttpStatus.OK_200, response2.getStatus()); + } + else + { + assertEquals(HttpStatus.OK_200, response1.getStatus()); + } + } + } + } }