Merge branch 'dev' into fix/nitro-secure-asset-safety

This commit is contained in:
DuckieTM
2026-06-17 09:52:06 +02:00
committed by GitHub
10 changed files with 218 additions and 15 deletions
@@ -166,8 +166,7 @@ public final class Emulator {
Emulator.config.register("rcon.rate_limit.timeout_ms", "0"); 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.denied_permissions", "cmd_shutdown;cmd_give_rank");
Emulator.config.register("rcon.execute_command.allowed_permissions", ""); Emulator.config.register("rcon.execute_command.allowed_permissions", "");
Emulator.config.register("nitro.secure.config.max_file_bytes", "2097152"); Emulator.config.register("rcon.max_payload_bytes", "65536");
Emulator.config.register("nitro.secure.gamedata.max_file_bytes", "16777216");
registerEarningsSettings(); registerEarningsSettings();
String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId());
System.out.println(startupCard(hotelTimezoneId)); System.out.println(startupCard(hotelTimezoneId));
@@ -50,6 +50,7 @@ import java.util.Date;
@NoAuthMessage @NoAuthMessage
public class SecureLoginEvent extends MessageHandler { public class SecureLoginEvent extends MessageHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(SecureLoginEvent.class); private static final Logger LOGGER = LoggerFactory.getLogger(SecureLoginEvent.class);
private static final int MAX_SSO_TICKET_LENGTH = 128;
@Override @Override
public int getRatelimit() { public int getRatelimit() {
@@ -80,9 +81,9 @@ public class SecureLoginEvent extends MessageHandler {
return; return;
} }
if (sso.isEmpty()) { if (sso.isEmpty() || sso.length() > MAX_SSO_TICKET_LENGTH) {
Emulator.getGameServer().getGameClientManager().disposeClient(this.client); 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; return;
} }
@@ -21,6 +21,7 @@ public final class AccessTokenService {
private static final SecureRandom RNG = new SecureRandom(); private static final SecureRandom RNG = new SecureRandom();
private static final Base64.Encoder URL_ENC = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Encoder URL_ENC = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder URL_DEC = Base64.getUrlDecoder(); private static final Base64.Decoder URL_DEC = Base64.getUrlDecoder();
private static final int MAX_TOKEN_CHARS = 2048;
private static volatile String cachedSecret = null; private static volatile String cachedSecret = null;
@@ -63,7 +64,7 @@ public final class AccessTokenService {
} }
public static int verify(String token) { 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("\\."); String[] parts = token.split("\\.");
if (parts.length != 3) return 0; if (parts.length != 3) return 0;
@@ -24,7 +24,9 @@ import java.util.concurrent.ConcurrentHashMap;
public class NitroSecureApiHandler extends ChannelDuplexHandler { public class NitroSecureApiHandler extends ChannelDuplexHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(NitroSecureApiHandler.class); private static final Logger LOGGER = LoggerFactory.getLogger(NitroSecureApiHandler.class);
private static final String ENABLED_CONFIG = "nitro.secure.api.enabled"; 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 String API_PREFIX = "/api/";
private static final int DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024;
private static final AttributeKey<Deque<SecureApiContext>> SECURE_CONTEXTS = private static final AttributeKey<Deque<SecureApiContext>> SECURE_CONTEXTS =
AttributeKey.valueOf("nitroSecureApiContexts"); AttributeKey.valueOf("nitroSecureApiContexts");
private static final ConcurrentHashMap<String, Long> NONCE_CACHE = new ConcurrentHashMap<>(); private static final ConcurrentHashMap<String, Long> NONCE_CACHE = new ConcurrentHashMap<>();
@@ -81,7 +83,14 @@ public class NitroSecureApiHandler extends ChannelDuplexHandler {
return; 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); req.content().getBytes(req.content().readerIndex(), encrypted);
byte[] clear = NitroSecureAssetHandler.decrypt(sessionKey, NitroSecureAssetHandler.fromHex(new String(encrypted, StandardCharsets.UTF_8))); byte[] clear = NitroSecureAssetHandler.decrypt(sessionKey, NitroSecureAssetHandler.fromHex(new String(encrypted, StandardCharsets.UTF_8)));
clear = unwrapEnvelope(clear, req, secureContext); clear = unwrapEnvelope(clear, req, secureContext);
@@ -173,6 +182,15 @@ public class NitroSecureApiHandler extends ChannelDuplexHandler {
return com.eu.habbo.Emulator.getConfig().getBoolean(ENABLED_CONFIG, true); 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) { private static byte[] unwrapEnvelope(byte[] clear, FullHttpRequest req, SecureApiContext secureContext) {
if (!requiresReplayEnvelope(req.method())) return clear; if (!requiresReplayEnvelope(req.method())) return clear;
@@ -24,6 +24,7 @@ public final class RememberJwtService {
private static final SecureRandom RNG = new SecureRandom(); private static final SecureRandom RNG = new SecureRandom();
private static final Base64.Encoder URL_ENC = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Encoder URL_ENC = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder URL_DEC = Base64.getUrlDecoder(); private static final Base64.Decoder URL_DEC = Base64.getUrlDecoder();
private static final int MAX_TOKEN_CHARS = 2048;
private static volatile String cachedSecret = null; private static volatile String cachedSecret = null;
@@ -238,7 +239,7 @@ public final class RememberJwtService {
} }
private static ParsedJwt verifyAndParse(String jwt) throws Exception { 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("\\."); String[] parts = jwt.split("\\.");
if (parts.length != 3) throw new IllegalArgumentException("not 3 segments"); if (parts.length != 3) throw new IllegalArgumentException("not 3 segments");
@@ -12,6 +12,9 @@ import io.netty.channel.ChannelInboundHandlerAdapter;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
public class RCONServerHandler extends ChannelInboundHandlerAdapter { public class RCONServerHandler extends ChannelInboundHandlerAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(RCONServerHandler.class); 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 // Gson is thread-safe and immutable once built — share one instance instead
// of allocating a parser per RCON request. // of allocating a parser per RCON request.
private static final Gson GSON = new Gson(); private static final Gson GSON = new Gson();
private static final int DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024;
@Override @Override
public void channelRegistered(ChannelHandlerContext ctx) throws Exception { 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) { for (String s : Emulator.getRconServer().allowedAdresses) {
if (s.equalsIgnoreCase(adress)) { if (s.equalsIgnoreCase(address)) {
return; return;
} }
} }
ctx.channel().close(); ctx.channel().close();
LOGGER.warn("RCON Remote connection closed: {}. IP not allowed!", adress); LOGGER.warn("RCON Remote connection closed: {}. IP not allowed!", address);
} }
@Override @Override
@@ -43,7 +47,15 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter {
} }
try { 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); data.getBytes(0, d);
String message = new String(d, java.nio.charset.StandardCharsets.UTF_8); String message = new String(d, java.nio.charset.StandardCharsets.UTF_8);
Gson gson = GSON; Gson gson = GSON;
@@ -60,12 +72,34 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter {
e.printStackTrace(); e.printStackTrace();
} }
ChannelFuture f = ctx.channel().write(Unpooled.copiedBuffer(response.getBytes(java.nio.charset.StandardCharsets.UTF_8)), ctx.channel().voidPromise()); writeAndClose(ctx, response);
ctx.channel().flush();
ctx.flush();
f.channel().close();
} finally { } finally {
data.release(); 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();
}
} }
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -50,6 +50,30 @@ class RCONServerHandlerContractTest {
assertTrue(registerIndex < serverIndex, "RCON rate limit defaults must be registered before RCONServer is constructed"); 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 @Test
void inboundByteBufIsReleasedFromFinallyBlock() throws Exception { void inboundByteBufIsReleasedFromFinallyBlock() throws Exception {
String source = handlerSource(); String source = handlerSource();
@@ -59,4 +83,16 @@ class RCONServerHandlerContractTest {
assertTrue(finallyIndex >= 0, "RCON channelRead must release inbound ByteBufs from a finally block"); 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"); 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");
}
} }