Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +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;
Expand Down Expand Up @@ -62,6 +67,10 @@ public abstract class BasicConfiguration {

protected String origin;

protected Set<String> allowedOrigins = Collections.emptySet();

private List<Pattern> allowedOriginPatterns = Collections.emptyList();

protected boolean enableCors = true;

protected boolean httpCompression = true;
Expand Down Expand Up @@ -132,6 +141,7 @@ protected BasicConfiguration(BasicConfiguration conf) {

setAddVersionHeader(conf.isAddVersionHeader());
setOrigin(conf.getOrigin());
setAllowedOrigins(conf.getAllowedOrigins());
setEnableCors(conf.isEnableCors());
setAllowHeaders(conf.getAllowHeaders());

Expand Down Expand Up @@ -375,6 +385,75 @@ public String getOrigin() {
return origin;
}

/**
* Origins allowed to send credentialed cross-origin requests and to open
* cross-origin websocket connections.
* <p>
* When empty, the request <b>ORIGIN</b> header is still echoed back in the
* <b>Access-Control-Allow-Origin</b> header, but without
* <b>Access-Control-Allow-Credentials</b>, and cross-origin websocket
* handshakes are not restricted.
* <p>
* Entries are matched against the full origin and may contain <code>*</code>
* as a wildcard for any part of the host or port, for example
* <code>https://*.example.com</code> or <code>http://localhost:*</code>.
*
* @param allowedOrigins - allowed origins
*/
public void setAllowedOrigins(Set<String> allowedOrigins) {
if (allowedOrigins == null || allowedOrigins.isEmpty()) {
this.allowedOrigins = Collections.emptySet();
this.allowedOriginPatterns = Collections.emptyList();
return;
}

this.allowedOrigins = Collections.unmodifiableSet(new LinkedHashSet<>(allowedOrigins));

List<Pattern> 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<String> getAllowedOrigins() {
return allowedOrigins;
}

public boolean isOriginAllowed(String requestOrigin) {
if (allowedOrigins.isEmpty()) {
return true;
}
if (requestOrigin == null) {
return false;
}
if (allowedOrigins.contains(requestOrigin)) {
return true;
}
for (Pattern pattern : allowedOriginPatterns) {
if (pattern.matcher(requestOrigin).matches()) {
return true;
}
}
return false;
}

/**
* cors dispose
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -74,6 +77,9 @@ public class AuthorizeHandler extends ChannelInboundHandlerAdapter implements Di

private static final Logger log = LoggerFactory.getLogger(AuthorizeHandler.class);

private static final Set<String> SENSITIVE_HEADERS =
new HashSet<>(Arrays.asList("cookie", "authorization", "proxy-authorization", "x-api-key"));

private final CancelableScheduler scheduler;

private final String connectPath;
Expand Down Expand Up @@ -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 (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);
return false;
}

Map<String, List<String>> headers = new HashMap<String, List<String>>(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<String> values = req.headers().getAll(name);
headers.put(name, values);
}
Expand Down Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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())) {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading