From afacb09a6e6c0192d29b4e1ac9de234ddc67d073 Mon Sep 17 00:00:00 2001 From: Devin Date: Sat, 25 Jul 2026 11:45:16 +0000 Subject: [PATCH 1/3] Harden CORS, handshake origin and polling input validation --- .../socketio/BasicConfiguration.java | 36 +++++++++++++++ .../socketio/handler/AuthorizeHandler.java | 21 +++++++++ .../socketio/handler/EncoderHandler.java | 21 ++++++--- .../socketio/transport/PollingTransport.java | 43 ++++++++++++++++-- .../socketio/handler/EncoderHandlerTest.java | 45 +++++++++++++++++++ 5 files changed, 157 insertions(+), 9 deletions(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/BasicConfiguration.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/BasicConfiguration.java index 2266e7be..5897b4ee 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/BasicConfiguration.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/BasicConfiguration.java @@ -17,8 +17,11 @@ package com.socketio4j.socketio; import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; +import java.util.Set; import com.socketio4j.socketio.metrics.SocketIOMetrics; import com.socketio4j.socketio.nativeio.TransportType; @@ -62,6 +65,8 @@ public abstract class BasicConfiguration { protected String origin; + protected Set allowedOrigins = Collections.emptySet(); + protected boolean enableCors = true; protected boolean httpCompression = true; @@ -132,6 +137,7 @@ protected BasicConfiguration(BasicConfiguration conf) { setAddVersionHeader(conf.isAddVersionHeader()); setOrigin(conf.getOrigin()); + setAllowedOrigins(conf.getAllowedOrigins()); setEnableCors(conf.isEnableCors()); setAllowHeaders(conf.getAllowHeaders()); @@ -375,6 +381,36 @@ public String getOrigin() { return origin; } + /** + * Origins allowed to send credentialed cross-origin requests and to open + * cross-origin websocket connections. + *

+ * When empty, the request ORIGIN header is still echoed back in the + * Access-Control-Allow-Origin header, but without + * Access-Control-Allow-Credentials, and cross-origin websocket + * handshakes are not restricted. + * + * @param allowedOrigins - allowed origins + */ + public void setAllowedOrigins(Set allowedOrigins) { + if (allowedOrigins == null || allowedOrigins.isEmpty()) { + this.allowedOrigins = Collections.emptySet(); + } else { + this.allowedOrigins = Collections.unmodifiableSet(new LinkedHashSet<>(allowedOrigins)); + } + } + + public Set getAllowedOrigins() { + return allowedOrigins; + } + + public boolean isOriginAllowed(String requestOrigin) { + if (allowedOrigins.isEmpty()) { + return true; + } + return requestOrigin != null && allowedOrigins.contains(requestOrigin); + } + /** * cors dispose *

diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/AuthorizeHandler.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/AuthorizeHandler.java index b41f71a2..aac8b96f 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/AuthorizeHandler.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/AuthorizeHandler.java @@ -18,9 +18,12 @@ import java.io.IOException; import java.net.InetSocketAddress; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -74,6 +77,9 @@ public class AuthorizeHandler extends ChannelInboundHandlerAdapter implements Di private static final Logger log = LoggerFactory.getLogger(AuthorizeHandler.class); + private static final Set SENSITIVE_HEADERS = + new HashSet<>(Arrays.asList("cookie", "authorization", "proxy-authorization", "x-api-key")); + private final CancelableScheduler scheduler; private final String connectPath; @@ -161,8 +167,19 @@ private boolean authorize(ChannelHandlerContext ctx, Channel channel, String ori log.debug("Starting authorization for client: {} with origin: {}", channel.remoteAddress(), origin); } + if (!configuration.isOriginAllowed(origin)) { + log.warn("Blocked handshake from disallowed origin: {}, client: {}", origin, channel.remoteAddress()); + HttpResponse res = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.FORBIDDEN); + channel.writeAndFlush(res).addListener(ChannelFutureListener.CLOSE); + return false; + } + Map> headers = new HashMap>(req.headers().names().size()); for (String name : req.headers().names()) { + if (SENSITIVE_HEADERS.contains(name.toLowerCase(Locale.ROOT))) { + headers.put(name, Collections.singletonList("[redacted]")); + continue; + } List values = req.headers().getAll(name); headers.put(name, values); } @@ -204,6 +221,10 @@ private boolean authorize(ChannelHandlerContext ctx, Channel channel, String ori } } else { sessionId = this.generateOrGetSessionIdFromRequest(req.headers()); + if (clientsBox.get(sessionId) != null) { + log.warn("Client supplied an already used session id, generating a new one"); + sessionId = UUID.randomUUID(); + } if (log.isDebugEnabled()) { log.debug("Retrieved existing session ID: {} for client: {}", sessionId, channel.remoteAddress()); } diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/EncoderHandler.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/EncoderHandler.java index 2ad090c4..f4219fa1 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/EncoderHandler.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/EncoderHandler.java @@ -202,13 +202,22 @@ private void addOriginHeaders(String origin, HttpResponse res) { if (configuration.getOrigin() != null) { res.headers().add(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, configuration.getOrigin()); res.headers().add(HttpHeaderNames.ACCESS_CONTROL_ALLOW_CREDENTIALS, Boolean.TRUE); - } else { - if (origin != null) { - res.headers().add(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, origin); - res.headers().add(HttpHeaderNames.ACCESS_CONTROL_ALLOW_CREDENTIALS, Boolean.TRUE); - } else { - res.headers().add(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + res.headers().add(HttpHeaderNames.VARY, HttpHeaderNames.ORIGIN); + } else if (origin == null) { + res.headers().add(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + } else if (!configuration.getAllowedOrigins().isEmpty()) { + if (!configuration.isOriginAllowed(origin)) { + res.headers().add(HttpHeaderNames.VARY, HttpHeaderNames.ORIGIN); + return; } + res.headers().add(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, origin); + res.headers().add(HttpHeaderNames.ACCESS_CONTROL_ALLOW_CREDENTIALS, Boolean.TRUE); + res.headers().add(HttpHeaderNames.VARY, HttpHeaderNames.ORIGIN); + } else { + // credentials are not allowed for arbitrary reflected origins, + // configure allowedOrigins or origin to enable them + res.headers().add(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, origin); + res.headers().add(HttpHeaderNames.VARY, HttpHeaderNames.ORIGIN); } if (configuration.getAllowHeaders() != null) { res.headers().add(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS, configuration.getAllowHeaders()); diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/transport/PollingTransport.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/transport/PollingTransport.java index 1e34aeae..a024f385 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/transport/PollingTransport.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/transport/PollingTransport.java @@ -89,7 +89,13 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception ctx.channel().attr(EncoderHandler.USER_AGENT).set(userAgent); if (j != null && j.get(0) != null) { - Integer index = Integer.valueOf(j.get(0)); + Integer index = parseInt(j.get(0)); + if (index == null) { + log.debug("Malformed jsonp index: {}", j.get(0)); + sendBadRequest(ctx); + req.release(); + return; + } ctx.channel().attr(EncoderHandler.JSONP_INDEX).set(index); } if (b64 != null && b64.get(0) != null) { @@ -99,13 +105,26 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception } else if ("false".equals(flag)) { flag = "0"; } - Integer enable = Integer.valueOf(flag); + Integer enable = parseInt(flag); + if (enable == null) { + log.debug("Malformed b64 flag: {}", b64.get(0)); + sendBadRequest(ctx); + req.release(); + return; + } ctx.channel().attr(EncoderHandler.B64).set(enable == 1); } try { if (sid != null && sid.get(0) != null) { - final UUID sessionId = UUID.fromString(sid.get(0)); + final UUID sessionId; + try { + sessionId = UUID.fromString(sid.get(0)); + } catch (IllegalArgumentException e) { + log.debug("Malformed sid: {}", sid.get(0)); + sendBadRequest(ctx); + return; + } handleMessage(req, sessionId, queryDecoder, ctx); } else { // first connection @@ -128,6 +147,11 @@ private void handleMessage(FullHttpRequest req, UUID sessionId, QueryStringDecod String origin = req.headers().get(HttpHeaderNames.ORIGIN); if (queryDecoder.parameters().containsKey("disconnect")) { ClientHead client = clientsBox.get(sessionId); + if (client == null) { + log.debug("{} is not registered. Closing connection", sessionId); + sendError(ctx); + return; + } client.onChannelDisconnect(); ctx.channel().writeAndFlush(new XHRPostMessage(origin, sessionId)); } else if (HttpMethod.POST.equals(req.method())) { @@ -203,6 +227,19 @@ protected void onGet(UUID sessionId, ChannelHandlerContext ctx, String origin) { authorizeHandler.connect(client); } + private static Integer parseInt(String value) { + try { + return Integer.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } + + private void sendBadRequest(ChannelHandlerContext ctx) { + HttpResponse res = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.BAD_REQUEST); + ctx.channel().writeAndFlush(res).addListener(ChannelFutureListener.CLOSE); + } + private void sendError(ChannelHandlerContext ctx) { HttpResponse res = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.INTERNAL_SERVER_ERROR); ctx.channel().writeAndFlush(res).addListener(ChannelFutureListener.CLOSE); diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/handler/EncoderHandlerTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/handler/EncoderHandlerTest.java index b967d412..acfe7540 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/handler/EncoderHandlerTest.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/handler/EncoderHandlerTest.java @@ -17,6 +17,7 @@ package com.socketio4j.socketio.handler; import java.io.IOException; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -159,9 +160,53 @@ void shouldHandleXHROptionsMessage() throws Exception { assertThat(response.headers().get("Connection")).isEqualTo("keep-alive"); assertThat(response.headers().get("Access-Control-Allow-Headers")).isEqualTo("content-type"); assertThat(response.headers().get("Access-Control-Allow-Origin")).isEqualTo(TEST_ORIGIN); + assertThat(response.headers().get("Access-Control-Allow-Credentials")).isNull(); + assertThat(response.headers().get("Vary")).isEqualTo("origin"); + } + + @Test + @DisplayName("Should allow credentials only for allow-listed origins") + void shouldAllowCredentialsForAllowedOrigin() throws Exception { + // Given + configuration.setAllowedOrigins(Collections.singleton(TEST_ORIGIN)); + encoderHandler = new EncoderHandler(configuration, mockEncoder); + channel = new EmbeddedChannel(encoderHandler); + + XHROptionsMessage message = new XHROptionsMessage(TEST_ORIGIN, sessionId); + channel.attr(EncoderHandler.ORIGIN).set(TEST_ORIGIN); + ChannelPromise promise = channel.newPromise(); + + // When + encoderHandler.write(channel.pipeline().context(encoderHandler), message, promise); + + // Then + HttpResponse response = channel.readOutbound(); + assertThat(response.headers().get("Access-Control-Allow-Origin")).isEqualTo(TEST_ORIGIN); assertThat(response.headers().get("Access-Control-Allow-Credentials")).isEqualTo("true"); } + @Test + @DisplayName("Should not emit CORS headers for origins outside the allow list") + void shouldRejectOriginOutsideAllowList() throws Exception { + // Given + configuration.setAllowedOrigins(Collections.singleton(TEST_ORIGIN)); + encoderHandler = new EncoderHandler(configuration, mockEncoder); + channel = new EmbeddedChannel(encoderHandler); + + String evilOrigin = "http://evil.example.com"; + XHROptionsMessage message = new XHROptionsMessage(evilOrigin, sessionId); + channel.attr(EncoderHandler.ORIGIN).set(evilOrigin); + ChannelPromise promise = channel.newPromise(); + + // When + encoderHandler.write(channel.pipeline().context(encoderHandler), message, promise); + + // Then + HttpResponse response = channel.readOutbound(); + assertThat(response.headers().get("Access-Control-Allow-Origin")).isNull(); + assertThat(response.headers().get("Access-Control-Allow-Credentials")).isNull(); + } + @Test @DisplayName("Should handle XHR post message correctly") void shouldHandleXHRPostMessage() throws Exception { From ebb64068f0e4586deee9582aad3a0155494ee242 Mon Sep 17 00:00:00 2001 From: Devin Date: Sat, 25 Jul 2026 12:14:40 +0000 Subject: [PATCH 2/3] Support wildcard patterns in allowedOrigins --- .../socketio/BasicConfiguration.java | 49 ++++++++++- .../socketio/AllowedOriginsTest.java | 88 +++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 netty-socketio-core/src/test/java/com/socketio4j/socketio/AllowedOriginsTest.java diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/BasicConfiguration.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/BasicConfiguration.java index 5897b4ee..bf938ab1 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/BasicConfiguration.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/BasicConfiguration.java @@ -16,12 +16,14 @@ */ package com.socketio4j.socketio; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.regex.Pattern; import com.socketio4j.socketio.metrics.SocketIOMetrics; import com.socketio4j.socketio.nativeio.TransportType; @@ -67,6 +69,8 @@ public abstract class BasicConfiguration { protected Set allowedOrigins = Collections.emptySet(); + private List allowedOriginPatterns = Collections.emptyList(); + protected boolean enableCors = true; protected boolean httpCompression = true; @@ -389,15 +393,43 @@ public String getOrigin() { * Access-Control-Allow-Origin header, but without * Access-Control-Allow-Credentials, and cross-origin websocket * handshakes are not restricted. + *

+ * Entries are matched against the full origin and may contain * + * as a wildcard for any part of the host or port, for example + * https://*.example.com or http://localhost:*. * * @param allowedOrigins - allowed origins */ public void setAllowedOrigins(Set allowedOrigins) { if (allowedOrigins == null || allowedOrigins.isEmpty()) { this.allowedOrigins = Collections.emptySet(); - } else { - this.allowedOrigins = Collections.unmodifiableSet(new LinkedHashSet<>(allowedOrigins)); + this.allowedOriginPatterns = Collections.emptyList(); + return; + } + + this.allowedOrigins = Collections.unmodifiableSet(new LinkedHashSet<>(allowedOrigins)); + + List patterns = new ArrayList<>(); + for (String allowedOrigin : this.allowedOrigins) { + if (allowedOrigin != null && allowedOrigin.indexOf('*') >= 0) { + patterns.add(compileOriginPattern(allowedOrigin)); + } } + this.allowedOriginPatterns = Collections.unmodifiableList(patterns); + } + + private static Pattern compileOriginPattern(String allowedOrigin) { + StringBuilder regex = new StringBuilder(); + int start = 0; + int wildcard; + while ((wildcard = allowedOrigin.indexOf('*', start)) >= 0) { + regex.append(Pattern.quote(allowedOrigin.substring(start, wildcard))); + // a wildcard never spans the scheme separator or a path + regex.append("[^/]*"); + start = wildcard + 1; + } + regex.append(Pattern.quote(allowedOrigin.substring(start))); + return Pattern.compile(regex.toString()); } public Set getAllowedOrigins() { @@ -408,7 +440,18 @@ public boolean isOriginAllowed(String requestOrigin) { if (allowedOrigins.isEmpty()) { return true; } - return requestOrigin != null && allowedOrigins.contains(requestOrigin); + if (requestOrigin == null) { + return false; + } + if (allowedOrigins.contains(requestOrigin)) { + return true; + } + for (Pattern pattern : allowedOriginPatterns) { + if (pattern.matcher(requestOrigin).matches()) { + return true; + } + } + return false; } /** diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/AllowedOriginsTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/AllowedOriginsTest.java new file mode 100644 index 00000000..3111683e --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/AllowedOriginsTest.java @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * 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 + * + * 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 com.socketio4j.socketio; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Allowed origins matching") +class AllowedOriginsTest { + + private final Configuration configuration = new Configuration(); + + @Test + @DisplayName("Should allow any origin when no allow list is configured") + void shouldAllowAnyOriginByDefault() { + assertThat(configuration.isOriginAllowed("http://evil.example")).isTrue(); + assertThat(configuration.isOriginAllowed(null)).isTrue(); + } + + @Test + @DisplayName("Should match exact origins only") + void shouldMatchExactOrigins() { + configuration.setAllowedOrigins(Collections.singleton("https://app.example.com")); + + assertThat(configuration.isOriginAllowed("https://app.example.com")).isTrue(); + assertThat(configuration.isOriginAllowed("http://app.example.com")).isFalse(); + assertThat(configuration.isOriginAllowed("https://app.example.com:8080")).isFalse(); + assertThat(configuration.isOriginAllowed("https://evil.example")).isFalse(); + assertThat(configuration.isOriginAllowed(null)).isFalse(); + } + + @Test + @DisplayName("Should match wildcard subdomain patterns") + void shouldMatchWildcardSubdomains() { + configuration.setAllowedOrigins(new HashSet<>(Arrays.asList( + "https://*.example.com", "https://*.example2.com"))); + + assertThat(configuration.isOriginAllowed("https://app.example.com")).isTrue(); + assertThat(configuration.isOriginAllowed("https://a.b.example.com")).isTrue(); + assertThat(configuration.isOriginAllowed("https://app.example2.com")).isTrue(); + + assertThat(configuration.isOriginAllowed("https://example.com")).isFalse(); + assertThat(configuration.isOriginAllowed("http://app.example.com")).isFalse(); + assertThat(configuration.isOriginAllowed("https://example.com.evil.test")).isFalse(); + assertThat(configuration.isOriginAllowed("https://app.example.com.evil.test")).isFalse(); + } + + @Test + @DisplayName("Should match wildcard ports") + void shouldMatchWildcardPorts() { + configuration.setAllowedOrigins(Collections.singleton("http://localhost:*")); + + assertThat(configuration.isOriginAllowed("http://localhost:3000")).isTrue(); + assertThat(configuration.isOriginAllowed("http://localhost:8080")).isTrue(); + assertThat(configuration.isOriginAllowed("http://localhost")).isFalse(); + assertThat(configuration.isOriginAllowed("http://evil.test:3000")).isFalse(); + } + + @Test + @DisplayName("Should reset patterns when the allow list is cleared") + void shouldResetPatterns() { + configuration.setAllowedOrigins(Collections.singleton("https://*.example.com")); + configuration.setAllowedOrigins(Collections.emptySet()); + + assertThat(configuration.getAllowedOrigins()).isEmpty(); + assertThat(configuration.isOriginAllowed("https://evil.example")).isTrue(); + } +} From 4ce7a3cb9a8c7699f2b15d1898ec582055548b75 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:55:35 +0000 Subject: [PATCH 3/3] Do not reject handshakes without an Origin header against the allow list --- .../java/com/socketio4j/socketio/handler/AuthorizeHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/AuthorizeHandler.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/AuthorizeHandler.java index aac8b96f..3f4ac1d0 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/AuthorizeHandler.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/handler/AuthorizeHandler.java @@ -167,7 +167,7 @@ private boolean authorize(ChannelHandlerContext ctx, Channel channel, String ori log.debug("Starting authorization for client: {} with origin: {}", channel.remoteAddress(), origin); } - if (!configuration.isOriginAllowed(origin)) { + if (origin != null && !configuration.isOriginAllowed(origin)) { log.warn("Blocked handshake from disallowed origin: {}, client: {}", origin, channel.remoteAddress()); HttpResponse res = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.FORBIDDEN); channel.writeAndFlush(res).addListener(ChannelFutureListener.CLOSE);