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 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"); + } +}