This is an automated email from the ASF dual-hosted git repository.
reiern70 pushed a commit to branch wicket-10.x
in repository https://gitbox.apache.org/repos/asf/wicket.git
The following commit(s) were added to refs/heads/wicket-10.x by this push:
new 5e4dcaf940 Keep the Servlet header contract in the WebSocket request
copies
5e4dcaf940 is described below
commit 5e4dcaf9409939ea2bcc134eba9b481825700363
Author: reiern70 <[email protected]>
AuthorDate: Mon Oct 19 12:59:37 2026 -0500
Keep the Servlet header contract in the WebSocket request copies
ServletRequestCopy, the HttpServletRequest Wicket keeps for the lifetime of
a WebSocket connection, stored the header enumerations of the request it
copied and handed the same ones out on every read. It looked them up by the
exact name the container reported, so a lowercase "origin", as sent over
HTTP/2 and by many proxies, was not found as "Origin". Each enumeration
could
be read once: getHeader consumed a value, so a second call returned the next
value or null; getHeaders returned the spent enumeration; getIntHeader
consumed it as well and threw a NullPointerException for an absent header.
getHeaders returned null for an absent header, which ServletWebRequest
dereferences. getLocales had the same single-use problem.
JavaxUpgradeHttpRequest, the request every JSR 356 connection is copied
from, kept the handshake header map as it came, looked names up
case-sensitively and returned null from getHeaders and getHeaderNames when
there was nothing to return.
Both now copy the headers when they are created, into a map whose names
compare case-insensitively as the Servlet specification requires, and return
a fresh enumeration on every read, empty rather than null when there are no
values. ServletRequestCopy returns -1 from getIntHeader for an absent header
and copies the locales the same way. A name the copied request reports more
than once in different case is copied once, because that request's
getHeaders already returns all of its values; JavaxUpgradeHttpRequest merges
the values of handshake map keys that differ only in case.
An application now sees a WebSocket request that answers header lookups the
way the handshake request did. WebSocketConnectionOriginFilter no longer
rejects a connection whose Origin header arrived in lowercase.
getHeaderNames lists each name once, in case-insensitive alphabetical order.
Only private fields change; there is no API change.
GitHub issue #1688
---
.../wicket/protocol/ws/api/ServletRequestCopy.java | 47 ++++----
.../protocol/ws/api/ServletRequestCopyTest.java | 126 +++++++++++++++++++++
.../protocol/ws/javax/JavaxUpgradeHttpRequest.java | 88 +++++---------
.../ws/javax/JavaxUpgradeHttpRequestTest.java | 108 ++++++++++++++++++
4 files changed, 288 insertions(+), 81 deletions(-)
diff --git
a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/ServletRequestCopy.java
b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/ServletRequestCopy.java
index 860fc12ada..dc3dc6a1eb 100644
---
a/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/ServletRequestCopy.java
+++
b/wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/ServletRequestCopy.java
@@ -24,8 +24,10 @@ import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
+import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.TreeMap;
import java.util.UUID;
import jakarta.servlet.AsyncContext;
@@ -61,7 +63,7 @@ public class ServletRequestCopy implements HttpServletRequest
private final HttpSessionCopy httpSession;
private final StringBuffer requestURL;
private final Map<String, Object> attributes = new HashMap<>();
- private final Map<String, Enumeration<String>> headers = new
HashMap<>();
+ private final Map<String, List<String>> headers = new
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
private final Map<String, String[]> parameters = new HashMap<>();
private final String method;
private final String serverName;
@@ -70,7 +72,7 @@ public class ServletRequestCopy implements HttpServletRequest
private final String scheme;
private final String contentType;
private final Locale locale;
- private final Enumeration<Locale> locales;
+ private final List<Locale> locales;
private final boolean isSecure;
private final String remoteUser;
private final String remoteAddr;
@@ -100,7 +102,7 @@ public class ServletRequestCopy implements
HttpServletRequest
this.characterEncoding = request.getCharacterEncoding();
this.contentType = request.getContentType();
this.locale = request.getLocale();
- this.locales = request.getLocales();
+ this.locales = copy(request.getLocales());
this.isSecure = request.isSecure();
this.remoteUser = request.getRemoteUser();
this.remoteAddr = request.getRemoteAddr();
@@ -120,8 +122,10 @@ public class ServletRequestCopy implements
HttpServletRequest
Enumeration<String> e = request.getHeaderNames();
while (e != null && e.hasMoreElements()) {
s = e.nextElement();
- Enumeration<String> headerValues =
request.getHeaders(s);
- this.headers.put(s, headerValues);
+ if (headers.containsKey(s) == false)
+ {
+ headers.put(s, copy(request.getHeaders(s)));
+ }
}
e = request.getAttributeNames();
@@ -138,6 +142,17 @@ public class ServletRequestCopy implements
HttpServletRequest
requestId = UUID.randomUUID().toString();
}
+ private static <T> List<T> copy(Enumeration<T> values)
+ {
+ return values != null ? Collections.list(values) :
Collections.emptyList();
+ }
+
+ private List<String> getHeaderValues(String name)
+ {
+ return name != null ? headers.getOrDefault(name,
Collections.emptyList())
+ : Collections.emptyList();
+ }
+
@Override
public String getServerName() {
return serverName;
@@ -197,18 +212,14 @@ public class ServletRequestCopy implements
HttpServletRequest
@Override
public String getHeader(String name)
{
- Enumeration<String> values = headers.get(name);
- if (values != null && values.hasMoreElements())
- {
- return values.nextElement();
- }
- return null;
+ List<String> values = getHeaderValues(name);
+ return values.isEmpty() ? null : values.get(0);
}
@Override
public Enumeration<String> getHeaders(final String name)
{
- return headers.get(name);
+ return Collections.enumeration(getHeaderValues(name));
}
@Override
@@ -262,14 +273,8 @@ public class ServletRequestCopy implements
HttpServletRequest
@Override
public int getIntHeader(String name)
{
- Enumeration<String> values = headers.get(name);
- int result = -1;
- if (values.hasMoreElements())
- {
- String value = values.nextElement();
- result = Integer.parseInt(value, 10);
- }
- return result;
+ String value = getHeader(name);
+ return value != null ? Integer.parseInt(value, 10) : -1;
}
@Override
@@ -337,7 +342,7 @@ public class ServletRequestCopy implements
HttpServletRequest
@Override
public Enumeration getLocales()
{
- return locales;
+ return Collections.enumeration(locales);
}
@Override
diff --git
a/wicket-native-websocket/wicket-native-websocket-core/src/test/java/org/apache/wicket/protocol/ws/api/ServletRequestCopyTest.java
b/wicket-native-websocket/wicket-native-websocket-core/src/test/java/org/apache/wicket/protocol/ws/api/ServletRequestCopyTest.java
new file mode 100644
index 0000000000..4f2b623408
--- /dev/null
+++
b/wicket-native-websocket/wicket-native-websocket-core/src/test/java/org/apache/wicket/protocol/ws/api/ServletRequestCopyTest.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.wicket.protocol.ws.api;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.apache.wicket.protocol.http.mock.MockHttpServletRequest;
+import org.apache.wicket.protocol.http.mock.MockHttpSession;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests that {@link ServletRequestCopy} keeps the header contract of {@link
HttpServletRequest}.
+ */
+class ServletRequestCopyTest
+{
+ private MockHttpServletRequest original;
+
+ @BeforeEach
+ void before()
+ {
+ original = new MockHttpServletRequest(null, new
MockHttpSession(null), null,
+ Locale.GERMANY);
+ }
+
+ @Test
+ void headerNamesAreCaseInsensitive()
+ {
+ original.addHeader("origin", "http://example.com");
+
+ ServletRequestCopy copy = new ServletRequestCopy(original);
+
+ assertEquals("http://example.com", copy.getHeader("Origin"));
+ assertEquals(List.of("http://example.com"),
Collections.list(copy.getHeaders("ORIGIN")));
+ }
+
+ @Test
+ void headersCanBeReadRepeatedly()
+ {
+ original.addHeader("Accept-Encoding", "gzip");
+ original.addHeader("Accept-Encoding", "br");
+ original.addHeader("Max-Forwards", "10");
+
+ ServletRequestCopy copy = new ServletRequestCopy(original);
+
+ assertEquals("gzip", copy.getHeader("Accept-Encoding"));
+ assertEquals("gzip", copy.getHeader("Accept-Encoding"));
+ assertEquals(List.of("gzip", "br"),
Collections.list(copy.getHeaders("Accept-Encoding")));
+ assertEquals(List.of("gzip", "br"),
Collections.list(copy.getHeaders("Accept-Encoding")));
+ assertEquals(10, copy.getIntHeader("Max-Forwards"));
+ assertEquals(10, copy.getIntHeader("Max-Forwards"));
+ }
+
+ @Test
+ void absentHeaderHasNoValues()
+ {
+ ServletRequestCopy copy = new ServletRequestCopy(original);
+
+ assertNull(copy.getHeader("X-Absent"));
+ assertNotNull(copy.getHeaders("X-Absent"));
+ assertEquals(List.of(),
Collections.list(copy.getHeaders("X-Absent")));
+ assertEquals(-1, copy.getIntHeader("X-Absent"));
+ }
+
+ @Test
+ void headerReportedUnderSeveralCasesIsCopiedOnce()
+ {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession(true)).thenReturn(new
MockHttpSession(null));
+ when(request.getHeaderNames())
+ .thenReturn(Collections.enumeration(List.of("Origin",
"origin")));
+ when(request.getHeaders(anyString()))
+ .thenAnswer(invocation ->
Collections.enumeration(List.of("http://example.com")));
+
+ ServletRequestCopy copy = new ServletRequestCopy(request);
+
+ assertEquals(List.of("Origin"),
Collections.list(copy.getHeaderNames()));
+ assertEquals(List.of("http://example.com"),
Collections.list(copy.getHeaders("origin")));
+ }
+
+ @Test
+ void localesCanBeReadRepeatedly()
+ {
+ HttpServletRequest copy = new ServletRequestCopy(original);
+
+ List<Locale> expected = List.of(Locale.GERMANY, Locale.GERMAN);
+ assertEquals(expected, Collections.list(copy.getLocales()));
+ assertEquals(expected, Collections.list(copy.getLocales()));
+ }
+
+ @Test
+ void missingLocalesAreCopiedAsNone()
+ {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession(true)).thenReturn(new
MockHttpSession(null));
+
+ HttpServletRequest copy = new ServletRequestCopy(request);
+
+ assertEquals(List.of(), Collections.list(copy.getLocales()));
+ }
+}
diff --git
a/wicket-native-websocket/wicket-native-websocket-javax/src/main/java/org/apache/wicket/protocol/ws/javax/JavaxUpgradeHttpRequest.java
b/wicket-native-websocket/wicket-native-websocket-javax/src/main/java/org/apache/wicket/protocol/ws/javax/JavaxUpgradeHttpRequest.java
index 9b7c029ab6..35403d28d3 100644
---
a/wicket-native-websocket/wicket-native-websocket-javax/src/main/java/org/apache/wicket/protocol/ws/javax/JavaxUpgradeHttpRequest.java
+++
b/wicket-native-websocket/wicket-native-websocket-javax/src/main/java/org/apache/wicket/protocol/ws/javax/JavaxUpgradeHttpRequest.java
@@ -20,14 +20,16 @@ import java.io.BufferedReader;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.Principal;
+import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
-import java.util.Set;
+import java.util.TreeMap;
import java.util.UUID;
import jakarta.servlet.AsyncContext;
@@ -83,7 +85,7 @@ public class JavaxUpgradeHttpRequest implements
HttpServletRequest
}
this.httpSession = (HttpSession) userProperties.get(SESSION);
- this.headers = (Map<String, List<String>>)
userProperties.get(HEADERS);
+ this.headers = copyHeaders((Map<String, List<String>>)
userProperties.get(HEADERS));
this.queryString = session.getQueryString();
this.userPrincipal = session.getUserPrincipal();
Object requestURI = session.getRequestURI();
@@ -104,6 +106,26 @@ public class JavaxUpgradeHttpRequest implements
HttpServletRequest
requestId = UUID.randomUUID().toString();
}
+ private static Map<String, List<String>> copyHeaders(Map<String,
List<String>> headers)
+ {
+ Map<String, List<String>> copy = new
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ if (headers != null)
+ {
+ for (Map.Entry<String, List<String>> header :
headers.entrySet())
+ {
+ copy.computeIfAbsent(header.getKey(), name ->
new ArrayList<>())
+ .addAll(header.getValue());
+ }
+ }
+ return copy;
+ }
+
+ private List<String> getHeaderValues(String name)
+ {
+ return name != null ? headers.getOrDefault(name,
Collections.emptyList())
+ : Collections.emptyList();
+ }
+
@Override
public String getAuthType()
{
@@ -126,74 +148,20 @@ public class JavaxUpgradeHttpRequest implements
HttpServletRequest
@Override
public String getHeader(String name)
{
- String value = null;
- if (headers != null)
- {
- List<String> headerValues = headers.get(name);
- if (headerValues != null && !headerValues.isEmpty())
- {
- value = headerValues.get(0);
- }
- }
- return value;
+ List<String> values = getHeaderValues(name);
+ return values.isEmpty() ? null : values.get(0);
}
@Override
public Enumeration<String> getHeaders(String name)
{
- Enumeration<String> values = null;
- if (headers != null)
- {
- List<String> headerValues = headers.get(name);
- if (headerValues != null && !headerValues.isEmpty())
- {
- final Iterator<String> iterator =
headerValues.iterator();
- values = new Enumeration<String>()
- {
- @Override
- public boolean hasMoreElements()
- {
- return iterator.hasNext();
- }
-
- @Override
- public String nextElement()
- {
- return iterator.next();
- }
- };
- }
- }
- return values;
+ return Collections.enumeration(getHeaderValues(name));
}
@Override
public Enumeration<String> getHeaderNames()
{
- Enumeration<String> names = null;
- if (headers != null)
- {
- Set<String> headerNames = headers.keySet();
- if (!headerNames.isEmpty())
- {
- final Iterator<String> iterator =
headerNames.iterator();
- names = new Enumeration<String>()
- {
- @Override
- public boolean hasMoreElements()
- {
- return iterator.hasNext();
- }
-
- @Override
- public String nextElement()
- {
- return iterator.next();
- }
- };
- }
- }
- return names;
+ return Collections.enumeration(headers.keySet());
}
@Override
diff --git
a/wicket-native-websocket/wicket-native-websocket-javax/src/test/java/org/apache/wicket/protocol/ws/javax/JavaxUpgradeHttpRequestTest.java
b/wicket-native-websocket/wicket-native-websocket-javax/src/test/java/org/apache/wicket/protocol/ws/javax/JavaxUpgradeHttpRequestTest.java
new file mode 100644
index 0000000000..b2283f6cdd
--- /dev/null
+++
b/wicket-native-websocket/wicket-native-websocket-javax/src/test/java/org/apache/wicket/protocol/ws/javax/JavaxUpgradeHttpRequestTest.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.wicket.protocol.ws.javax;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.websocket.EndpointConfig;
+import jakarta.websocket.Session;
+
+import org.apache.wicket.protocol.http.mock.MockHttpSession;
+import org.apache.wicket.protocol.ws.api.ServletRequestCopy;
+import org.apache.wicket.protocol.ws.api.WebSocketConnectionOriginFilter;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests that {@link JavaxUpgradeHttpRequest} keeps the header contract of
+ * {@link HttpServletRequest}.
+ */
+class JavaxUpgradeHttpRequestTest
+{
+ @Test
+ void headerNamesAreCaseInsensitive()
+ {
+ JavaxUpgradeHttpRequest request = upgradeRequest(
+ Map.of("origin", List.of("http://example.com")));
+
+ assertEquals("http://example.com", request.getHeader("Origin"));
+ assertEquals(List.of("http://example.com"),
Collections.list(request.getHeaders("ORIGIN")));
+ }
+
+ @Test
+ void headerNamesDifferingOnlyInCaseAreMerged()
+ {
+ Map<String, List<String>> headers = new LinkedHashMap<>();
+ headers.put("Origin", List.of("http://example.com"));
+ headers.put("origin", List.of("http://example.org"));
+
+ JavaxUpgradeHttpRequest request = upgradeRequest(headers);
+
+ assertEquals(List.of("Origin"),
Collections.list(request.getHeaderNames()));
+ assertEquals(List.of("http://example.com",
"http://example.org"),
+ Collections.list(request.getHeaders("origin")));
+ }
+
+ @Test
+ void absentHeaderHasNoValues()
+ {
+ JavaxUpgradeHttpRequest request = upgradeRequest(null);
+
+ assertNull(request.getHeader("Origin"));
+ assertNotNull(request.getHeaders("Origin"));
+ assertEquals(List.of(),
Collections.list(request.getHeaders("Origin")));
+ assertNotNull(request.getHeaderNames());
+ assertEquals(List.of(),
Collections.list(request.getHeaderNames()));
+ }
+
+ @Test
+ void copyOfTheUpgradeRequestPassesTheOriginFilter()
+ {
+ ServletRequestCopy copy = new ServletRequestCopy(
+ upgradeRequest(Map.of("origin",
List.of("http://example.com"))));
+
+ WebSocketConnectionOriginFilter filter = new
WebSocketConnectionOriginFilter(
+ List.of("http://example.com"));
+ assertNull(filter.doFilter(copy));
+ assertNull(filter.doFilter(copy));
+ assertEquals("http://example.com", copy.getHeader("Origin"));
+ }
+
+ private static JavaxUpgradeHttpRequest upgradeRequest(Map<String,
List<String>> headers)
+ {
+ Map<String, Object> userProperties = new HashMap<>();
+ userProperties.put(JavaxUpgradeHttpRequest.SESSION, new
MockHttpSession(null));
+ if (headers != null)
+ {
+ userProperties.put(JavaxUpgradeHttpRequest.HEADERS,
headers);
+ }
+
+ Session session = mock(Session.class);
+ when(session.getUserProperties()).thenReturn(userProperties);
+ return new JavaxUpgradeHttpRequest(session,
mock(EndpointConfig.class));
+ }
+}