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
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/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/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/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/GiveBadge.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveBadge.java
index 4cb93feb..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,8 +3,14 @@ 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;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.Size;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -53,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()){
@@ -70,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();
}
@@ -89,9 +102,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..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
@@ -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;
@@ -20,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) {
@@ -28,10 +41,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";
@@ -40,9 +57,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..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
@@ -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;
@@ -20,15 +21,34 @@ 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 {
- 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();
+ 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);
+ statement.setInt(3, object.pixels);
+ statement.executeUpdate();
} catch (SQLException e) {
this.status = RCONMessage.SYSTEM_ERROR;
LOGGER.error("Caught SQL exception", e);
@@ -40,9 +60,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..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
@@ -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;
@@ -20,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);
@@ -42,12 +65,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/GiveRespect.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/GiveRespect.java
index 05c57de7..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_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();
+ 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";
@@ -50,4 +82,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/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 {
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/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/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/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 {
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/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/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/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/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/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/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/main/java/com/eu/habbo/messages/rcon/SetRank.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/SetRank.java
index 639f9674..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
@@ -3,6 +3,11 @@ 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 java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
public class SetRank extends RCONMessage {
@@ -12,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) {
@@ -29,11 +53,30 @@ 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")
public int user_id;
+ @Positive(message = "invalid rank")
public int rank;
}
}
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/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/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/main/java/com/eu/habbo/networking/rconserver/RCONServer.java b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java
index 9eaeeef4..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
@@ -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 extends RCONMessage> message = this.messages.get(key.replace("_", "").toLowerCase());
String result;
@@ -96,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);
@@ -118,4 +144,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/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/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");
+ }
+}
diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/ChangeRoomOwnerContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/ChangeRoomOwnerContractTest.java
new file mode 100644
index 00000000..dfe446ff
--- /dev/null
+++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/ChangeRoomOwnerContractTest.java
@@ -0,0 +1,39 @@
+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 ChangeRoomOwnerContractTest {
+ private static String source() throws Exception {
+ return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/ChangeRoomOwner.java"));
+ }
+
+ @Test
+ void validatesRoomAndUserBeforeChangingOwnership() throws Exception {
+ String source = source();
+
+ assertTrue(source.contains("json.room_id <= 0 || json.user_id <= 0"),
+ "Room owner changes must reject invalid identifiers");
+ assertTrue(source.contains("getHabboInfo(json.user_id)"),
+ "Room owner changes must resolve the canonical user from storage");
+ assertTrue(source.contains("HABBO_NOT_FOUND"),
+ "Room owner changes must report missing target users");
+ assertTrue(source.contains("ROOM_NOT_FOUND"),
+ "Room owner changes must report missing rooms");
+ }
+
+ @Test
+ void doesNotTrustClientSuppliedOwnerNames() throws Exception {
+ String source = source();
+
+ assertTrue(source.contains("setOwnerName(owner.getUsername())"),
+ "Room owner changes must use the stored username");
+ assertFalse(source.contains("setOwnerName(json.username)"),
+ "Room owner changes must not trust JSON usernames");
+ }
+}
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");
+ }
+}
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");
+ }
+}
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..24f7caba
--- /dev/null
+++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveCreditsContractTest.java
@@ -0,0 +1,26 @@
+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");
+ 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
new file mode 100644
index 00000000..fc06f148
--- /dev/null
+++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GivePixelsContractTest.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 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");
+ 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
new file mode 100644
index 00000000..de541783
--- /dev/null
+++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveRespectContractTest.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 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");
+ 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/GiveUserClothingContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveUserClothingContractTest.java
new file mode 100644
index 00000000..a431bbc7
--- /dev/null
+++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/GiveUserClothingContractTest.java
@@ -0,0 +1,38 @@
+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 GiveUserClothingContractTest {
+ private static String source() throws Exception {
+ return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/GiveUserClothing.java"));
+ }
+
+ @Test
+ void validatesUsersAndClothingBeforeWritingInventoryRows() throws Exception {
+ String source = source();
+
+ assertTrue(source.contains("object.user_id <= 0 || object.clothing_id <= 0"),
+ "Clothing grants must reject invalid identifiers");
+ assertTrue(source.contains("userExists(object.user_id)"),
+ "Clothing grants must reject missing users before inserting rows");
+ assertTrue(source.contains("clothingExists(object.clothing_id)"),
+ "Clothing grants must reject catalog clothing ids that do not exist");
+ assertTrue(source.contains("catalog_clothing"),
+ "Clothing grants must validate against catalog_clothing");
+ }
+
+ @Test
+ void handlesDuplicateGrantsWithoutSurfacingSqlErrors() throws Exception {
+ String source = source();
+
+ assertTrue(source.contains("INSERT IGNORE INTO users_clothing"),
+ "Duplicate clothing grants should be idempotent");
+ assertTrue(source.contains("SYSTEM_ERROR"),
+ "Unexpected SQL failures must be surfaced to the RCON caller");
+ }
+}
diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/ModifyUserSubscriptionGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/ModifyUserSubscriptionGuardTest.java
new file mode 100644
index 00000000..f35074de
--- /dev/null
+++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/ModifyUserSubscriptionGuardTest.java
@@ -0,0 +1,38 @@
+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 ModifyUserSubscriptionGuardTest {
+ @Test
+ void validatesDurationAgainstConfiguredCeiling() {
+ assertTrue(ModifyUserSubscription.isValidDuration(1, 10));
+ assertTrue(ModifyUserSubscription.isValidDuration(10, 10));
+ assertFalse(ModifyUserSubscription.isValidDuration(0, 10));
+ assertFalse(ModifyUserSubscription.isValidDuration(-1, 10));
+ assertFalse(ModifyUserSubscription.isValidDuration(11, 10));
+ }
+
+ @Test
+ void parsesInvalidDurationCeilingsAsDefault() {
+ assertEquals(ModifyUserSubscription.DEFAULT_MAX_DURATION_SECONDS, ModifyUserSubscription.parseMaxDuration(null));
+ assertEquals(ModifyUserSubscription.DEFAULT_MAX_DURATION_SECONDS, ModifyUserSubscription.parseMaxDuration("0"));
+ assertEquals(60, ModifyUserSubscription.parseMaxDuration("60"));
+ }
+
+ @Test
+ void clampsPartialRemovalToRemainingSubscriptionTime() throws Exception {
+ String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/ModifyUserSubscription.java"));
+
+ assertTrue(source.contains("Math.min(json.duration, s.getRemaining())"),
+ "Partial subscription removal must not drive duration below the remaining time");
+ assertTrue(source.contains("rcon.subscription.max_duration_seconds"),
+ "RCON subscription duration ceiling must be configurable");
+ }
+}
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");
+ }
+}
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"));
+ }
+}
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));
+ }
+}
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");
+ }
+}
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");
+ }
+}
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"));
+ }
+}
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");
+ }
+}
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");
+ }
+}
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..893d556b
--- /dev/null
+++ b/Emulator/src/test/java/com/eu/habbo/networking/rconserver/RCONServerHandlerContractTest.java
@@ -0,0 +1,62 @@
+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 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"));
+ }
+
+ @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");
+ }
+
+ @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");
+ }
+}