From 39d21daeff4dafe3ff4191a55d0a897051b95f3d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 17:55:53 +0200 Subject: [PATCH 01/19] chore(deps): add resilience and validation libraries --- Emulator/pom.xml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index b281492a..e3c3c5ca 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -117,6 +117,37 @@ compile + + + com.github.ben-manes.caffeine + caffeine + 3.2.4 + compile + + + + + io.github.resilience4j + resilience4j-ratelimiter + 2.4.0 + compile + + + + io.github.resilience4j + resilience4j-circuitbreaker + 2.4.0 + compile + + + + + org.hibernate.validator + hibernate-validator + 9.1.0.Final + compile + + org.apache.commons From 994d539caf592b32713b450d8136444ce221fada Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 18:27:11 +0200 Subject: [PATCH 02/19] fix(rcon): rate limit remote command bursts --- .../src/main/java/com/eu/habbo/Emulator.java | 4 ++ .../networking/rconserver/RCONServer.java | 36 ++++++++++++++ .../RCONServerHandlerContractTest.java | 48 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 Emulator/src/test/java/com/eu/habbo/networking/rconserver/RCONServerHandlerContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index de1a4e86..86ceca74 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -153,6 +153,10 @@ public final class Emulator { Emulator.config.register("camera.price.points.type", "5"); Emulator.config.register("camera.render.delay", "5"); Emulator.config.register("hotel.timezone", java.time.ZoneId.systemDefault().getId()); + Emulator.config.register("rcon.rate_limit.enabled", "1"); + Emulator.config.register("rcon.rate_limit.limit_for_period", "60"); + Emulator.config.register("rcon.rate_limit.refresh_period_ms", "1000"); + Emulator.config.register("rcon.rate_limit.timeout_ms", "0"); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); System.out.println(); LOGGER.info("https://github.com/duckietm/Arcturus-Morningstar-Extended, "); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java index 9eaeeef4..95d14d99 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java @@ -3,15 +3,21 @@ package com.eu.habbo.networking.rconserver; import com.eu.habbo.Emulator; import com.eu.habbo.messages.rcon.*; import com.eu.habbo.networking.Server; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import gnu.trove.map.hash.THashMap; +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterConfig; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInitializer; import io.netty.channel.socket.SocketChannel; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.SocketAddress; +import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -23,6 +29,8 @@ public class RCONServer extends Server { private final THashMap> messages; private final GsonBuilder gsonBuilder; + private final boolean rateLimitEnabled; + private final LoadingCache rateLimiters; List allowedAdresses = new ArrayList<>(); public RCONServer(String host, int port) throws Exception { @@ -32,6 +40,16 @@ public class RCONServer extends Server { this.gsonBuilder = new GsonBuilder(); this.gsonBuilder.registerTypeAdapter(RCONMessage.class, new RCONMessage.RCONMessageSerializer()); + this.rateLimitEnabled = Emulator.getConfig().getBoolean("rcon.rate_limit.enabled", true); + RateLimiterConfig rateLimiterConfig = RateLimiterConfig.custom() + .limitForPeriod(Math.max(1, Emulator.getConfig().getInt("rcon.rate_limit.limit_for_period", 60))) + .limitRefreshPeriod(Duration.ofMillis(Math.max(100, Emulator.getConfig().getInt("rcon.rate_limit.refresh_period_ms", 1000)))) + .timeoutDuration(Duration.ofMillis(Math.max(0, Emulator.getConfig().getInt("rcon.rate_limit.timeout_ms", 0)))) + .build(); + this.rateLimiters = Caffeine.newBuilder() + .maximumSize(512) + .expireAfterAccess(Duration.ofMinutes(10)) + .build(address -> RateLimiter.of("rcon-" + address, rateLimiterConfig)); this.addRCONMessage("alertuser", AlertUser.class); this.addRCONMessage("disconnect", DisconnectUser.class); @@ -89,6 +107,11 @@ public class RCONServer extends Server { } public String handle(ChannelHandlerContext ctx, String key, String body) throws Exception { + if (!this.acquirePermit(ctx)) { + LOGGER.warn("RCON rate limit exceeded for {}", remoteAddress(ctx)); + return "RATE_LIMITED"; + } + Class message = this.messages.get(key.replace("_", "").toLowerCase()); String result; @@ -118,4 +141,17 @@ public class RCONServer extends Server { public List getCommands() { return new ArrayList<>(this.messages.keySet()); } + + private boolean acquirePermit(ChannelHandlerContext ctx) { + return !this.rateLimitEnabled || this.rateLimiters.get(remoteAddress(ctx)).acquirePermission(); + } + + private static String remoteAddress(ChannelHandlerContext ctx) { + if (ctx == null || ctx.channel() == null) { + return "unknown"; + } + + SocketAddress address = ctx.channel().remoteAddress(); + return address == null ? "unknown" : address.toString(); + } } 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 new file mode 100644 index 00000000..7888b0d0 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/networking/rconserver/RCONServerHandlerContractTest.java @@ -0,0 +1,48 @@ +package com.eu.habbo.networking.rconserver; + +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 RCONServerHandlerContractTest { + private static String serverSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java")); + } + + private static String emulatorSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/Emulator.java")); + } + + @Test + void rconRequestsAreRateLimitedPerRemoteAddress() throws Exception { + String source = serverSource(); + + assertTrue(source.contains("LoadingCache"), + "RCON server must keep per-remote-address rate limiters"); + assertTrue(source.contains("Caffeine.newBuilder()"), + "RCON rate limiters must expire instead of growing forever"); + assertTrue(source.contains(".acquirePermission()"), + "RCON handler must consume a Resilience4j permit before dispatching commands"); + assertTrue(source.contains("RATE_LIMITED"), + "RCON callers need a deterministic response when the rate limit rejects a request"); + } + + @Test + void rconRateLimitDefaultsAreRegisteredBeforeServerStarts() throws Exception { + String source = emulatorSource(); + int registerIndex = source.indexOf("register(\"rcon.rate_limit.enabled\", \"1\")"); + int serverIndex = source.indexOf("new RCONServer"); + + assertTrue(registerIndex >= 0, "RCON rate limiting must have a registered default toggle"); + assertTrue(source.contains("register(\"rcon.rate_limit.limit_for_period\", \"60\")"), + "RCON rate limit must have a registered default limit"); + assertTrue(source.contains("register(\"rcon.rate_limit.refresh_period_ms\", \"1000\")"), + "RCON rate limit must have a registered default refresh period"); + assertTrue(source.contains("register(\"rcon.rate_limit.timeout_ms\", \"0\")"), + "RCON rate limit must reject immediately by default instead of blocking event loops"); + assertTrue(registerIndex < serverIndex, "RCON rate limit defaults must be registered before RCONServer is constructed"); + } +} From d7fa02a453c41f6ed16f4f96b9b86763e0d823d6 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 18:42:15 +0200 Subject: [PATCH 03/19] fix(rcon): validate privileged payloads --- .../com/eu/habbo/messages/rcon/GiveBadge.java | 8 ++++ .../eu/habbo/messages/rcon/GiveCredits.java | 3 ++ .../eu/habbo/messages/rcon/GivePixels.java | 3 ++ .../eu/habbo/messages/rcon/GivePoints.java | 5 +++ .../eu/habbo/messages/rcon/RCONMessage.java | 11 +++++ .../messages/rcon/RconPayloadValidator.java | 37 ++++++++++++++++ .../com/eu/habbo/messages/rcon/SetRank.java | 3 ++ .../networking/rconserver/RCONServer.java | 5 ++- .../rcon/RconPayloadValidatorTest.java | 44 +++++++++++++++++++ 9 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/rcon/RconPayloadValidator.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/RconPayloadValidatorTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveBadge.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveBadge.java index 4cb93feb..f233b32c 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveBadge.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveBadge.java @@ -5,6 +5,10 @@ import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.habbohotel.users.HabboBadge; import com.eu.habbo.messages.outgoing.users.AddUserBadgeComposer; import com.google.gson.Gson; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -89,9 +93,13 @@ public class GiveBadge extends RCONMessage { static class GiveBadgeJSON { + @Positive(message = "invalid user") public int user_id = -1; + @NotBlank(message = "invalid badge") + @Size(max = 512, message = "invalid badge") + @Pattern(regexp = "[A-Za-z0-9_\\-;]+", message = "invalid badge") public String badge; } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java index 60e9326a..a5b8fb07 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java @@ -3,6 +3,7 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; +import jakarta.validation.constraints.Positive; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,9 +41,11 @@ public class GiveCredits extends RCONMessage { static class JSONGiveCredits { + @Positive(message = "invalid user") public int user_id; + @Positive(message = "invalid credits") public int credits; } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java index 45001f98..cf499056 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java @@ -3,6 +3,7 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; +import jakarta.validation.constraints.Positive; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,9 +41,11 @@ public class GivePixels extends RCONMessage { static class JSONGivePixels { + @Positive(message = "invalid user") public int user_id; + @Positive(message = "invalid pixels") public int pixels; } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePoints.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePoints.java index 2afff479..1d28481d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePoints.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePoints.java @@ -3,6 +3,8 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -42,12 +44,15 @@ public class GivePoints extends RCONMessage { static class JSONGivePoints { + @Positive(message = "invalid user") public int user_id; + @Positive(message = "invalid points") public int points; + @Min(value = 0, message = "invalid currency type") public int type; } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/RCONMessage.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/RCONMessage.java index 9948ee23..da13df21 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/RCONMessage.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/RCONMessage.java @@ -31,6 +31,17 @@ public abstract class RCONMessage { public abstract void handle(Gson gson, T json); + public boolean validate(T json) { + String validationError = RconPayloadValidator.validate(json); + if (validationError == null) { + return true; + } + + this.status = STATUS_ERROR; + this.message = validationError; + return false; + } + @SuppressWarnings("rawtypes") public static class RCONMessageSerializer implements JsonSerializer { @Override diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/RconPayloadValidator.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/RconPayloadValidator.java new file mode 100644 index 00000000..52680ae6 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/RconPayloadValidator.java @@ -0,0 +1,37 @@ +package com.eu.habbo.messages.rcon; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator; + +import java.util.Comparator; +import java.util.Set; + +final class RconPayloadValidator { + private static final Validator VALIDATOR; + + static { + ValidatorFactory factory = Validation.byDefaultProvider() + .configure() + .messageInterpolator(new ParameterMessageInterpolator()) + .buildValidatorFactory(); + VALIDATOR = factory.getValidator(); + } + + private RconPayloadValidator() { + } + + static String validate(Object payload) { + if (payload == null) { + return "invalid payload"; + } + + Set> violations = VALIDATOR.validate(payload); + return violations.stream() + .min(Comparator.comparing(violation -> violation.getPropertyPath().toString())) + .map(ConstraintViolation::getMessage) + .orElse(null); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRank.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRank.java index 639f9674..e05bee9e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRank.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRank.java @@ -3,6 +3,7 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; +import jakarta.validation.constraints.Positive; public class SetRank extends RCONMessage { @@ -31,9 +32,11 @@ public class SetRank extends RCONMessage { static class JSONSetRank { + @Positive(message = "invalid user") public int user_id; + @Positive(message = "invalid rank") public int rank; } } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java index 95d14d99..298db96c 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java @@ -119,7 +119,10 @@ public class RCONServer extends Server { try { RCONMessage rcon = message.getDeclaredConstructor().newInstance(); Gson gson = this.gsonBuilder.create(); - rcon.handle(gson, gson.fromJson(body, rcon.type)); + Object payload = gson.fromJson(body, rcon.type); + if (rcon.validate(payload)) { + rcon.handle(gson, payload); + } LOGGER.info("Handled RCON Message: {}", message.getSimpleName()); result = gson.toJson(rcon, RCONMessage.class); diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/RconPayloadValidatorTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/RconPayloadValidatorTest.java new file mode 100644 index 00000000..a95806e6 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/RconPayloadValidatorTest.java @@ -0,0 +1,44 @@ +package com.eu.habbo.messages.rcon; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class RconPayloadValidatorTest { + @Test + void acceptsValidAnnotatedPayloads() { + SetRank.JSONSetRank payload = new SetRank.JSONSetRank(); + payload.user_id = 1; + payload.rank = 2; + + assertNull(RconPayloadValidator.validate(payload)); + } + + @Test + void rejectsInvalidSetRankPayloadsBeforeDispatch() { + SetRank.JSONSetRank payload = new SetRank.JSONSetRank(); + payload.user_id = 0; + payload.rank = 2; + + assertEquals("invalid user", RconPayloadValidator.validate(payload)); + } + + @Test + void rejectsInvalidGrantPayloadsBeforeDispatch() { + GiveCredits.JSONGiveCredits payload = new GiveCredits.JSONGiveCredits(); + payload.user_id = 1; + payload.credits = 0; + + assertEquals("invalid credits", RconPayloadValidator.validate(payload)); + } + + @Test + void rejectsBlankBadgePayloadsBeforeDispatch() { + GiveBadge.GiveBadgeJSON payload = new GiveBadge.GiveBadgeJSON(); + payload.user_id = 1; + payload.badge = " "; + + assertEquals("invalid badge", RconPayloadValidator.validate(payload)); + } +} From aaad94f954ef218310791d55be33ac36834b47e9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 02:14:02 +0200 Subject: [PATCH 04/19] fix(rcon): upsert offline pixel grants RCON GivePixels previously used an UPDATE for offline users, so users without an existing users_currency type 0 row received no pixels while the command still returned success. Match the GivePoints and housekeeping paths with an upsert and add a contract test that keeps offline pixel grants creating missing currency rows. --- .../eu/habbo/messages/rcon/GivePixels.java | 9 +++---- .../messages/rcon/GivePixelsContractTest.java | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePixelsContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java index cf499056..3e0f228d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java @@ -26,10 +26,11 @@ public class GivePixels extends RCONMessage { if (habbo != null) { habbo.givePixels(object.pixels); } else { - try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE users_currency SET users_currency.amount = users_currency.amount + ? WHERE users_currency.user_id = ? AND users_currency.type = 0")) { - statement.setInt(1, object.pixels); - statement.setInt(2, object.user_id); - statement.execute(); + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO users_currency (`user_id`, `type`, `amount`) VALUES (?, 0, ?) ON DUPLICATE KEY UPDATE amount = amount + ?")) { + statement.setInt(1, object.user_id); + statement.setInt(2, object.pixels); + statement.setInt(3, object.pixels); + statement.executeUpdate(); } catch (SQLException e) { this.status = RCONMessage.SYSTEM_ERROR; LOGGER.error("Caught SQL exception", e); diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePixelsContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePixelsContractTest.java new file mode 100644 index 00000000..af404d5c --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePixelsContractTest.java @@ -0,0 +1,24 @@ +package com.eu.habbo.messages.rcon; + +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 GivePixelsContractTest { + private static String givePixelsSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/GivePixels.java")); + } + + @Test + void offlinePixelGrantCreatesMissingCurrencyRow() throws Exception { + String source = givePixelsSource(); + + assertTrue(source.contains("INSERT INTO users_currency"), + "Offline RCON pixel grants must create the users_currency type 0 row when it is missing"); + assertTrue(source.contains("ON DUPLICATE KEY UPDATE"), + "Offline RCON pixel grants should increment existing rows with an upsert"); + } +} From 4330bf5a6292043995b5fedf57492f906302dc49 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 15:15:19 +0200 Subject: [PATCH 05/19] fix(rcon): always release inbound buffers RCONServerHandler released the inbound ByteBuf only after successfully parsing, writing, flushing, and closing the response. Any exception before the tail release could leak Netty buffers and let malformed RCON traffic consume memory over time. Guard non-ByteBuf messages, release accepted buffers from a finally block, and add a contract test for the release invariant. --- .../rconserver/RCONServerHandler.java | 50 +++++++++++-------- .../RCONServerHandlerContractTest.java | 14 ++++++ 2 files changed, 42 insertions(+), 22 deletions(-) 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 26226413..014ba0ec 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 @@ -37,29 +37,35 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - ByteBuf data = (ByteBuf) msg; - - byte[] d = new byte[data.readableBytes()]; - data.getBytes(0, d); - String message = new String(d, java.nio.charset.StandardCharsets.UTF_8); - Gson gson = GSON; - String response = "ERROR"; - String key = ""; - try { - JsonObject object = gson.fromJson(message, JsonObject.class); - key = object.get("key").getAsString(); - response = Emulator.getRconServer().handle(ctx, key, object.get("data").toString()); - } catch (ArrayIndexOutOfBoundsException e) { - LOGGER.error("Unknown RCON Message: {}", key); - } catch (Exception e) { - LOGGER.error("Invalid RCON Message: {}", message); - e.printStackTrace(); + if (!(msg instanceof ByteBuf data)) { + ctx.fireChannelRead(msg); + return; } - 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(); - data.release(); + try { + byte[] d = new byte[data.readableBytes()]; + data.getBytes(0, d); + String message = new String(d, java.nio.charset.StandardCharsets.UTF_8); + Gson gson = GSON; + String response = "ERROR"; + String key = ""; + try { + JsonObject object = gson.fromJson(message, JsonObject.class); + key = object.get("key").getAsString(); + response = Emulator.getRconServer().handle(ctx, key, object.get("data").toString()); + } catch (ArrayIndexOutOfBoundsException e) { + LOGGER.error("Unknown RCON Message: {}", key); + } catch (Exception e) { + LOGGER.error("Invalid RCON Message: {}", message); + 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(); + } finally { + data.release(); + } } } 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 7888b0d0..893d556b 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 @@ -12,6 +12,10 @@ class RCONServerHandlerContractTest { return Files.readString(Path.of("src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java")); } + private static String handlerSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java")); + } + private static String emulatorSource() throws Exception { return Files.readString(Path.of("src/main/java/com/eu/habbo/Emulator.java")); } @@ -45,4 +49,14 @@ class RCONServerHandlerContractTest { "RCON rate limit must reject immediately by default instead of blocking event loops"); assertTrue(registerIndex < serverIndex, "RCON rate limit defaults must be registered before RCONServer is constructed"); } + + @Test + void inboundByteBufIsReleasedFromFinallyBlock() throws Exception { + String source = handlerSource(); + int finallyIndex = source.indexOf("finally"); + int releaseIndex = source.indexOf("data.release()"); + + 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"); + } } From b94acdf719957e02fcd6cb358570ac5f68de9afc Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 15:19:53 +0200 Subject: [PATCH 06/19] fix(rcon): report missing offline credit targets GiveCredits treated offline UPDATE execution as success without checking whether any user row was changed. Nonexistent user ids could therefore return an offline success response while granting nothing. Use executeUpdate(), return HABBO_NOT_FOUND when no row is affected, and keep SQL errors from falling through to the offline success message. --- .../eu/habbo/messages/rcon/GiveCredits.java | 6 ++++- .../rcon/GiveCreditsContractTest.java | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveCreditsContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java index a5b8fb07..d5f906ed 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java @@ -29,10 +29,14 @@ public class GiveCredits extends RCONMessage { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE users SET credits = credits + ? WHERE id = ? LIMIT 1")) { statement.setInt(1, object.credits); statement.setInt(2, object.user_id); - statement.execute(); + if (statement.executeUpdate() == 0) { + this.status = RCONMessage.HABBO_NOT_FOUND; + return; + } } catch (SQLException e) { this.status = RCONMessage.SYSTEM_ERROR; LOGGER.error("Caught SQL exception", e); + return; } this.message = "offline"; diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveCreditsContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveCreditsContractTest.java new file mode 100644 index 00000000..dda7da84 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveCreditsContractTest.java @@ -0,0 +1,24 @@ +package com.eu.habbo.messages.rcon; + +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 GiveCreditsContractTest { + private static String giveCreditsSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java")); + } + + @Test + void offlineCreditGrantReportsMissingUsersWhenNoRowsChange() throws Exception { + String source = giveCreditsSource(); + + assertTrue(source.contains("executeUpdate()"), + "Offline RCON credit grants must inspect the affected row count"); + assertTrue(source.contains("HABBO_NOT_FOUND"), + "Offline RCON credit grants must report missing users when the UPDATE changes no rows"); + } +} From d8260ec461096a81cdaf73b0f254924243f7868a Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 15:24:11 +0200 Subject: [PATCH 07/19] fix(rcon): bind offline respect counters correctly GiveRespect inverted the offline SQL parameters for respects_given and respects_received. Online users received the intended counters, but offline users had the two persisted counters swapped. Bind respect_given to respects_given and respect_received to respects_received, with a contract test to keep the RCON offline path aligned. --- .../eu/habbo/messages/rcon/GiveRespect.java | 6 ++--- .../rcon/GiveRespectContractTest.java | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveRespectContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveRespect.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveRespect.java index 05c57de7..3b9a57b2 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveRespect.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveRespect.java @@ -30,8 +30,8 @@ public class GiveRespect extends RCONMessage { habbo.getClient().sendResponse(new UserDataComposer(habbo)); } else { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET respects_given = respects_given + ?, respects_received = respects_received + ?, daily_respect_points = daily_respect_points + ? WHERE user_id = ? LIMIT 1")) { - statement.setInt(1, object.respect_received); - statement.setInt(2, object.respect_given); + statement.setInt(1, object.respect_given); + statement.setInt(2, object.respect_received); statement.setInt(3, object.daily_respects); statement.setInt(4, object.user_id); statement.execute(); @@ -50,4 +50,4 @@ public class GiveRespect extends RCONMessage { public int respect_received = 0; public int daily_respects = 0; } -} \ No newline at end of file +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveRespectContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveRespectContractTest.java new file mode 100644 index 00000000..b36999c3 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveRespectContractTest.java @@ -0,0 +1,24 @@ +package com.eu.habbo.messages.rcon; + +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 GiveRespectContractTest { + private static String giveRespectSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/GiveRespect.java")); + } + + @Test + void offlineRespectGrantBindsGivenAndReceivedToMatchingColumns() throws Exception { + String source = giveRespectSource(); + + assertTrue(source.contains("statement.setInt(1, object.respect_given);"), + "respects_given must be incremented with respect_given"); + assertTrue(source.contains("statement.setInt(2, object.respect_received);"), + "respects_received must be incremented with respect_received"); + } +} From 4eafb54c576d453aafdb49b45bc34dcd758a5ead Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 15:28:01 +0200 Subject: [PATCH 08/19] fix(rcon): allow online motto updates outside rooms SetMotto updated the in-memory motto and then unconditionally broadcast RoomUserData through the current room. Online users without a current room could throw a null-pointer exception after the state change, making the RCON call report an error despite mutating the user. Only broadcast room data when a room is present and cover the invariant with a contract test. --- .../com/eu/habbo/messages/rcon/SetMotto.java | 6 +++-- .../messages/rcon/SetMottoContractTest.java | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/SetMottoContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetMotto.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetMotto.java index 88ce9c20..d6651097 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetMotto.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetMotto.java @@ -24,7 +24,9 @@ public class SetMotto extends RCONMessage { if (habbo != null) { habbo.getHabboInfo().setMotto(json.motto); - habbo.getHabboInfo().getCurrentRoom().sendComposer(new RoomUserDataComposer(habbo).compose()); + if (habbo.getHabboInfo().getCurrentRoom() != null) { + habbo.getHabboInfo().getCurrentRoom().sendComposer(new RoomUserDataComposer(habbo).compose()); + } } else { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { try (PreparedStatement statement = connection.prepareStatement("UPDATE users SET motto = ? WHERE id = ? LIMIT 1")) { @@ -45,4 +47,4 @@ public class SetMotto extends RCONMessage { public String motto; } -} \ No newline at end of file +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/SetMottoContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/SetMottoContractTest.java new file mode 100644 index 00000000..a8867b68 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/SetMottoContractTest.java @@ -0,0 +1,22 @@ +package com.eu.habbo.messages.rcon; + +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 SetMottoContractTest { + private static String setMottoSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/SetMotto.java")); + } + + @Test + void onlineMottoUpdateDoesNotRequireCurrentRoom() throws Exception { + String source = setMottoSource(); + + assertTrue(source.contains("getCurrentRoom() != null"), + "RCON SetMotto must not fail for online users outside a room"); + } +} From 775197984f73527783f8e04a9d6e087bc30b38b3 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 15:33:38 +0200 Subject: [PATCH 09/19] fix(rcon): validate offline badge targets GiveBadge could treat a missing offline user as eligible for a badge and insert through a nullable user subquery. Depending on SQL mode this could fail late or persist an orphaned user_id value. Resolve the offline user first, return HABBO_NOT_FOUND when absent, and insert badges with the resolved user id only. --- .../com/eu/habbo/messages/rcon/GiveBadge.java | 17 +++++++++--- .../messages/rcon/GiveBadgeContractTest.java | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveBadgeContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveBadge.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveBadge.java index f233b32c..dcca9cc9 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveBadge.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveBadge.java @@ -3,6 +3,8 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.habbohotel.users.HabboBadge; +import com.eu.habbo.habbohotel.users.HabboInfo; +import com.eu.habbo.habbohotel.users.HabboManager; import com.eu.habbo.messages.outgoing.users.AddUserBadgeComposer; import com.google.gson.Gson; import jakarta.validation.constraints.NotBlank; @@ -57,11 +59,18 @@ public class GiveBadge extends RCONMessage { this.message = Emulator.getTexts().getValue("commands.succes.cmd_badge.given").replace("%user%", username).replace("%badge%", badgeCode); } } else { + HabboInfo habboInfo = HabboManager.getOfflineHabboInfo(json.user_id); + if (habboInfo == null) { + this.status = RCONMessage.HABBO_NOT_FOUND; + return; + } + + username = habboInfo.getUsername(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { for (String badgeCode : json.badge.split(";")) { int numberOfRows = 0; - try (PreparedStatement statement = connection.prepareStatement("SELECT COUNT(slot_id) FROM users_badges INNER JOIN users ON users.id = user_id WHERE users.id = ? AND badge_code = ? LIMIT 1")) { - statement.setInt(1, json.user_id); + try (PreparedStatement statement = connection.prepareStatement("SELECT COUNT(slot_id) FROM users_badges WHERE user_id = ? AND badge_code = ? LIMIT 1")) { + statement.setInt(1, habboInfo.getId()); statement.setString(2, badgeCode); try (ResultSet set = statement.executeQuery()) { if (set.next()){ @@ -74,8 +83,8 @@ public class GiveBadge extends RCONMessage { this.status = RCONMessage.STATUS_ERROR; this.message += Emulator.getTexts().getValue("commands.error.cmd_badge.already_owns").replace("%user%", username).replace("%badge%", badgeCode) + "\r"; } else { - try (PreparedStatement statement = connection.prepareStatement("INSERT INTO users_badges VALUES (null, (SELECT id FROM users WHERE users.id = ? LIMIT 1), 0, ?)", Statement.RETURN_GENERATED_KEYS)) { - statement.setInt(1, json.user_id); + try (PreparedStatement statement = connection.prepareStatement("INSERT INTO users_badges (`id`, `user_id`, `slot_id`, `badge_code`) VALUES (null, ?, 0, ?)", Statement.RETURN_GENERATED_KEYS)) { + statement.setInt(1, habboInfo.getId()); statement.setString(2, badgeCode); statement.execute(); } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveBadgeContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveBadgeContractTest.java new file mode 100644 index 00000000..aaba384a --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveBadgeContractTest.java @@ -0,0 +1,27 @@ +package com.eu.habbo.messages.rcon; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GiveBadgeContractTest { + private static String giveBadgeSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/GiveBadge.java")); + } + + @Test + void offlineBadgeGrantRequiresExistingUserBeforeInsert() throws Exception { + String source = giveBadgeSource(); + + assertTrue(source.contains("HabboManager.getOfflineHabboInfo(json.user_id)"), + "Offline RCON badge grants must verify the target user exists"); + assertTrue(source.contains("RCONMessage.HABBO_NOT_FOUND"), + "Offline RCON badge grants must report missing users"); + assertFalse(source.contains("(SELECT id FROM users WHERE users.id = ? LIMIT 1)"), + "Offline RCON badge grants must not insert through a nullable user subquery"); + } +} From 3cb24a5185e1a6b806b66da77619c9444a1796e0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 16:29:15 +0200 Subject: [PATCH 10/19] fix(rcon): constrain setrank requests --- .../com/eu/habbo/messages/rcon/SetRank.java | 40 +++++++++++++++++++ .../messages/rcon/SetRankRequestGuard.java | 39 ++++++++++++++++++ .../rcon/SetRankRequestGuardTest.java | 32 +++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRankRequestGuard.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/SetRankRequestGuardTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRank.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRank.java index e05bee9e..496db38d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRank.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRank.java @@ -5,6 +5,10 @@ import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; import jakarta.validation.constraints.Positive; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + public class SetRank extends RCONMessage { public SetRank() { @@ -13,6 +17,25 @@ public class SetRank extends RCONMessage { @Override public void handle(Gson gson, JSONSetRank object) { + int maxRank = SetRankRequestGuard.parseMaxRank( + Emulator.getConfig().getValue("rcon.setrank.max_rank", String.valueOf(SetRankRequestGuard.DEFAULT_MAX_RANK))); + String validationError = SetRankRequestGuard.validate( + object.user_id, + object.rank, + maxRank, + rankId -> Emulator.getGameEnvironment().getPermissionsManager().rankExists(rankId)); + if (validationError != null) { + this.status = RCONMessage.STATUS_ERROR; + this.message = validationError; + return; + } + + if (!userExists(object.user_id)) { + this.status = RCONMessage.HABBO_NOT_FOUND; + this.message = "user not found"; + return; + } + try { Emulator.getGameEnvironment().getHabboManager().setRank(object.user_id, object.rank); } catch (Exception e) { @@ -30,6 +53,23 @@ public class SetRank extends RCONMessage { } } + private static boolean userExists(int userId) { + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); + if (habbo != null) { + return true; + } + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT id FROM users WHERE id = ? LIMIT 1")) { + statement.setInt(1, userId); + try (ResultSet set = statement.executeQuery()) { + return set.next(); + } + } catch (Exception e) { + return false; + } + } + static class JSONSetRank { @Positive(message = "invalid user") diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRankRequestGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRankRequestGuard.java new file mode 100644 index 00000000..aa55344a --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRankRequestGuard.java @@ -0,0 +1,39 @@ +package com.eu.habbo.messages.rcon; + +import java.util.function.IntPredicate; + +public class SetRankRequestGuard { + public static final int DEFAULT_MAX_RANK = 12; + + private SetRankRequestGuard() { + } + + public static String validate(int userId, int rankId, int maxRank, IntPredicate rankExists) { + if (userId <= 0) { + return "invalid user"; + } + + if (rankId <= 0) { + return "invalid rank"; + } + + if (maxRank > 0 && rankId > maxRank) { + return "rank exceeds rcon ceiling"; + } + + if (!rankExists.test(rankId)) { + return "invalid rank"; + } + + return null; + } + + public static int parseMaxRank(String rawValue) { + try { + int value = Integer.parseInt(rawValue); + return value > 0 ? value : DEFAULT_MAX_RANK; + } catch (Exception e) { + return DEFAULT_MAX_RANK; + } + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/SetRankRequestGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/SetRankRequestGuardTest.java new file mode 100644 index 00000000..a4ae0431 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/SetRankRequestGuardTest.java @@ -0,0 +1,32 @@ +package com.eu.habbo.messages.rcon; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class SetRankRequestGuardTest { + @Test + void acceptsKnownRanksWithinTheRconCeiling() { + assertNull(SetRankRequestGuard.validate(1, 5, 12, rankId -> rankId == 5)); + } + + @Test + void rejectsInvalidUsersRanksAndUnknownRanks() { + assertEquals("invalid user", SetRankRequestGuard.validate(0, 5, 12, rankId -> true)); + assertEquals("invalid rank", SetRankRequestGuard.validate(1, 0, 12, rankId -> true)); + assertEquals("invalid rank", SetRankRequestGuard.validate(1, 5, 12, rankId -> false)); + } + + @Test + void rejectsRanksAboveConfiguredCeiling() { + assertEquals("rank exceeds rcon ceiling", SetRankRequestGuard.validate(1, 13, 12, rankId -> true)); + } + + @Test + void parsesInvalidMaxRankAsDefaultCeiling() { + assertEquals(SetRankRequestGuard.DEFAULT_MAX_RANK, SetRankRequestGuard.parseMaxRank(null)); + assertEquals(SetRankRequestGuard.DEFAULT_MAX_RANK, SetRankRequestGuard.parseMaxRank("0")); + assertEquals(7, SetRankRequestGuard.parseMaxRank("7")); + } +} From dba0337a7bceacbec434acb9c92cfc404475ddd2 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 16:45:04 +0200 Subject: [PATCH 11/19] fix(rcon): validate grant requests --- .../eu/habbo/messages/rcon/GiveCredits.java | 12 +++++ .../eu/habbo/messages/rcon/GivePixels.java | 18 +++++++ .../eu/habbo/messages/rcon/GivePoints.java | 21 ++++++++ .../eu/habbo/messages/rcon/GiveRespect.java | 34 ++++++++++++- .../habbo/messages/rcon/RconGrantGuard.java | 49 +++++++++++++++++++ .../habbo/messages/rcon/RconUserLookup.java | 28 +++++++++++ .../rcon/GiveCreditsContractTest.java | 2 + .../messages/rcon/GivePixelsContractTest.java | 4 ++ .../messages/rcon/GivePointsContractTest.java | 28 +++++++++++ .../rcon/GiveRespectContractTest.java | 4 ++ .../messages/rcon/RconGrantGuardTest.java | 38 ++++++++++++++ 11 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/rcon/RconGrantGuard.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/rcon/RconUserLookup.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePointsContractTest.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/RconGrantGuardTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java index d5f906ed..ed6c2f49 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveCredits.java @@ -21,6 +21,18 @@ public class GiveCredits extends RCONMessage { @Override public void handle(Gson gson, JSONGiveCredits object) { + int maxAmount = RconGrantGuard.parseMaxAmount( + Emulator.getConfig().getValue("rcon.grant.max_amount", String.valueOf(RconGrantGuard.DEFAULT_MAX_AMOUNT))); + String validationError = RconGrantGuard.validateUserId(object.user_id); + if (validationError == null) { + validationError = RconGrantGuard.validatePositiveAmount(object.credits, maxAmount, "credits"); + } + if (validationError != null) { + this.status = RCONMessage.STATUS_ERROR; + this.message = validationError; + return; + } + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(object.user_id); if (habbo != null) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java index 3e0f228d..137c3bd1 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePixels.java @@ -21,11 +21,29 @@ public class GivePixels extends RCONMessage { @Override public void handle(Gson gson, JSONGivePixels object) { + int maxAmount = RconGrantGuard.parseMaxAmount( + Emulator.getConfig().getValue("rcon.grant.max_amount", String.valueOf(RconGrantGuard.DEFAULT_MAX_AMOUNT))); + String validationError = RconGrantGuard.validateUserId(object.user_id); + if (validationError == null) { + validationError = RconGrantGuard.validatePositiveAmount(object.pixels, maxAmount, "pixels"); + } + if (validationError != null) { + this.status = RCONMessage.STATUS_ERROR; + this.message = validationError; + return; + } + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(object.user_id); if (habbo != null) { habbo.givePixels(object.pixels); } else { + if (!RconUserLookup.userExists(object.user_id)) { + this.status = RCONMessage.HABBO_NOT_FOUND; + this.message = "user not found"; + return; + } + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO users_currency (`user_id`, `type`, `amount`) VALUES (?, 0, ?) ON DUPLICATE KEY UPDATE amount = amount + ?")) { statement.setInt(1, object.user_id); statement.setInt(2, object.pixels); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePoints.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePoints.java index 1d28481d..c6e7a564 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePoints.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GivePoints.java @@ -22,11 +22,32 @@ public class GivePoints extends RCONMessage { @Override public void handle(Gson gson, JSONGivePoints object) { + int maxAmount = RconGrantGuard.parseMaxAmount( + Emulator.getConfig().getValue("rcon.grant.max_amount", String.valueOf(RconGrantGuard.DEFAULT_MAX_AMOUNT))); + String validationError = RconGrantGuard.validateUserId(object.user_id); + if (validationError == null) { + validationError = RconGrantGuard.validateCurrencyType(object.type); + } + if (validationError == null) { + validationError = RconGrantGuard.validatePositiveAmount(object.points, maxAmount, "points"); + } + if (validationError != null) { + this.status = RCONMessage.STATUS_ERROR; + this.message = validationError; + return; + } + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(object.user_id); if (habbo != null) { habbo.givePoints(object.type, object.points); } else { + if (!RconUserLookup.userExists(object.user_id)) { + this.status = RCONMessage.HABBO_NOT_FOUND; + this.message = "user not found"; + return; + } + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO users_currency (`user_id`, `type`, `amount`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = amount + ?")) { statement.setInt(1, object.user_id); statement.setInt(2, object.type); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveRespect.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveRespect.java index 3b9a57b2..1efb3a56 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveRespect.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveRespect.java @@ -21,6 +21,27 @@ public class GiveRespect extends RCONMessage { @Override public void handle(Gson gson, JSONGiveRespect object) { + int maxAmount = RconGrantGuard.parseMaxAmount( + Emulator.getConfig().getValue("rcon.grant.max_amount", String.valueOf(RconGrantGuard.DEFAULT_MAX_AMOUNT))); + String validationError = RconGrantGuard.validateUserId(object.user_id); + if (validationError == null) { + validationError = RconGrantGuard.validateNonNegativeAmount(object.respect_given, maxAmount, "respect_given"); + } + if (validationError == null) { + validationError = RconGrantGuard.validateNonNegativeAmount(object.respect_received, maxAmount, "respect_received"); + } + if (validationError == null) { + validationError = RconGrantGuard.validateNonNegativeAmount(object.daily_respects, maxAmount, "daily_respects"); + } + if (validationError == null && object.respect_given == 0 && object.respect_received == 0 && object.daily_respects == 0) { + validationError = "no respect grant provided"; + } + if (validationError != null) { + this.status = RCONMessage.STATUS_ERROR; + this.message = validationError; + return; + } + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(object.user_id); if (habbo != null) { @@ -29,15 +50,26 @@ public class GiveRespect extends RCONMessage { habbo.getHabboStats().respectPointsToGive += object.daily_respects; habbo.getClient().sendResponse(new UserDataComposer(habbo)); } else { + if (!RconUserLookup.userExists(object.user_id)) { + this.status = RCONMessage.HABBO_NOT_FOUND; + this.message = "user not found"; + return; + } + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET respects_given = respects_given + ?, respects_received = respects_received + ?, daily_respect_points = daily_respect_points + ? WHERE user_id = ? LIMIT 1")) { statement.setInt(1, object.respect_given); statement.setInt(2, object.respect_received); statement.setInt(3, object.daily_respects); statement.setInt(4, object.user_id); - statement.execute(); + if (statement.executeUpdate() == 0) { + this.status = RCONMessage.HABBO_NOT_FOUND; + this.message = "user not found"; + return; + } } catch (SQLException e) { this.status = RCONMessage.SYSTEM_ERROR; LOGGER.error("Caught SQL exception", e); + return; } this.message = "offline"; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/RconGrantGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/RconGrantGuard.java new file mode 100644 index 00000000..3b5864f6 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/RconGrantGuard.java @@ -0,0 +1,49 @@ +package com.eu.habbo.messages.rcon; + +public class RconGrantGuard { + public static final int DEFAULT_MAX_AMOUNT = 1_000_000; + + private RconGrantGuard() { + } + + public static String validateUserId(int userId) { + return userId > 0 ? null : "invalid user"; + } + + public static String validatePositiveAmount(int amount, int maxAmount, String fieldName) { + if (amount <= 0) { + return "invalid " + fieldName; + } + + if (maxAmount > 0 && amount > maxAmount) { + return fieldName + " exceeds rcon grant ceiling"; + } + + return null; + } + + public static String validateNonNegativeAmount(int amount, int maxAmount, String fieldName) { + if (amount < 0) { + return "invalid " + fieldName; + } + + if (maxAmount > 0 && amount > maxAmount) { + return fieldName + " exceeds rcon grant ceiling"; + } + + return null; + } + + public static String validateCurrencyType(int type) { + return type >= 0 ? null : "invalid currency type"; + } + + public static int parseMaxAmount(String rawValue) { + try { + int value = Integer.parseInt(rawValue); + return value > 0 ? value : DEFAULT_MAX_AMOUNT; + } catch (Exception e) { + return DEFAULT_MAX_AMOUNT; + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/RconUserLookup.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/RconUserLookup.java new file mode 100644 index 00000000..548cb517 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/RconUserLookup.java @@ -0,0 +1,28 @@ +package com.eu.habbo.messages.rcon; + +import com.eu.habbo.Emulator; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +public class RconUserLookup { + private RconUserLookup() { + } + + public static boolean userExists(int userId) { + if (Emulator.getGameEnvironment().getHabboManager().getHabbo(userId) != null) { + return true; + } + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT id FROM users WHERE id = ? LIMIT 1")) { + statement.setInt(1, userId); + try (ResultSet set = statement.executeQuery()) { + return set.next(); + } + } catch (Exception e) { + return false; + } + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveCreditsContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveCreditsContractTest.java index dda7da84..24f7caba 100644 --- a/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveCreditsContractTest.java +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveCreditsContractTest.java @@ -20,5 +20,7 @@ class GiveCreditsContractTest { "Offline RCON credit grants must inspect the affected row count"); assertTrue(source.contains("HABBO_NOT_FOUND"), "Offline RCON credit grants must report missing users when the UPDATE changes no rows"); + assertTrue(source.contains("RconGrantGuard.validatePositiveAmount"), + "RCON credit grants must reject zero, negative, and oversized grants"); } } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePixelsContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePixelsContractTest.java index af404d5c..fc06f148 100644 --- a/Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePixelsContractTest.java +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePixelsContractTest.java @@ -20,5 +20,9 @@ class GivePixelsContractTest { "Offline RCON pixel grants must create the users_currency type 0 row when it is missing"); assertTrue(source.contains("ON DUPLICATE KEY UPDATE"), "Offline RCON pixel grants should increment existing rows with an upsert"); + assertTrue(source.contains("RconGrantGuard.validatePositiveAmount"), + "RCON pixel grants must reject zero, negative, and oversized grants"); + assertTrue(source.contains("RconUserLookup.userExists"), + "Offline RCON pixel grants must not create orphan currency rows for missing users"); } } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePointsContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePointsContractTest.java new file mode 100644 index 00000000..8fd43fc6 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePointsContractTest.java @@ -0,0 +1,28 @@ +package com.eu.habbo.messages.rcon; + +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 GivePointsContractTest { + private static String givePointsSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/GivePoints.java")); + } + + @Test + void pointGrantsValidateAmountTypeAndOfflineUserExistence() throws Exception { + String source = givePointsSource(); + + assertTrue(source.contains("RconGrantGuard.validateCurrencyType"), + "RCON point grants must reject invalid currency types"); + assertTrue(source.contains("RconGrantGuard.validatePositiveAmount"), + "RCON point grants must reject zero, negative, and oversized grants"); + assertTrue(source.contains("RconUserLookup.userExists"), + "Offline RCON point grants must not create orphan currency rows for missing users"); + assertTrue(source.contains("ON DUPLICATE KEY UPDATE"), + "Offline RCON point grants should increment existing rows with an upsert"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveRespectContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveRespectContractTest.java index b36999c3..de541783 100644 --- a/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveRespectContractTest.java +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveRespectContractTest.java @@ -20,5 +20,9 @@ class GiveRespectContractTest { "respects_given must be incremented with respect_given"); assertTrue(source.contains("statement.setInt(2, object.respect_received);"), "respects_received must be incremented with respect_received"); + assertTrue(source.contains("RconGrantGuard.validateNonNegativeAmount"), + "RCON respect grants must reject negative values"); + assertTrue(source.contains("statement.executeUpdate() == 0"), + "Offline RCON respect grants must report missing users when the UPDATE changes no rows"); } } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/RconGrantGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/RconGrantGuardTest.java new file mode 100644 index 00000000..46ed1bdb --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/RconGrantGuardTest.java @@ -0,0 +1,38 @@ +package com.eu.habbo.messages.rcon; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class RconGrantGuardTest { + @Test + void validatesPositiveGrantAmounts() { + assertNull(RconGrantGuard.validatePositiveAmount(1, 100, "credits")); + assertEquals("invalid credits", RconGrantGuard.validatePositiveAmount(0, 100, "credits")); + assertEquals("invalid credits", RconGrantGuard.validatePositiveAmount(-1, 100, "credits")); + assertEquals("credits exceeds rcon grant ceiling", RconGrantGuard.validatePositiveAmount(101, 100, "credits")); + } + + @Test + void validatesNonNegativeGrantAmounts() { + assertNull(RconGrantGuard.validateNonNegativeAmount(0, 100, "respect_given")); + assertEquals("invalid respect_given", RconGrantGuard.validateNonNegativeAmount(-1, 100, "respect_given")); + assertEquals("respect_given exceeds rcon grant ceiling", RconGrantGuard.validateNonNegativeAmount(101, 100, "respect_given")); + } + + @Test + void validatesUserAndCurrencyIdentifiers() { + assertNull(RconGrantGuard.validateUserId(1)); + assertEquals("invalid user", RconGrantGuard.validateUserId(0)); + assertNull(RconGrantGuard.validateCurrencyType(0)); + assertEquals("invalid currency type", RconGrantGuard.validateCurrencyType(-1)); + } + + @Test + void parsesInvalidGrantCeilingsAsDefault() { + assertEquals(RconGrantGuard.DEFAULT_MAX_AMOUNT, RconGrantGuard.parseMaxAmount(null)); + assertEquals(RconGrantGuard.DEFAULT_MAX_AMOUNT, RconGrantGuard.parseMaxAmount("0")); + assertEquals(500, RconGrantGuard.parseMaxAmount("500")); + } +} From 4747699656653c5e18949128ca1fa03b491fe1b9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 17:31:42 +0200 Subject: [PATCH 12/19] fix(rcon): validate room ownership and clothing grants --- .../habbo/messages/rcon/ChangeRoomOwner.java | 31 ++++++++-- .../habbo/messages/rcon/GiveUserClothing.java | 59 ++++++++++++++++++- .../rcon/ChangeRoomOwnerContractTest.java | 39 ++++++++++++ .../rcon/GiveUserClothingContractTest.java | 38 ++++++++++++ 4 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/ChangeRoomOwnerContractTest.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveUserClothingContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ChangeRoomOwner.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ChangeRoomOwner.java index 262b60be..1012c54d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ChangeRoomOwner.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ChangeRoomOwner.java @@ -2,6 +2,7 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.rooms.Room; +import com.eu.habbo.habbohotel.users.HabboInfo; import com.google.gson.Gson; public class ChangeRoomOwner extends RCONMessage { @@ -11,15 +12,33 @@ public class ChangeRoomOwner extends RCONMessage { @Override public void handle(Gson gson, JSON json) { + if (json.room_id <= 0 || json.user_id <= 0) { + this.status = RCONMessage.STATUS_ERROR; + this.message = "invalid room or user"; + return; + } + + HabboInfo owner = Emulator.getGameEnvironment().getHabboManager().getHabboInfo(json.user_id); + if (owner == null) { + this.status = RCONMessage.HABBO_NOT_FOUND; + this.message = "user not found"; + return; + } + Room room = Emulator.getGameEnvironment().getRoomManager().loadRoom(json.room_id); - if (room != null) { - room.setOwnerId(json.user_id); - room.setOwnerName(json.username); - room.setNeedsUpdate(true); - room.save(); - Emulator.getGameEnvironment().getRoomManager().unloadRoom(room); + if (room == null) { + this.status = RCONMessage.ROOM_NOT_FOUND; + this.message = "room not found"; + return; } + + room.setOwnerId(owner.getId()); + room.setOwnerName(owner.getUsername()); + room.setNeedsUpdate(true); + room.save(); + Emulator.getGameEnvironment().getRoomManager().unloadRoom(room); + this.message = "updated room owner"; } static class JSON { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveUserClothing.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveUserClothing.java index 41b81669..bdd7a57e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveUserClothing.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveUserClothing.java @@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory; import java.sql.Connection; import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.sql.SQLException; public class GiveUserClothing extends RCONMessage { @@ -23,14 +24,35 @@ public class GiveUserClothing extends RCONMessage Date: Sun, 14 Jun 2026 17:37:47 +0200 Subject: [PATCH 13/19] fix(rcon): harden gift creation requests --- .../com/eu/habbo/messages/rcon/SendGift.java | 97 +++++++++++-------- .../messages/rcon/SendGiftContractTest.java | 46 +++++++++ 2 files changed, 104 insertions(+), 39 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/SendGiftContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/SendGift.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/SendGift.java index 8f644659..a8a4d25d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/SendGift.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/SendGift.java @@ -3,19 +3,14 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.items.Item; import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.users.HabboInfo; import com.eu.habbo.habbohotel.users.HabboItem; +import com.eu.habbo.habbohotel.users.HabboManager; import com.eu.habbo.messages.outgoing.inventory.InventoryRefreshComposer; import com.google.gson.Gson; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; public class SendGift extends RCONMessage { - private static final Logger LOGGER = LoggerFactory.getLogger(SendGift.class); + private static final int DEFAULT_MAX_MESSAGE_LENGTH = 300; public SendGift() { super(SendGiftJSON.class); @@ -23,13 +18,13 @@ public class SendGift extends RCONMessage { @Override public void handle(Gson gson, SendGiftJSON json) { - if (json.user_id < 0) { + if (json.user_id <= 0) { this.status = RCONMessage.STATUS_ERROR; this.message = Emulator.getTexts().getValue("commands.error.cmd_gift.user_not_found").replace("%username%", json.user_id + ""); return; } - if (json.itemid < 0) { + if (json.itemid <= 0) { this.status = RCONMessage.STATUS_ERROR; this.message = Emulator.getTexts().getValue("commands.error.cmd_gift.not_a_number"); return; @@ -42,50 +37,74 @@ public class SendGift extends RCONMessage { return; } - boolean userFound; - Habbo habbo; - - habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(json.user_id); - - userFound = habbo != null; - String username = ""; - if (!userFound) { - try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT * FROM users WHERE id = ? LIMIT 1")) { - statement.setInt(1, json.user_id); - try (ResultSet set = statement.executeQuery()) { - if (set.next()) { - username = set.getString("username"); - userFound = true; - } - } - } catch (SQLException e) { - LOGGER.error("Caught SQL exception", e); - } - } else { - username = habbo.getHabboInfo().getUsername(); + if (!baseItem.allowGift()) { + this.status = RCONMessage.STATUS_ERROR; + this.message = Emulator.getTexts().getValue("commands.error.cmd_gift.not_found").replace("%itemid%", json.itemid + ""); + return; } - if (!userFound) { - this.status = RCONMessage.STATUS_ERROR; - this.message = Emulator.getTexts().getValue("commands.error.cmd_gift.user_not_found").replace("%username%", username); + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(json.user_id); + HabboInfo habboInfo = habbo != null ? habbo.getHabboInfo() : HabboManager.getOfflineHabboInfo(json.user_id); + if (habboInfo == null) { + this.status = RCONMessage.HABBO_NOT_FOUND; + this.message = Emulator.getTexts().getValue("commands.error.cmd_gift.user_not_found").replace("%username%", json.user_id + ""); return; } HabboItem item = Emulator.getGameEnvironment().getItemManager().createItem(0, baseItem, 0, 0, ""); - Item giftItem = Emulator.getGameEnvironment().getItemManager().getItem((Integer) Emulator.getGameEnvironment().getCatalogManager().giftFurnis.values().toArray()[Emulator.getRandom().nextInt(Emulator.getGameEnvironment().getCatalogManager().giftFurnis.size())]); + Item giftItem = this.randomGiftItem(); + if (item == null || giftItem == null) { + this.status = RCONMessage.SYSTEM_ERROR; + this.message = "gift configuration unavailable"; + return; + } String extraData = "1\t" + item.getId(); - extraData += "\t0\t0\t0\t" + json.message + "\t0\t0"; + extraData += "\t0\t0\t0\t" + sanitizeGiftMessage(json.message) + "\t0\t0"; - Emulator.getGameEnvironment().getItemManager().createGift(username, giftItem, extraData, 0, 0); + if (Emulator.getGameEnvironment().getItemManager().createGift(habboInfo.getUsername(), giftItem, extraData, 0, 0) == null) { + this.status = RCONMessage.SYSTEM_ERROR; + this.message = "failed to create gift"; + return; + } - this.message = Emulator.getTexts().getValue("commands.succes.cmd_gift").replace("%username%", username).replace("%itemname%", item.getBaseItem().getName()); + this.message = Emulator.getTexts().getValue("commands.succes.cmd_gift").replace("%username%", habboInfo.getUsername()).replace("%itemname%", item.getBaseItem().getName()); if (habbo != null) { habbo.getClient().sendResponse(new InventoryRefreshComposer()); } } + private Item randomGiftItem() { + synchronized (Emulator.getGameEnvironment().getCatalogManager().giftFurnis) { + int size = Emulator.getGameEnvironment().getCatalogManager().giftFurnis.size(); + if (size == 0) { + return null; + } + + Object[] giftIds = Emulator.getGameEnvironment().getCatalogManager().giftFurnis.values().toArray(); + return Emulator.getGameEnvironment().getItemManager().getItem((Integer) giftIds[Emulator.getRandom().nextInt(size)]); + } + } + + static String sanitizeGiftMessage(String message) { + int maxLength = Emulator.getConfig().getInt("hotel.gifts.length.max", DEFAULT_MAX_MESSAGE_LENGTH); + if (maxLength <= 0) { + maxLength = DEFAULT_MAX_MESSAGE_LENGTH; + } + + if (message == null) { + return ""; + } + + String sanitized = message.replace('\t', ' ').replace('\r', ' ').replace('\n', ' '); + if (sanitized.length() > maxLength) { + return sanitized.substring(0, maxLength); + } + + return sanitized; + } + static class SendGiftJSON { public int user_id = -1; @@ -96,4 +115,4 @@ public class SendGift extends RCONMessage { public String message = ""; } -} \ No newline at end of file +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/SendGiftContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/SendGiftContractTest.java new file mode 100644 index 00000000..9c759cd9 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/SendGiftContractTest.java @@ -0,0 +1,46 @@ +package com.eu.habbo.messages.rcon; + +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 SendGiftContractTest { + private static String source() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/SendGift.java")); + } + + @Test + void validatesGiftTargetsAndItemsBeforeCreatingInventoryRows() throws Exception { + String source = source(); + + assertTrue(source.contains("json.user_id <= 0"), + "RCON gifts must reject invalid target users"); + assertTrue(source.contains("json.itemid <= 0"), + "RCON gifts must reject invalid item ids"); + assertTrue(source.contains("baseItem.allowGift()"), + "RCON gifts must respect the item giftability flag"); + assertTrue(source.contains("HabboManager.getOfflineHabboInfo(json.user_id)"), + "RCON gifts must resolve offline users through HabboManager"); + assertTrue(source.contains("HABBO_NOT_FOUND"), + "RCON gifts must report missing users with the RCON missing-user status"); + } + + @Test + void sanitizesGiftMessageAndHandlesMissingGiftConfiguration() throws Exception { + String source = source(); + + assertTrue(source.contains("sanitizeGiftMessage(json.message)"), + "RCON gift extraData must use a sanitized message"); + assertTrue(source.contains("replace('\\t', ' ').replace('\\r', ' ').replace('\\n', ' ')"), + "RCON gift messages must not inject gift extraData delimiters"); + assertTrue(source.contains("hotel.gifts.length.max"), + "RCON gift messages must respect the configured gift length limit"); + assertTrue(source.contains("giftFurnis.size()"), + "RCON gift creation must guard against empty gift wrapper configuration"); + assertTrue(source.contains("createGift(habboInfo.getUsername()"), + "RCON gifts must create the wrapper for the canonical target username"); + } +} From 5d8dc670bd62d50a786912051fe1e2a0d636b7f9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 17:41:42 +0200 Subject: [PATCH 14/19] fix(rcon): cap subscription duration changes --- .../messages/rcon/ModifyUserSubscription.java | 30 ++++++++++++--- .../rcon/ModifyUserSubscriptionGuardTest.java | 38 +++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/ModifyUserSubscriptionGuardTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ModifyUserSubscription.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ModifyUserSubscription.java index 6504725d..0d299502 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ModifyUserSubscription.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ModifyUserSubscription.java @@ -9,6 +9,7 @@ import org.slf4j.LoggerFactory; public class ModifyUserSubscription extends RCONMessage { private static final Logger LOGGER = LoggerFactory.getLogger(ModifyUserSubscription.class); + static final int DEFAULT_MAX_DURATION_SECONDS = 31_536_000; public ModifyUserSubscription() { super(ModifyUserSubscription.JSON.class); @@ -38,10 +39,11 @@ public class ModifyUserSubscription extends RCONMessage= 1 && duration <= maxDuration; + } + + static int parseMaxDuration(String configured) { + try { + int parsed = Integer.parseInt(configured); + if (parsed > 0) { + return parsed; + } + } catch (NumberFormatException ignored) { + } + + return DEFAULT_MAX_DURATION_SECONDS; + } + static class JSON { public int user_id; @@ -96,4 +114,4 @@ public class ModifyUserSubscription extends RCONMessage Date: Sun, 14 Jun 2026 17:48:01 +0200 Subject: [PATCH 15/19] fix(rcon): guard user update mutations --- .../eu/habbo/messages/rcon/UpdateUser.java | 56 ++++++++++++++++++- .../messages/rcon/UpdateUserGuardTest.java | 50 +++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/UpdateUserGuardTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/UpdateUser.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/UpdateUser.java index 7357a760..14800d18 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/UpdateUser.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/UpdateUser.java @@ -12,9 +12,13 @@ import org.slf4j.LoggerFactory; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.util.regex.Pattern; public class UpdateUser extends RCONMessage { private static final Logger LOGGER = LoggerFactory.getLogger(UpdateUser.class); + static final int DEFAULT_MAX_ACHIEVEMENT_SCORE_DELTA = 10_000; + static final int MAX_LOOK_LENGTH = 256; + private static final Pattern LOOK_PATTERN = Pattern.compile("^[A-Za-z0-9.-]{1,256}$"); public UpdateUser() { super(UpdateUser.JSON.class); @@ -23,6 +27,19 @@ public class UpdateUser extends RCONMessage { @Override public void handle(Gson gson, JSON json) { if (json.user_id > 0) { + int maxAchievementScoreDelta = parseMaxAchievementScoreDelta(Emulator.getConfig().getValue("rcon.updateuser.max_achievement_score_delta", String.valueOf(DEFAULT_MAX_ACHIEVEMENT_SCORE_DELTA))); + if (!isValidAchievementScoreDelta(json.achievement_score, maxAchievementScoreDelta)) { + this.status = RCONMessage.STATUS_ERROR; + this.message = "invalid achievement score"; + return; + } + + if (!isValidLook(json.look)) { + this.status = RCONMessage.STATUS_ERROR; + this.message = "invalid look"; + return; + } + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(json.user_id); if (habbo != null) { @@ -97,23 +114,56 @@ public class UpdateUser extends RCONMessage { index++; } statement.setInt(index, json.user_id); - statement.execute(); + if (statement.executeUpdate() == 0) { + this.status = RCONMessage.HABBO_NOT_FOUND; + this.message = "user not found"; + return; + } } if (!json.look.isEmpty()) { try (PreparedStatement statement = connection.prepareStatement("UPDATE users SET look = ? WHERE id = ? LIMIT 1")) { statement.setString(1, json.look); statement.setInt(2, json.user_id); - statement.execute(); + if (statement.executeUpdate() == 0) { + this.status = RCONMessage.HABBO_NOT_FOUND; + this.message = "user not found"; + return; + } } } } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); + this.status = RCONMessage.SYSTEM_ERROR; + this.message = "failed to update user"; } } + } else { + this.status = RCONMessage.STATUS_ERROR; + this.message = "invalid user"; } } + static boolean isValidAchievementScoreDelta(int achievementScoreDelta, int maxAchievementScoreDelta) { + return achievementScoreDelta >= 0 && achievementScoreDelta <= maxAchievementScoreDelta; + } + + static int parseMaxAchievementScoreDelta(String configured) { + try { + int parsed = Integer.parseInt(configured); + if (parsed >= 0) { + return parsed; + } + } catch (NumberFormatException ignored) { + } + + return DEFAULT_MAX_ACHIEVEMENT_SCORE_DELTA; + } + + static boolean isValidLook(String look) { + return look == null || look.isEmpty() || (look.length() <= MAX_LOOK_LENGTH && LOOK_PATTERN.matcher(look).matches()); + } + static class JSON { public int user_id; @@ -142,4 +192,4 @@ public class UpdateUser extends RCONMessage { public boolean strip_unredeemed_clothing = false; //More could be added in the future. } -} \ No newline at end of file +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/UpdateUserGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/UpdateUserGuardTest.java new file mode 100644 index 00000000..0ca4dc2a --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/UpdateUserGuardTest.java @@ -0,0 +1,50 @@ +package com.eu.habbo.messages.rcon; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class UpdateUserGuardTest { + @Test + void validatesAchievementScoreDelta() { + assertTrue(UpdateUser.isValidAchievementScoreDelta(0, 100)); + assertTrue(UpdateUser.isValidAchievementScoreDelta(100, 100)); + assertFalse(UpdateUser.isValidAchievementScoreDelta(-1, 100)); + assertFalse(UpdateUser.isValidAchievementScoreDelta(101, 100)); + } + + @Test + void parsesInvalidAchievementScoreCeilingsAsDefault() { + assertEquals(UpdateUser.DEFAULT_MAX_ACHIEVEMENT_SCORE_DELTA, UpdateUser.parseMaxAchievementScoreDelta(null)); + assertEquals(UpdateUser.DEFAULT_MAX_ACHIEVEMENT_SCORE_DELTA, UpdateUser.parseMaxAchievementScoreDelta("-1")); + assertEquals(0, UpdateUser.parseMaxAchievementScoreDelta("0")); + assertEquals(50, UpdateUser.parseMaxAchievementScoreDelta("50")); + } + + @Test + void validatesLookShapeAndLength() { + assertTrue(UpdateUser.isValidLook(null)); + assertTrue(UpdateUser.isValidLook("")); + assertTrue(UpdateUser.isValidLook("hr-115-42.hd-195-19.ch-3030-82")); + assertFalse(UpdateUser.isValidLook("hd-1\nch-1")); + assertFalse(UpdateUser.isValidLook("hd_1")); + assertFalse(UpdateUser.isValidLook("a".repeat(UpdateUser.MAX_LOOK_LENGTH + 1))); + } + + @Test + void offlineUpdatesReportMissingUsersAndUseAffectedRows() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/UpdateUser.java")); + + assertTrue(source.contains("executeUpdate() == 0"), + "Offline UpdateUser mutations must inspect affected row counts"); + assertTrue(source.contains("HABBO_NOT_FOUND"), + "Offline UpdateUser mutations must report missing users"); + assertTrue(source.contains("rcon.updateuser.max_achievement_score_delta"), + "Achievement score deltas must have a configurable RCON ceiling"); + } +} From 15b56f951944c0905d8bd1d46e4aded277038e06 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 21:13:24 +0200 Subject: [PATCH 16/19] fix(rcon): bound mute and achievement mutations --- .../src/main/java/com/eu/habbo/Emulator.java | 2 ++ .../com/eu/habbo/messages/rcon/MuteUser.java | 27 +++++++++++++-- .../messages/rcon/ProgressAchievement.java | 28 +++++++++++++-- .../messages/rcon/MuteUserGuardTest.java | 31 +++++++++++++++++ .../rcon/ProgressAchievementGuardTest.java | 34 +++++++++++++++++++ 5 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/MuteUserGuardTest.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/ProgressAchievementGuardTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 86ceca74..63abc5f5 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -157,6 +157,8 @@ public final class Emulator { Emulator.config.register("rcon.rate_limit.limit_for_period", "60"); Emulator.config.register("rcon.rate_limit.refresh_period_ms", "1000"); Emulator.config.register("rcon.rate_limit.timeout_ms", "0"); + Emulator.config.register("rcon.mute.max_duration_seconds", "604800"); + Emulator.config.register("rcon.achievement.max_progress", "10000"); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); System.out.println(); LOGGER.info("https://github.com/duckietm/Arcturus-Morningstar-Extended, "); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/MuteUser.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/MuteUser.java index 711b7d22..304172d4 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/MuteUser.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/MuteUser.java @@ -3,6 +3,8 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,6 +14,7 @@ import java.sql.SQLException; public class MuteUser extends RCONMessage { private static final Logger LOGGER = LoggerFactory.getLogger(MuteUser.class); + static final int DEFAULT_MAX_DURATION_SECONDS = 604_800; public MuteUser() { super(MuteUser.JSON.class); @@ -19,6 +22,13 @@ public class MuteUser extends RCONMessage { @Override public void handle(Gson gson, JSON json) { + int maxDuration = parseMaxDuration(Emulator.getConfig().getValue("rcon.mute.max_duration_seconds", String.valueOf(DEFAULT_MAX_DURATION_SECONDS))); + if (json.duration < 0 || json.duration > maxDuration) { + this.status = RCONMessage.STATUS_ERROR; + this.message = "duration must be between 0 and " + maxDuration + " seconds"; + return; + } + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(json.user_id); if (habbo != null) { @@ -29,7 +39,7 @@ public class MuteUser extends RCONMessage { } } else { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET mute_end_timestamp = ? WHERE user_id = ? LIMIT 1")) { - statement.setInt(1, Emulator.getIntUnixTimestamp() + json.duration); + statement.setInt(1, json.duration == 0 ? 0 : Emulator.getIntUnixTimestamp() + json.duration); statement.setInt(2, json.user_id); if (statement.executeUpdate() == 0) { this.status = HABBO_NOT_FOUND; @@ -40,11 +50,24 @@ public class MuteUser extends RCONMessage { } } + static int parseMaxDuration(String configured) { + try { + int parsed = Integer.parseInt(configured); + if (parsed >= 0) { + return parsed; + } + } catch (NumberFormatException ignored) { + } + + return DEFAULT_MAX_DURATION_SECONDS; + } + static class JSON { + @Positive(message = "invalid user") public int user_id; - + @Min(value = 0, message = "invalid duration") public int duration; } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ProgressAchievement.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ProgressAchievement.java index 69cc6d54..77160c8a 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ProgressAchievement.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ProgressAchievement.java @@ -5,8 +5,10 @@ import com.eu.habbo.habbohotel.achievements.Achievement; import com.eu.habbo.habbohotel.achievements.AchievementManager; import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; +import jakarta.validation.constraints.Positive; public class ProgressAchievement extends RCONMessage { + static final int DEFAULT_MAX_PROGRESS = 10_000; public ProgressAchievement() { super(ProgressAchievementJSON.class); @@ -14,6 +16,13 @@ public class ProgressAchievement extends RCONMessage maxProgress) { + this.status = RCONMessage.STATUS_ERROR; + this.message = "progress must be between 1 and " + maxProgress; + return; + } + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(json.user_id); if (habbo != null) { @@ -28,14 +37,27 @@ public class ProgressAchievement extends RCONMessage 0) { + return parsed; + } + } catch (NumberFormatException ignored) { + } + + return DEFAULT_MAX_PROGRESS; + } + static class ProgressAchievementJSON { + @Positive(message = "invalid user") public int user_id; - + @Positive(message = "invalid achievement") public int achievement_id; - + @Positive(message = "invalid progress") public int progress; } -} \ No newline at end of file +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/MuteUserGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/MuteUserGuardTest.java new file mode 100644 index 00000000..11cdb9bc --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/MuteUserGuardTest.java @@ -0,0 +1,31 @@ +package com.eu.habbo.messages.rcon; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MuteUserGuardTest { + @Test + void parsesInvalidDurationCeilingsAsDefault() { + assertEquals(MuteUser.DEFAULT_MAX_DURATION_SECONDS, MuteUser.parseMaxDuration(null)); + assertEquals(MuteUser.DEFAULT_MAX_DURATION_SECONDS, MuteUser.parseMaxDuration("-1")); + assertEquals(0, MuteUser.parseMaxDuration("0")); + assertEquals(60, MuteUser.parseMaxDuration("60")); + } + + @Test + void rejectsNegativeAndOversizedMuteDurations() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/MuteUser.java")); + + assertTrue(source.contains("json.duration < 0 || json.duration > maxDuration"), + "RCON mute must reject negative durations and configured-duration overflows"); + assertTrue(source.contains("json.duration == 0 ? 0 : Emulator.getIntUnixTimestamp() + json.duration"), + "Offline unmute must clear mute_end_timestamp instead of writing the current timestamp"); + assertTrue(source.contains("rcon.mute.max_duration_seconds"), + "RCON mute duration ceiling must be configurable"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/ProgressAchievementGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/ProgressAchievementGuardTest.java new file mode 100644 index 00000000..6d26b7e8 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/ProgressAchievementGuardTest.java @@ -0,0 +1,34 @@ +package com.eu.habbo.messages.rcon; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProgressAchievementGuardTest { + @Test + void parsesInvalidProgressCeilingsAsDefault() { + assertEquals(ProgressAchievement.DEFAULT_MAX_PROGRESS, ProgressAchievement.parseMaxProgress(null)); + assertEquals(ProgressAchievement.DEFAULT_MAX_PROGRESS, ProgressAchievement.parseMaxProgress("0")); + assertEquals(50, ProgressAchievement.parseMaxProgress("50")); + } + + @Test + void validatesAchievementProgressPayload() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/ProgressAchievement.java")); + + assertTrue(source.contains("@Positive(message = \"invalid user\")"), + "RCON achievement progress must reject invalid target users before execution"); + assertTrue(source.contains("@Positive(message = \"invalid achievement\")"), + "RCON achievement progress must reject invalid achievement ids before execution"); + assertTrue(source.contains("@Positive(message = \"invalid progress\")"), + "RCON achievement progress must reject zero or negative progress before execution"); + assertTrue(source.contains("json.progress > maxProgress"), + "RCON achievement progress must reject configured-progress overflows"); + assertTrue(source.contains("rcon.achievement.max_progress"), + "RCON achievement progress ceiling must be configurable"); + } +} From 25273679a17e00820eb822fa5436dc5892fe9348 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 21:18:28 +0200 Subject: [PATCH 17/19] fix(rcon): constrain remote command execution --- .../src/main/java/com/eu/habbo/Emulator.java | 3 + .../habbo/messages/rcon/ExecuteCommand.java | 127 +++++++++++++++++- .../rcon/ExecuteCommandGuardTest.java | 53 ++++++++ 3 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/ExecuteCommandGuardTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 63abc5f5..3c3d93b2 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -159,6 +159,9 @@ public final class Emulator { Emulator.config.register("rcon.rate_limit.timeout_ms", "0"); Emulator.config.register("rcon.mute.max_duration_seconds", "604800"); Emulator.config.register("rcon.achievement.max_progress", "10000"); + Emulator.config.register("rcon.execute_command.max_length", "256"); + Emulator.config.register("rcon.execute_command.denied_permissions", "cmd_shutdown;cmd_update_config;cmd_update_permissions;cmd_give_rank;cmd_badge;cmd_gift;cmd_credits;cmd_points;cmd_pixels;cmd_massbadge;cmd_masscredits;cmd_massgift;cmd_massduckets;cmd_masspoints;cmd_empty;cmd_empty_bots;cmd_empty_pets;cmd_unload;cmd_ban;cmd_superban;cmd_ip_ban;cmd_machine_ban;cmd_disconnect"); + Emulator.config.register("rcon.execute_command.allowed_permissions", ""); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); System.out.println(); LOGGER.info("https://github.com/duckietm/Arcturus-Morningstar-Extended, "); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ExecuteCommand.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ExecuteCommand.java index fd16a35c..d728a86b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ExecuteCommand.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ExecuteCommand.java @@ -1,14 +1,48 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.commands.Command; import com.eu.habbo.habbohotel.commands.CommandHandler; import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + public class ExecuteCommand extends RCONMessage { private static final Logger LOGGER = LoggerFactory.getLogger(ExecuteCommand.class); + static final int DEFAULT_MAX_COMMAND_LENGTH = 256; + private static final String DEFAULT_DENIED_PERMISSIONS = String.join(";", + "cmd_shutdown", + "cmd_update_config", + "cmd_update_permissions", + "cmd_give_rank", + "cmd_badge", + "cmd_gift", + "cmd_credits", + "cmd_points", + "cmd_pixels", + "cmd_massbadge", + "cmd_masscredits", + "cmd_massgift", + "cmd_massduckets", + "cmd_masspoints", + "cmd_empty", + "cmd_empty_bots", + "cmd_empty_pets", + "cmd_unload", + "cmd_ban", + "cmd_superban", + "cmd_ip_ban", + "cmd_machine_ban", + "cmd_disconnect"); public ExecuteCommand() { @@ -18,6 +52,33 @@ public class ExecuteCommand extends RCONMessage maxLength) { + this.status = STATUS_ERROR; + this.message = "invalid command"; + return; + } + + String commandKey = commandKey(commandLine); + if (commandKey.isEmpty()) { + this.status = STATUS_ERROR; + this.message = "invalid command"; + return; + } + + Command command = CommandHandler.getCommand(commandKey); + String commandPermission = command != null && command.permission != null ? command.permission : commandKey; + + if (!isAllowed(commandPermission, + Emulator.getConfig().getValue("rcon.execute_command.denied_permissions", DEFAULT_DENIED_PERMISSIONS), + Emulator.getConfig().getValue("rcon.execute_command.allowed_permissions", ""))) { + this.status = STATUS_ERROR; + this.message = "command not allowed"; + return; + } + Habbo habbo = Emulator.getGameServer().getGameClientManager().getHabbo(json.user_id); if (habbo == null) { @@ -26,18 +87,78 @@ public class ExecuteCommand extends RCONMessage allowed = permissionSet(allowedPermissions); + if (!allowed.isEmpty()) { + return allowed.contains(normalized); + } + + return !permissionSet(deniedPermissions).contains(normalized); + } + + static String commandKey(String commandLine) { + if (commandLine == null) { + return ""; + } + + String trimmed = commandLine.trim(); + if (!trimmed.startsWith(":")) { + return ""; + } + + String withoutPrefix = trimmed.substring(1).trim(); + if (withoutPrefix.isEmpty()) { + return ""; + } + + return withoutPrefix.split("\\s+", 2)[0].toLowerCase(Locale.ROOT); + } + + static int parseMaxCommandLength(String configured) { + try { + int parsed = Integer.parseInt(configured); + if (parsed > 0) { + return parsed; + } + } catch (NumberFormatException ignored) { + } + + return DEFAULT_MAX_COMMAND_LENGTH; + } + + private static Set permissionSet(String permissions) { + if (permissions == null || permissions.isBlank()) { + return Set.of(); + } + + return Arrays.stream(permissions.split("[;,]")) + .map(ExecuteCommand::normalize) + .filter(value -> !value.isEmpty()) + .collect(Collectors.toUnmodifiableSet()); + } + + private static String normalize(String permission) { + return permission == null ? "" : permission.trim().toLowerCase(Locale.ROOT); + } + static class JSONExecuteCommand { + @Positive(message = "invalid user") public int user_id; - + @NotBlank(message = "invalid command") + @Size(max = 512, message = "invalid command") public String command; } -} \ No newline at end of file +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/ExecuteCommandGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/ExecuteCommandGuardTest.java new file mode 100644 index 00000000..f46863f0 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/ExecuteCommandGuardTest.java @@ -0,0 +1,53 @@ +package com.eu.habbo.messages.rcon; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExecuteCommandGuardTest { + @Test + void extractsCommandKeyOnlyFromColonCommands() { + assertEquals("dance", ExecuteCommand.commandKey(":dance")); + assertEquals("dance", ExecuteCommand.commandKey(" :DaNcE 1 2 ")); + assertEquals("", ExecuteCommand.commandKey("dance")); + assertEquals("", ExecuteCommand.commandKey(": ")); + } + + @Test + void deniedPermissionsBlockDangerousDefaultsUnlessExplicitlyAllowed() { + assertFalse(ExecuteCommand.isAllowed("cmd_shutdown", "cmd_shutdown;cmd_give_rank", "")); + assertFalse(ExecuteCommand.isAllowed("CMD_GIVE_RANK", "cmd_shutdown,cmd_give_rank", "")); + assertTrue(ExecuteCommand.isAllowed("cmd_dance", "cmd_shutdown;cmd_give_rank", "")); + assertTrue(ExecuteCommand.isAllowed("cmd_shutdown", "cmd_shutdown", "cmd_shutdown")); + assertFalse(ExecuteCommand.isAllowed("cmd_dance", "cmd_shutdown", "cmd_about")); + } + + @Test + void parsesInvalidCommandLengthAsDefault() { + assertEquals(ExecuteCommand.DEFAULT_MAX_COMMAND_LENGTH, ExecuteCommand.parseMaxCommandLength(null)); + assertEquals(ExecuteCommand.DEFAULT_MAX_COMMAND_LENGTH, ExecuteCommand.parseMaxCommandLength("0")); + assertEquals(64, ExecuteCommand.parseMaxCommandLength("64")); + } + + @Test + void executeCommandHasConfigurableGuardRails() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/ExecuteCommand.java")); + String emulator = Files.readString(Path.of("src/main/java/com/eu/habbo/Emulator.java")); + + assertTrue(source.contains("CommandHandler.getCommand(commandKey)"), + "RCON executecommand must resolve aliases to the registered command permission"); + assertTrue(source.contains("rcon.execute_command.denied_permissions"), + "RCON executecommand must support a configurable denied-permission list"); + assertTrue(source.contains("rcon.execute_command.allowed_permissions"), + "RCON executecommand must support a stricter configurable allowlist"); + assertTrue(source.contains("!commandLine.startsWith(\":\") || commandLine.length() > maxLength"), + "RCON executecommand must reject non-command payloads and oversized command lines"); + assertTrue(emulator.contains("rcon.execute_command.denied_permissions"), + "RCON executecommand guard defaults must be registered before the RCON server starts"); + } +} From 11554eae7b12507c10c9387af65f844d157572f7 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 21:23:21 +0200 Subject: [PATCH 18/19] fix(rcon): validate social and room commands --- .../com/eu/habbo/messages/rcon/AlertUser.java | 7 ++ .../eu/habbo/messages/rcon/ForwardUser.java | 5 ++ .../eu/habbo/messages/rcon/FriendRequest.java | 17 ++++- .../eu/habbo/messages/rcon/IgnoreUser.java | 19 +++++- .../com/eu/habbo/messages/rcon/StalkUser.java | 6 ++ .../com/eu/habbo/messages/rcon/TalkUser.java | 8 +++ .../rcon/SocialRoomCommandGuardTest.java | 66 +++++++++++++++++++ 7 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/SocialRoomCommandGuardTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/AlertUser.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/AlertUser.java index b5a23427..03ecfb28 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/AlertUser.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/AlertUser.java @@ -3,6 +3,9 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; public class AlertUser extends RCONMessage { @@ -16,6 +19,7 @@ public class AlertUser extends RCONMessage { if (habbo != null) { habbo.alert(object.message); + return; } this.status = RCONMessage.HABBO_NOT_FOUND; @@ -23,9 +27,12 @@ public class AlertUser extends RCONMessage { static class JSONAlertUser { + @Positive(message = "invalid user") int user_id; + @NotBlank(message = "invalid message") + @Size(max = 4096, message = "invalid message") String message; } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ForwardUser.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ForwardUser.java index b6f18b6a..db3c98a4 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ForwardUser.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ForwardUser.java @@ -5,6 +5,7 @@ import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.messages.outgoing.rooms.ForwardToRoomComposer; import com.google.gson.Gson; +import jakarta.validation.constraints.Positive; public class ForwardUser extends RCONMessage { @@ -26,8 +27,10 @@ public class ForwardUser extends RCONMessage { habbo.getClient().sendResponse(new ForwardToRoomComposer(object.room_id)); Emulator.getGameEnvironment().getRoomManager().enterRoom(habbo, object.room_id, "", true); + return; } else { this.status = RCONMessage.ROOM_NOT_FOUND; + return; } } @@ -36,9 +39,11 @@ public class ForwardUser extends RCONMessage { static class ForwardUserJSON { + @Positive(message = "invalid user") public int user_id; + @Positive(message = "invalid room") public int room_id; } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/FriendRequest.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/FriendRequest.java index 59d0bf3b..d70dd794 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/FriendRequest.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/FriendRequest.java @@ -10,6 +10,7 @@ import com.eu.habbo.messages.outgoing.MessageComposer; import com.eu.habbo.messages.outgoing.Outgoing; import com.eu.habbo.messages.outgoing.friends.FriendRequestComposer; import com.google.gson.Gson; +import jakarta.validation.constraints.Positive; public class FriendRequest extends RCONMessage { public FriendRequest() { @@ -18,6 +19,18 @@ public class FriendRequest extends RCONMessage { @Override public void handle(Gson gson, JSON json) { + if (json.user_id == json.target_id) { + this.status = RCONMessage.STATUS_ERROR; + this.message = "cannot friend self"; + return; + } + + if (!RconUserLookup.userExists(json.user_id) || !RconUserLookup.userExists(json.target_id)) { + this.status = RCONMessage.HABBO_NOT_FOUND; + this.message = "user not found"; + return; + } + if (!Messenger.friendRequested(json.user_id, json.target_id)) { Messenger.makeFriendRequest(json.user_id, json.target_id); @@ -49,9 +62,11 @@ public class FriendRequest extends RCONMessage { static class JSON { + @Positive(message = "invalid user") public int user_id; + @Positive(message = "invalid target") public int target_id; } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/IgnoreUser.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/IgnoreUser.java index 852a1764..7fc4ff56 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/IgnoreUser.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/IgnoreUser.java @@ -3,6 +3,7 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; +import jakarta.validation.constraints.Positive; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,13 +20,25 @@ public class IgnoreUser extends RCONMessage { @Override public void handle(Gson gson, JSONIgnoreUser object) { + if (object.user_id == object.target_id) { + this.status = RCONMessage.STATUS_ERROR; + this.message = "cannot ignore self"; + return; + } + + if (!RconUserLookup.userExists(object.user_id) || !RconUserLookup.userExists(object.target_id)) { + this.status = RCONMessage.HABBO_NOT_FOUND; + this.message = "user not found"; + return; + } + Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(object.user_id); if (habbo != null) { habbo.getHabboStats().ignoreUser(habbo.getClient(), object.target_id); } else { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); - PreparedStatement statement = connection.prepareStatement("INSERT INTO users_ignored (user_id, target_id) VALUES (?, ?)")) { + PreparedStatement statement = connection.prepareStatement("INSERT IGNORE INTO users_ignored (user_id, target_id) VALUES (?, ?)")) { statement.setInt(1, object.user_id); statement.setInt(2, object.target_id); statement.execute(); @@ -39,8 +52,10 @@ public class IgnoreUser extends RCONMessage { static class JSONIgnoreUser { + @Positive(message = "invalid user") public int user_id; + @Positive(message = "invalid target") public int target_id; } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/StalkUser.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/StalkUser.java index f72e5036..eed597db 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/StalkUser.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/StalkUser.java @@ -4,6 +4,7 @@ import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.messages.outgoing.rooms.ForwardToRoomComposer; import com.google.gson.Gson; +import jakarta.validation.constraints.Positive; public class StalkUser extends RCONMessage { public StalkUser() { @@ -44,14 +45,19 @@ public class StalkUser extends RCONMessage { if (this.status == 0) { habbo.getClient().sendResponse(new ForwardToRoomComposer(target.getHabboInfo().getCurrentRoom().getId())); } + } else { + this.status = HABBO_NOT_FOUND; + this.message = "offline"; } } static class StalkUserJSON { + @Positive(message = "invalid user") public int user_id; + @Positive(message = "invalid target") public int follow_id; } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/TalkUser.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/TalkUser.java index 1f190cca..0c09d0a9 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/TalkUser.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/TalkUser.java @@ -4,6 +4,9 @@ import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.rooms.RoomChatMessageBubbles; import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; public class TalkUser extends RCONMessage { public TalkUser() { @@ -38,15 +41,20 @@ public class TalkUser extends RCONMessage { static class JSON { + @NotBlank(message = "invalid type") + @Size(max = 16, message = "invalid type") public String type; + @Positive(message = "invalid user") public int user_id; public int bubble_id = -1; + @NotBlank(message = "invalid message") + @Size(max = 512, message = "invalid message") public String message; } } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/SocialRoomCommandGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/SocialRoomCommandGuardTest.java new file mode 100644 index 00000000..1a743a10 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/SocialRoomCommandGuardTest.java @@ -0,0 +1,66 @@ +package com.eu.habbo.messages.rcon; + +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 SocialRoomCommandGuardTest { + @Test + void forwardUserDoesNotOverwriteSuccessfulStatusWithNotFound() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/ForwardUser.java")); + + assertTrue(source.contains("enterRoom(habbo, object.room_id, \"\", true);") && source.contains("return;"), + "ForwardUser must return after a successful room forward instead of falling through to HABBO_NOT_FOUND"); + assertTrue(source.contains("@Positive(message = \"invalid user\")"), + "ForwardUser must reject invalid user ids before execution"); + assertTrue(source.contains("@Positive(message = \"invalid room\")"), + "ForwardUser must reject invalid room ids before execution"); + } + + @Test + void alertUserOnlyReportsNotFoundWhenTargetIsMissing() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/AlertUser.java")); + + assertTrue(source.contains("habbo.alert(object.message);") && source.contains("return;"), + "AlertUser must return after delivering the alert"); + assertTrue(source.contains("@NotBlank(message = \"invalid message\")"), + "AlertUser must reject blank alerts before execution"); + assertTrue(source.contains("@Size(max = 4096"), + "AlertUser must bound alert payload size"); + } + + @Test + void friendAndIgnoreRequestsValidateBothUsers() throws Exception { + String friend = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/FriendRequest.java")); + String ignore = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/IgnoreUser.java")); + + assertTrue(friend.contains("json.user_id == json.target_id"), + "FriendRequest must reject self-friend requests"); + assertTrue(friend.contains("RconUserLookup.userExists(json.user_id)") && friend.contains("RconUserLookup.userExists(json.target_id)"), + "FriendRequest must reject missing source or target users"); + assertTrue(ignore.contains("object.user_id == object.target_id"), + "IgnoreUser must reject self-ignore requests"); + assertTrue(ignore.contains("RconUserLookup.userExists(object.user_id)") && ignore.contains("RconUserLookup.userExists(object.target_id)"), + "IgnoreUser must reject missing source or target users"); + assertTrue(ignore.contains("INSERT IGNORE INTO users_ignored"), + "IgnoreUser offline writes must avoid duplicate rows"); + } + + @Test + void talkAndStalkPayloadsAreValidated() throws Exception { + String talk = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/TalkUser.java")); + String stalk = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/StalkUser.java")); + + assertTrue(talk.contains("@NotBlank(message = \"invalid type\")"), + "TalkUser must reject blank talk types before execution"); + assertTrue(talk.contains("@Size(max = 512"), + "TalkUser must bound impersonated chat message size"); + assertTrue(stalk.contains("@Positive(message = \"invalid target\")"), + "StalkUser must reject invalid target ids before execution"); + assertTrue(stalk.contains("this.status = HABBO_NOT_FOUND"), + "StalkUser must report missing source users"); + } +} From aa6dcd1062ffd8ac8d497ea1b243c2136419cd3d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 21:40:59 +0200 Subject: [PATCH 19/19] fix(rcon): bound alert payloads --- .../eu/habbo/messages/rcon/HotelAlert.java | 7 +++ .../habbo/messages/rcon/ImageAlertUser.java | 18 ++++++- .../habbo/messages/rcon/ImageHotelAlert.java | 16 +++++- .../messages/rcon/AlertPayloadGuardTest.java | 50 +++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/rcon/AlertPayloadGuardTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/HotelAlert.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/HotelAlert.java index 70989239..3713d9e0 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/HotelAlert.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/HotelAlert.java @@ -6,6 +6,9 @@ import com.eu.habbo.messages.ServerMessage; import com.eu.habbo.messages.outgoing.generic.alerts.GenericAlertComposer; import com.eu.habbo.messages.outgoing.generic.alerts.StaffAlertWithLinkComposer; import com.google.gson.Gson; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import java.util.Map; @@ -37,9 +40,13 @@ public class HotelAlert extends RCONMessage { static class JSONHotelAlert { + @NotBlank(message = "invalid message") + @Size(max = 4096, message = "invalid message") public String message; + @Size(max = 2048, message = "invalid url") + @Pattern(regexp = "^$|https?://.+", message = "invalid url") public String url = ""; } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ImageAlertUser.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ImageAlertUser.java index 12afed25..61fe3424 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ImageAlertUser.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ImageAlertUser.java @@ -5,6 +5,10 @@ import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer; import com.google.gson.Gson; import gnu.trove.map.hash.THashMap; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; public class ImageAlertUser extends RCONMessage { public ImageAlertUser() { @@ -51,27 +55,39 @@ public class ImageAlertUser extends RCONMessage { static class JSON { + @Positive(message = "invalid user") public int user_id; + @NotBlank(message = "invalid bubble") + @Size(max = 64, message = "invalid bubble") + @Pattern(regexp = "[A-Za-z0-9_.-]+", message = "invalid bubble") public String bubble_key = ""; + @Size(max = 4096, message = "invalid message") public String message = ""; + @Size(max = 2048, message = "invalid url") + @Pattern(regexp = "^$|https?://.+", message = "invalid url") public String url = ""; + @Size(max = 256, message = "invalid url title") public String url_message = ""; + @Size(max = 256, message = "invalid title") public String title = ""; + @Size(max = 32, message = "invalid display") + @Pattern(regexp = "^$|[A-Za-z0-9_.-]+", message = "invalid display") public String display_type = ""; + @Size(max = 2048, message = "invalid image") public String image = ""; } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ImageHotelAlert.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ImageHotelAlert.java index d392a6b2..742b8d19 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ImageHotelAlert.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ImageHotelAlert.java @@ -6,6 +6,9 @@ import com.eu.habbo.messages.ServerMessage; import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer; import com.google.gson.Gson; import gnu.trove.map.hash.THashMap; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import java.util.Map; @@ -55,24 +58,35 @@ public class ImageHotelAlert extends RCONMessage { static class JSON { + @NotBlank(message = "invalid bubble") + @Size(max = 64, message = "invalid bubble") + @Pattern(regexp = "[A-Za-z0-9_.-]+", message = "invalid bubble") public String bubble_key = ""; + @Size(max = 4096, message = "invalid message") public String message = ""; + @Size(max = 2048, message = "invalid url") + @Pattern(regexp = "^$|https?://.+", message = "invalid url") public String url = ""; + @Size(max = 256, message = "invalid url title") public String url_message = ""; + @Size(max = 256, message = "invalid title") public String title = ""; + @Size(max = 32, message = "invalid display") + @Pattern(regexp = "^$|[A-Za-z0-9_.-]+", message = "invalid display") public String display_type = ""; + @Size(max = 2048, message = "invalid image") public String image = ""; } -} \ No newline at end of file +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/AlertPayloadGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/AlertPayloadGuardTest.java new file mode 100644 index 00000000..c885bff6 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/AlertPayloadGuardTest.java @@ -0,0 +1,50 @@ +package com.eu.habbo.messages.rcon; + +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 AlertPayloadGuardTest { + @Test + void hotelAlertPayloadIsBoundedAndUrlValidated() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/HotelAlert.java")); + + assertTrue(source.contains("@NotBlank(message = \"invalid message\")"), + "HotelAlert must reject blank global alerts"); + assertTrue(source.contains("@Size(max = 4096"), + "HotelAlert must bound global alert text"); + assertTrue(source.contains("@Pattern(regexp = \"^$|https?://.+\""), + "HotelAlert must reject non-http alert links"); + } + + @Test + void imageHotelAlertPayloadIsBounded() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/ImageHotelAlert.java")); + + assertTrue(source.contains("@NotBlank(message = \"invalid bubble\")"), + "ImageHotelAlert must require a bubble key"); + assertTrue(source.contains("@Pattern(regexp = \"[A-Za-z0-9_.-]+\""), + "ImageHotelAlert bubble keys must be constrained to safe token characters"); + assertTrue(source.contains("@Size(max = 2048"), + "ImageHotelAlert URL/image fields must be bounded"); + assertTrue(source.contains("@Pattern(regexp = \"^$|https?://.+\""), + "ImageHotelAlert must reject non-http links"); + } + + @Test + void imageUserAlertPayloadIsBoundedAndTargetsValidUsers() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/ImageAlertUser.java")); + + assertTrue(source.contains("@Positive(message = \"invalid user\")"), + "ImageAlertUser must reject invalid target users before execution"); + assertTrue(source.contains("@NotBlank(message = \"invalid bubble\")"), + "ImageAlertUser must require a bubble key"); + assertTrue(source.contains("@Size(max = 4096"), + "ImageAlertUser must bound alert text"); + assertTrue(source.contains("@Pattern(regexp = \"^$|https?://.+\""), + "ImageAlertUser must reject non-http links"); + } +}