Skip to content

Commit 1f0120e

Browse files
authored
fix: filter hop-by-hop headers in dev server proxy (#24092) (CP: 24.9) (#24102)
The dev server proxy in `AbstractDevServerRunner` forwarded all HTTP headers between the browser and the Vite dev server, including hop-by-hop headers that must not be forwarded by a proxy per RFC 9110 Section 7.6.1. It also forwarded the upstream Content-Length which may not match the actual bytes after HttpURLConnection decoding, causing broken responses on some servlet containers. This change filters hop-by-hop headers and Content-Length from proxied requests and responses, and avoids closing the output stream after `sendError()`. Related to #23564
1 parent 62abb69 commit 1f0120e

File tree

2 files changed

+192
-15
lines changed

2 files changed

+192
-15
lines changed

vaadin-dev-server/src/main/java/com/vaadin/base/devserver/AbstractDevServerRunner.java

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
import java.nio.charset.StandardCharsets;
3030
import java.util.Enumeration;
3131
import java.util.List;
32+
import java.util.Locale;
3233
import java.util.Map;
34+
import java.util.Set;
3335
import java.util.UUID;
3436
import java.util.concurrent.CompletableFuture;
3537
import java.util.concurrent.CompletionException;
@@ -98,6 +100,13 @@ public abstract class AbstractDevServerRunner implements DevModeHandler {
98100
private static final Pattern WEBPACK_ILLEGAL_CHAR_PATTERN = Pattern
99101
.compile("\"|%22");
100102

103+
// Hop-by-hop headers per RFC 9110 Section 7.6.1: these are
104+
// per-connection headers that a proxy must not forward to the next hop.
105+
// Stored in lowercase for case-insensitive matching.
106+
private static final Set<String> HOP_BY_HOP_HEADERS = Set.of("connection",
107+
"keep-alive", "proxy-authenticate", "proxy-authorization", "te",
108+
"trailer", "transfer-encoding", "upgrade");
109+
101110
private static final int DEFAULT_BUFFER_SIZE = 32 * 1024;
102111
private static final int DEFAULT_TIMEOUT = 120 * 1000;
103112

@@ -731,15 +740,20 @@ public boolean serveDevModeRequest(HttpServletRequest request,
731740
HttpURLConnection connection = prepareConnection(devServerRequestPath,
732741
request.getMethod());
733742

734-
// Copies all the headers from the original request
743+
// Copies request headers, skipping hop-by-hop headers
744+
// (RFC 9110 Section 7.6.1) which are per-connection and must not
745+
// be forwarded by a proxy
735746
Enumeration<String> headerNames = request.getHeaderNames();
736747
while (headerNames.hasMoreElements()) {
737748
String header = headerNames.nextElement();
738-
connection.setRequestProperty(header,
739-
// Exclude keep-alive
740-
"Connect".equals(header) ? "close"
741-
: request.getHeader(header));
749+
if (!HOP_BY_HOP_HEADERS
750+
.contains(header.toLowerCase(Locale.ENGLISH))) {
751+
connection.setRequestProperty(header,
752+
request.getHeader(header));
753+
}
742754
}
755+
// Connection is not reused, signal to the dev server
756+
connection.setRequestProperty("Connection", "close");
743757

744758
// Send the request
745759
getLogger().debug("Requesting resource from {} {}", getServerName(),
@@ -756,13 +770,18 @@ public boolean serveDevModeRequest(HttpServletRequest request,
756770
getLogger().debug("Served resource by {}: {} {}", getServerName(),
757771
responseCode, devServerRequestPath);
758772

759-
// Copies response headers
773+
// Copies response headers, skipping hop-by-hop headers
774+
// (RFC 9110 Section 7.6.1) and Content-Length. Content-Length is
775+
// dropped because HttpURLConnection may transparently decode the
776+
// body (de-chunking, decompression), making the original value
777+
// incorrect. The servlet container will determine the correct
778+
// framing from the actual bytes written (RFC 9110 Section 8.6).
760779
connection.getHeaderFields().forEach((header, values) -> {
761-
if (header != null) {
762-
if ("Transfer-Encoding".equals(header)) {
763-
return;
764-
}
765-
response.addHeader(header, values.get(0));
780+
if (header != null
781+
&& !HOP_BY_HOP_HEADERS
782+
.contains(header.toLowerCase(Locale.ENGLISH))
783+
&& !"Content-Length".equalsIgnoreCase(header)) {
784+
response.setHeader(header, values.get(0));
766785
}
767786
});
768787

@@ -775,16 +794,18 @@ public boolean serveDevModeRequest(HttpServletRequest request,
775794
// Copies response payload
776795
writeStream(response.getOutputStream(),
777796
connection.getInputStream());
797+
// Close request to avoid issues in CI and Chrome
798+
response.getOutputStream().close();
778799
} else if (responseCode < 400) {
779800
response.setStatus(responseCode);
801+
response.getOutputStream().close();
780802
} else {
781-
// Copies response code
803+
// sendError() commits the response and may generate an error
804+
// page; closing the output stream after that can throw in
805+
// some servlet containers.
782806
response.sendError(responseCode);
783807
}
784808

785-
// Close request to avoid issues in CI and Chrome
786-
response.getOutputStream().close();
787-
788809
return true;
789810
}
790811

vaadin-dev-server/src/test/java/com/vaadin/base/devserver/AbstractDevServerRunnerTest.java

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.vaadin.base.devserver;
22

3+
import java.io.ByteArrayInputStream;
34
import java.io.File;
45
import java.io.IOException;
56
import java.net.HttpURLConnection;
@@ -11,6 +12,7 @@
1112
import java.util.Arrays;
1213
import java.util.Collections;
1314
import java.util.HashMap;
15+
import java.util.LinkedHashMap;
1416
import java.util.List;
1517
import java.util.Map;
1618
import java.util.concurrent.CompletableFuture;
@@ -179,6 +181,160 @@ private InetAddress findLocalhostAddress(
179181
}
180182
}
181183

184+
@Test
185+
public void hopByHopRequestHeadersAreNotForwardedToDevServer()
186+
throws Exception {
187+
handler = new DummyRunner();
188+
waitForDevServer();
189+
DevModeHandler devServer = Mockito.spy(handler);
190+
191+
HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
192+
Mockito.when(response.getOutputStream())
193+
.thenReturn(Mockito.mock(ServletOutputStream.class));
194+
195+
HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
196+
Mockito.when(request.getPathInfo()).thenReturn("/VAADIN/test.js");
197+
Mockito.when(request.getRequestURI()).thenReturn("/VAADIN/test.js");
198+
199+
// Simulate browser headers including hop-by-hop
200+
List<String> headerNames = List.of("Connection", "Keep-Alive", "Accept",
201+
"Host", "Accept-Encoding");
202+
Mockito.when(request.getHeaderNames())
203+
.thenReturn(Collections.enumeration(headerNames));
204+
Mockito.when(request.getHeader("Connection")).thenReturn("keep-alive");
205+
Mockito.when(request.getHeader("Keep-Alive")).thenReturn("timeout=5");
206+
Mockito.when(request.getHeader("Accept")).thenReturn("*/*");
207+
Mockito.when(request.getHeader("Host")).thenReturn("localhost:8080");
208+
Mockito.when(request.getHeader("Accept-Encoding")).thenReturn("gzip");
209+
210+
HttpURLConnection connection = Mockito.mock(HttpURLConnection.class);
211+
Mockito.when(connection.getResponseCode())
212+
.thenReturn(HttpURLConnection.HTTP_OK);
213+
Mockito.when(connection.getHeaderFields()).thenReturn(Map.of());
214+
Mockito.when(connection.getInputStream())
215+
.thenReturn(new ByteArrayInputStream(new byte[0]));
216+
217+
Mockito.when(devServer.prepareConnection(Mockito.any(), Mockito.any()))
218+
.thenReturn(connection);
219+
220+
devServer.serveDevModeRequest(request, response);
221+
222+
// Hop-by-hop headers must not be forwarded
223+
Mockito.verify(connection, Mockito.never()).setRequestProperty(
224+
Mockito.eq("Connection"), Mockito.eq("keep-alive"));
225+
Mockito.verify(connection, Mockito.never()).setRequestProperty(
226+
Mockito.eq("Keep-Alive"), Mockito.anyString());
227+
228+
// Connection must be set to close since it's not reused
229+
Mockito.verify(connection).setRequestProperty("Connection", "close");
230+
231+
// Non-hop-by-hop headers must be forwarded
232+
Mockito.verify(connection).setRequestProperty("Accept", "*/*");
233+
Mockito.verify(connection).setRequestProperty("Host", "localhost:8080");
234+
Mockito.verify(connection).setRequestProperty("Accept-Encoding",
235+
"gzip");
236+
}
237+
238+
@Test
239+
public void hopByHopAndContentLengthResponseHeadersAreNotForwardedToBrowser()
240+
throws Exception {
241+
handler = new DummyRunner();
242+
waitForDevServer();
243+
DevModeHandler devServer = Mockito.spy(handler);
244+
245+
HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
246+
ServletOutputStream outputStream = Mockito
247+
.mock(ServletOutputStream.class);
248+
Mockito.when(response.getOutputStream()).thenReturn(outputStream);
249+
250+
HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
251+
Mockito.when(request.getPathInfo()).thenReturn("/VAADIN/test.js");
252+
Mockito.when(request.getRequestURI()).thenReturn("/VAADIN/test.js");
253+
Mockito.when(request.getHeaderNames())
254+
.thenReturn(Collections.emptyEnumeration());
255+
256+
// Dev server response with hop-by-hop headers, Content-Length, and
257+
// a legitimate end-to-end header
258+
Map<String, List<String>> responseHeaders = new LinkedHashMap<>();
259+
responseHeaders.put(null, List.of("HTTP/1.1 200 OK"));
260+
responseHeaders.put("Content-Type", List.of("application/javascript"));
261+
responseHeaders.put("Content-Length", List.of("42"));
262+
responseHeaders.put("Connection", List.of("keep-alive"));
263+
responseHeaders.put("Keep-Alive", List.of("timeout=5"));
264+
responseHeaders.put("Transfer-Encoding", List.of("chunked"));
265+
266+
HttpURLConnection connection = Mockito.mock(HttpURLConnection.class);
267+
Mockito.when(connection.getResponseCode())
268+
.thenReturn(HttpURLConnection.HTTP_OK);
269+
Mockito.when(connection.getHeaderFields()).thenReturn(responseHeaders);
270+
Mockito.when(connection.getInputStream())
271+
.thenReturn(new ByteArrayInputStream(new byte[0]));
272+
273+
Mockito.when(devServer.prepareConnection(Mockito.any(), Mockito.any()))
274+
.thenReturn(connection);
275+
276+
devServer.serveDevModeRequest(request, response);
277+
278+
// Only end-to-end headers should be forwarded
279+
Mockito.verify(response).setHeader("Content-Type",
280+
"application/javascript");
281+
282+
// Hop-by-hop headers must not be forwarded (RFC 9110 Section 7.6.1)
283+
Mockito.verify(response, Mockito.never())
284+
.setHeader(Mockito.eq("Connection"), Mockito.anyString());
285+
Mockito.verify(response, Mockito.never())
286+
.addHeader(Mockito.eq("Connection"), Mockito.anyString());
287+
Mockito.verify(response, Mockito.never())
288+
.setHeader(Mockito.eq("Keep-Alive"), Mockito.anyString());
289+
Mockito.verify(response, Mockito.never())
290+
.addHeader(Mockito.eq("Keep-Alive"), Mockito.anyString());
291+
Mockito.verify(response, Mockito.never()).setHeader(
292+
Mockito.eq("Transfer-Encoding"), Mockito.anyString());
293+
Mockito.verify(response, Mockito.never()).addHeader(
294+
Mockito.eq("Transfer-Encoding"), Mockito.anyString());
295+
296+
// Content-Length must not be forwarded (RFC 9110 Section 8.6:
297+
// may not match actual bytes after HttpURLConnection decoding)
298+
Mockito.verify(response, Mockito.never())
299+
.setHeader(Mockito.eq("Content-Length"), Mockito.anyString());
300+
Mockito.verify(response, Mockito.never())
301+
.addHeader(Mockito.eq("Content-Length"), Mockito.anyString());
302+
}
303+
304+
@Test
305+
public void outputStreamNotClosedAfterSendError() throws Exception {
306+
handler = new DummyRunner();
307+
waitForDevServer();
308+
DevModeHandler devServer = Mockito.spy(handler);
309+
310+
HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
311+
ServletOutputStream outputStream = Mockito
312+
.mock(ServletOutputStream.class);
313+
Mockito.when(response.getOutputStream()).thenReturn(outputStream);
314+
315+
HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
316+
Mockito.when(request.getPathInfo()).thenReturn("/VAADIN/test.js");
317+
Mockito.when(request.getRequestURI()).thenReturn("/VAADIN/test.js");
318+
Mockito.when(request.getHeaderNames())
319+
.thenReturn(Collections.emptyEnumeration());
320+
321+
HttpURLConnection connection = Mockito.mock(HttpURLConnection.class);
322+
Mockito.when(connection.getResponseCode())
323+
.thenReturn(HttpURLConnection.HTTP_INTERNAL_ERROR);
324+
Mockito.when(connection.getHeaderFields()).thenReturn(Map.of());
325+
326+
Mockito.when(devServer.prepareConnection(Mockito.any(), Mockito.any()))
327+
.thenReturn(connection);
328+
329+
devServer.serveDevModeRequest(request, response);
330+
331+
// sendError() commits the response; closing the output stream
332+
// after that can throw in some servlet containers
333+
Mockito.verify(response)
334+
.sendError(HttpURLConnection.HTTP_INTERNAL_ERROR);
335+
Mockito.verify(outputStream, Mockito.never()).close();
336+
}
337+
182338
private void assertOnDevProcessEnvironment(
183339
Class<? extends InetAddress> loopbackAddressType,
184340
Consumer<Map<String, String>> op) {

0 commit comments

Comments
 (0)