diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index a9f70bd7..ea27325a 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -166,8 +166,7 @@ public final class Emulator { Emulator.config.register("rcon.rate_limit.timeout_ms", "0"); Emulator.config.register("rcon.execute_command.denied_permissions", "cmd_shutdown;cmd_give_rank"); Emulator.config.register("rcon.execute_command.allowed_permissions", ""); - Emulator.config.register("nitro.secure.config.max_file_bytes", "2097152"); - Emulator.config.register("nitro.secure.gamedata.max_file_bytes", "16777216"); + Emulator.config.register("rcon.max_payload_bytes", "65536"); registerEarningsSettings(); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); System.out.println(startupCard(hotelTimezoneId)); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java index f9704bec..945e7b73 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java @@ -50,6 +50,7 @@ import java.util.Date; @NoAuthMessage public class SecureLoginEvent extends MessageHandler { private static final Logger LOGGER = LoggerFactory.getLogger(SecureLoginEvent.class); + private static final int MAX_SSO_TICKET_LENGTH = 128; @Override public int getRatelimit() { @@ -80,9 +81,9 @@ public class SecureLoginEvent extends MessageHandler { return; } - if (sso.isEmpty()) { + if (sso.isEmpty() || sso.length() > MAX_SSO_TICKET_LENGTH) { Emulator.getGameServer().getGameClientManager().disposeClient(this.client); - LOGGER.debug("Client is trying to connect without SSO ticket! Closed connection..."); + LOGGER.debug("Client is trying to connect with missing or invalid SSO ticket! Closed connection..."); return; } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AccessTokenService.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AccessTokenService.java index bb15c8b1..36135e08 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AccessTokenService.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AccessTokenService.java @@ -21,6 +21,7 @@ public final class AccessTokenService { private static final SecureRandom RNG = new SecureRandom(); private static final Base64.Encoder URL_ENC = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Decoder URL_DEC = Base64.getUrlDecoder(); + private static final int MAX_TOKEN_CHARS = 2048; private static volatile String cachedSecret = null; @@ -63,7 +64,7 @@ public final class AccessTokenService { } public static int verify(String token) { - if (token == null || token.isEmpty()) return 0; + if (token == null || token.isEmpty() || token.length() > MAX_TOKEN_CHARS) return 0; String[] parts = token.split("\\."); if (parts.length != 3) return 0; diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java index 45528857..afe09e5c 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java @@ -24,7 +24,9 @@ import java.util.concurrent.ConcurrentHashMap; public class NitroSecureApiHandler extends ChannelDuplexHandler { private static final Logger LOGGER = LoggerFactory.getLogger(NitroSecureApiHandler.class); private static final String ENABLED_CONFIG = "nitro.secure.api.enabled"; + private static final String MAX_PAYLOAD_CONFIG = "nitro.secure.api.max_payload_bytes"; private static final String API_PREFIX = "/api/"; + private static final int DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024; private static final AttributeKey> SECURE_CONTEXTS = AttributeKey.valueOf("nitroSecureApiContexts"); private static final ConcurrentHashMap NONCE_CACHE = new ConcurrentHashMap<>(); @@ -81,7 +83,14 @@ public class NitroSecureApiHandler extends ChannelDuplexHandler { return; } - byte[] encrypted = new byte[req.content().readableBytes()]; + int readableBytes = req.content().readableBytes(); + int maxPayloadBytes = maxPayloadBytes(); + if (readableBytes > maxPayloadBytes) { + sendText(ctx, req, HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE, "Secure payload too large."); + return; + } + + byte[] encrypted = new byte[readableBytes]; req.content().getBytes(req.content().readerIndex(), encrypted); byte[] clear = NitroSecureAssetHandler.decrypt(sessionKey, NitroSecureAssetHandler.fromHex(new String(encrypted, StandardCharsets.UTF_8))); clear = unwrapEnvelope(clear, req, secureContext); @@ -173,6 +182,15 @@ public class NitroSecureApiHandler extends ChannelDuplexHandler { return com.eu.habbo.Emulator.getConfig().getBoolean(ENABLED_CONFIG, true); } + static int maxPayloadBytes() { + if (com.eu.habbo.Emulator.getConfig() == null) { + return DEFAULT_MAX_PAYLOAD_BYTES; + } + + int configured = com.eu.habbo.Emulator.getConfig().getInt(MAX_PAYLOAD_CONFIG, DEFAULT_MAX_PAYLOAD_BYTES); + return configured > 0 ? configured : DEFAULT_MAX_PAYLOAD_BYTES; + } + private static byte[] unwrapEnvelope(byte[] clear, FullHttpRequest req, SecureApiContext secureContext) { if (!requiresReplayEnvelope(req.method())) return clear; diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java index 93763902..3a1747d4 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java @@ -24,6 +24,7 @@ public final class RememberJwtService { private static final SecureRandom RNG = new SecureRandom(); private static final Base64.Encoder URL_ENC = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Decoder URL_DEC = Base64.getUrlDecoder(); + private static final int MAX_TOKEN_CHARS = 2048; private static volatile String cachedSecret = null; @@ -238,7 +239,7 @@ public final class RememberJwtService { } private static ParsedJwt verifyAndParse(String jwt) throws Exception { - if (jwt == null || jwt.isEmpty()) throw new IllegalArgumentException("empty"); + if (jwt == null || jwt.isEmpty() || jwt.length() > MAX_TOKEN_CHARS) throw new IllegalArgumentException("empty"); String[] parts = jwt.split("\\."); if (parts.length != 3) throw new IllegalArgumentException("not 3 segments"); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java index 014ba0ec..7c81c050 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java @@ -12,6 +12,9 @@ import io.netty.channel.ChannelInboundHandlerAdapter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.InetSocketAddress; +import java.net.SocketAddress; + public class RCONServerHandler extends ChannelInboundHandlerAdapter { private static final Logger LOGGER = LoggerFactory.getLogger(RCONServerHandler.class); @@ -19,20 +22,21 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter { // Gson is thread-safe and immutable once built — share one instance instead // of allocating a parser per RCON request. private static final Gson GSON = new Gson(); + private static final int DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024; @Override public void channelRegistered(ChannelHandlerContext ctx) throws Exception { - String adress = ctx.channel().remoteAddress().toString().split(":")[0].replace("/", ""); + String address = remoteAddress(ctx); for (String s : Emulator.getRconServer().allowedAdresses) { - if (s.equalsIgnoreCase(adress)) { + if (s.equalsIgnoreCase(address)) { return; } } ctx.channel().close(); - LOGGER.warn("RCON Remote connection closed: {}. IP not allowed!", adress); + LOGGER.warn("RCON Remote connection closed: {}. IP not allowed!", address); } @Override @@ -43,7 +47,15 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter { } try { - byte[] d = new byte[data.readableBytes()]; + int readableBytes = data.readableBytes(); + int maxPayloadBytes = maxPayloadBytes(); + if (readableBytes > maxPayloadBytes) { + writeAndClose(ctx, "PAYLOAD_TOO_LARGE"); + LOGGER.warn("Rejected oversized RCON payload: {} bytes (max {})", readableBytes, maxPayloadBytes); + return; + } + + byte[] d = new byte[readableBytes]; data.getBytes(0, d); String message = new String(d, java.nio.charset.StandardCharsets.UTF_8); Gson gson = GSON; @@ -60,12 +72,34 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter { e.printStackTrace(); } - ChannelFuture f = ctx.channel().write(Unpooled.copiedBuffer(response.getBytes(java.nio.charset.StandardCharsets.UTF_8)), ctx.channel().voidPromise()); - ctx.channel().flush(); - ctx.flush(); - f.channel().close(); + writeAndClose(ctx, response); } finally { data.release(); } } + + static int maxPayloadBytes() { + if (Emulator.getConfig() == null) { + return DEFAULT_MAX_PAYLOAD_BYTES; + } + + int configured = Emulator.getConfig().getInt("rcon.max_payload_bytes", DEFAULT_MAX_PAYLOAD_BYTES); + return configured > 0 ? configured : DEFAULT_MAX_PAYLOAD_BYTES; + } + + static String remoteAddress(ChannelHandlerContext ctx) { + SocketAddress socketAddress = ctx.channel().remoteAddress(); + if (socketAddress instanceof InetSocketAddress inetSocketAddress && inetSocketAddress.getAddress() != null) { + return inetSocketAddress.getAddress().getHostAddress(); + } + + return socketAddress == null ? "" : socketAddress.toString().replace("/", ""); + } + + private static void writeAndClose(ChannelHandlerContext ctx, String response) { + ChannelFuture f = ctx.channel().write(Unpooled.copiedBuffer(response.getBytes(java.nio.charset.StandardCharsets.UTF_8)), ctx.channel().voidPromise()); + ctx.channel().flush(); + ctx.flush(); + f.channel().close(); + } } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/handshake/SecureLoginGuardContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/handshake/SecureLoginGuardContractTest.java new file mode 100644 index 00000000..149a5f86 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/handshake/SecureLoginGuardContractTest.java @@ -0,0 +1,28 @@ +package com.eu.habbo.messages.incoming.handshake; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SecureLoginGuardContractTest { + + private static String source() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java")); + } + + @Test + void websocketSsoTicketIsLengthBoundedBeforeDatabaseLookup() throws Exception { + String source = source(); + + int maxConstant = source.indexOf("MAX_SSO_TICKET_LENGTH = 128"); + int guard = source.indexOf("sso.isEmpty() || sso.length() > MAX_SSO_TICKET_LENGTH"); + int lookup = source.indexOf("SELECT id FROM users WHERE auth_ticket = ?"); + + assertTrue(maxConstant > -1, "Secure login should define the same SSO length cap used by HTTP auth"); + assertTrue(guard > -1, "Secure login must reject missing or oversized SSO tickets"); + assertTrue(guard < lookup, "SSO length must be validated before database lookup"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/networking/gameserver/auth/AuthTokenGuardContractTest.java b/Emulator/src/test/java/com/eu/habbo/networking/gameserver/auth/AuthTokenGuardContractTest.java new file mode 100644 index 00000000..1aa5a0ba --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/networking/gameserver/auth/AuthTokenGuardContractTest.java @@ -0,0 +1,41 @@ +package com.eu.habbo.networking.gameserver.auth; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AuthTokenGuardContractTest { + + @Test + void accessTokenRejectsOversizedTokensBeforeSplitAndDecode() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/networking/gameserver/auth/AccessTokenService.java")); + + int maxConstant = source.indexOf("MAX_TOKEN_CHARS = 2048"); + int lengthGuard = source.indexOf("token.length() > MAX_TOKEN_CHARS"); + int split = source.indexOf("token.split"); + int decode = source.indexOf("URL_DEC.decode"); + + assertTrue(maxConstant > -1, "Access tokens should have a bounded serialized size"); + assertTrue(lengthGuard > -1, "Access token verification must reject oversized tokens"); + assertTrue(lengthGuard < split, "Access token length guard must run before split"); + assertTrue(lengthGuard < decode, "Access token length guard must run before Base64 decode"); + } + + @Test + void rememberTokenRejectsOversizedTokensBeforeSplitAndDecode() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java")); + + int maxConstant = source.indexOf("MAX_TOKEN_CHARS = 2048"); + int lengthGuard = source.indexOf("jwt.length() > MAX_TOKEN_CHARS"); + int split = source.indexOf("jwt.split"); + int decode = source.indexOf("URL_DEC.decode"); + + assertTrue(maxConstant > -1, "Remember tokens should have a bounded serialized size"); + assertTrue(lengthGuard > -1, "Remember token verification must reject oversized tokens"); + assertTrue(lengthGuard < split, "Remember token length guard must run before split"); + assertTrue(lengthGuard < decode, "Remember token length guard must run before Base64 decode"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandlerContractTest.java b/Emulator/src/test/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandlerContractTest.java new file mode 100644 index 00000000..0e708197 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandlerContractTest.java @@ -0,0 +1,44 @@ +package com.eu.habbo.networking.gameserver.auth; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NitroSecureApiHandlerContractTest { + private static String handlerSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java")); + } + + private static String emulatorSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/Emulator.java")); + } + + @Test + void encryptedApiPayloadSizeIsBoundedBeforeCopyAndDecrypt() throws Exception { + String handler = handlerSource(); + String emulator = emulatorSource(); + + int readableBytes = handler.indexOf("int readableBytes = req.content().readableBytes()"); + int maxPayload = handler.indexOf("int maxPayloadBytes = maxPayloadBytes()", readableBytes); + int oversizedGuard = handler.indexOf("readableBytes > maxPayloadBytes", maxPayload); + int byteArray = handler.indexOf("new byte[readableBytes]", readableBytes); + int decrypt = handler.indexOf("NitroSecureAssetHandler.decrypt", byteArray); + + assertTrue(handler.contains("DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024"), + "Secure API handler should have a conservative default payload cap"); + assertTrue(handler.contains("nitro.secure.api.max_payload_bytes"), + "Secure API max payload should be configurable"); + assertTrue(readableBytes > -1, "Secure API handler must read content size before allocation"); + assertTrue(maxPayload > readableBytes, "Secure API handler must resolve max payload before allocation"); + assertTrue(oversizedGuard > maxPayload, "Secure API handler must reject oversized encrypted payloads"); + assertTrue(oversizedGuard < byteArray, "Oversized encrypted payloads must be rejected before byte array allocation"); + assertTrue(byteArray < decrypt, "Secure API payload must be bounded before decrypting"); + assertTrue(handler.contains("REQUEST_ENTITY_TOO_LARGE"), + "Secure API callers need a deterministic status for oversized encrypted payloads"); + assertTrue(emulator.contains("register(\"nitro.secure.api.max_payload_bytes\", \"65536\")"), + "Secure API max payload default must be registered before startup"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/networking/rconserver/RCONServerHandlerContractTest.java b/Emulator/src/test/java/com/eu/habbo/networking/rconserver/RCONServerHandlerContractTest.java index 893d556b..54e6586a 100644 --- a/Emulator/src/test/java/com/eu/habbo/networking/rconserver/RCONServerHandlerContractTest.java +++ b/Emulator/src/test/java/com/eu/habbo/networking/rconserver/RCONServerHandlerContractTest.java @@ -50,6 +50,30 @@ class RCONServerHandlerContractTest { assertTrue(registerIndex < serverIndex, "RCON rate limit defaults must be registered before RCONServer is constructed"); } + @Test + void rconPayloadSizeIsBoundedBeforeBufferCopy() throws Exception { + String handler = handlerSource(); + String emulator = emulatorSource(); + + int readableBytes = handler.indexOf("int readableBytes = data.readableBytes()"); + int maxPayload = handler.indexOf("int maxPayloadBytes = maxPayloadBytes()", readableBytes); + int oversizedGuard = handler.indexOf("readableBytes > maxPayloadBytes", maxPayload); + int byteArray = handler.indexOf("new byte[readableBytes]", readableBytes); + + assertTrue(handler.contains("DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024"), + "RCON handler should have a conservative default payload cap"); + assertTrue(handler.contains("rcon.max_payload_bytes"), + "RCON max payload should be configurable"); + assertTrue(readableBytes > -1, "RCON handler must read ByteBuf size before allocation"); + assertTrue(maxPayload > readableBytes, "RCON handler must resolve max payload before allocation"); + assertTrue(oversizedGuard > maxPayload, "RCON handler must reject oversized payloads"); + assertTrue(oversizedGuard < byteArray, "Oversized RCON payloads must be rejected before byte array allocation"); + assertTrue(handler.contains("PAYLOAD_TOO_LARGE"), + "RCON callers need a deterministic response for oversized payloads"); + assertTrue(emulator.contains("register(\"rcon.max_payload_bytes\", \"65536\")"), + "RCON max payload default must be registered before startup"); + } + @Test void inboundByteBufIsReleasedFromFinallyBlock() throws Exception { String source = handlerSource(); @@ -59,4 +83,16 @@ class RCONServerHandlerContractTest { assertTrue(finallyIndex >= 0, "RCON channelRead must release inbound ByteBufs from a finally block"); assertTrue(releaseIndex > finallyIndex, "RCON channelRead must release the inbound ByteBuf after finally starts"); } + + @Test + void rconWhitelistUsesSocketAddressInsteadOfStringSplitting() throws Exception { + String source = handlerSource(); + + assertTrue(source.contains("InetSocketAddress"), + "RCON whitelist should resolve socket addresses instead of parsing remoteAddress.toString()"); + assertTrue(source.contains("getHostAddress()"), + "RCON whitelist should compare the resolved host address"); + assertTrue(!source.contains(".toString().split(\":\")"), + "RCON whitelist must not split host:port strings because that breaks IPv6 addresses"); + } }