Merge pull request #198 from simoleo89/chore/deps-resilience-validation

fix(rcon): harden privileged commands and payloads
This commit is contained in:
DuckieTM
2026-06-15 07:24:12 +02:00
committed by GitHub
52 changed files with 1708 additions and 107 deletions
+31
View File
@@ -117,6 +117,37 @@
<scope>compile</scope>
</dependency>
<!-- Caffeine cache - high-performance local caching for hot emulator lookups -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.2.4</version>
<scope>compile</scope>
</dependency>
<!-- Resilience4j - rate limits and circuit breakers for RCON/HTTP/external integrations -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-ratelimiter</artifactId>
<version>2.4.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-circuitbreaker</artifactId>
<version>2.4.0</version>
<scope>compile</scope>
</dependency>
<!-- Hibernate Validator - Jakarta Bean Validation for packet/RCON/admin DTO guards -->
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>9.1.0.Final</version>
<scope>compile</scope>
</dependency>
<!-- Apache Commons Lang -->
<dependency>
<groupId>org.apache.commons</groupId>
@@ -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<AlertUser.JSONAlertUser> {
@@ -16,6 +19,7 @@ public class AlertUser extends RCONMessage<AlertUser.JSONAlertUser> {
if (habbo != null) {
habbo.alert(object.message);
return;
}
this.status = RCONMessage.HABBO_NOT_FOUND;
@@ -23,9 +27,12 @@ public class AlertUser extends RCONMessage<AlertUser.JSONAlertUser> {
static class JSONAlertUser {
@Positive(message = "invalid user")
int user_id;
@NotBlank(message = "invalid message")
@Size(max = 4096, message = "invalid message")
String message;
}
}
@@ -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<ChangeRoomOwner.JSON> {
@@ -11,15 +12,33 @@ public class ChangeRoomOwner extends RCONMessage<ChangeRoomOwner.JSON> {
@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 {
@@ -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<ExecuteCommand.JSONExecuteCommand> {
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<ExecuteCommand.JSONExecuteComman
@Override
public void handle(Gson gson, JSONExecuteCommand json) {
try {
String commandLine = json.command.trim();
int maxLength = parseMaxCommandLength(Emulator.getConfig().getValue("rcon.execute_command.max_length", String.valueOf(DEFAULT_MAX_COMMAND_LENGTH)));
if (!commandLine.startsWith(":") || commandLine.length() > 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<ExecuteCommand.JSONExecuteComman
}
CommandHandler.handleCommand(habbo.getClient(), json.command);
if (!CommandHandler.handleCommand(habbo.getClient(), commandLine)) {
this.status = STATUS_ERROR;
this.message = "command failed";
}
} catch (Exception e) {
this.status = STATUS_ERROR;
LOGGER.error("Caught exception", e);
}
}
static boolean isAllowed(String commandPermission, String deniedPermissions, String allowedPermissions) {
String normalized = normalize(commandPermission);
Set<String> 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<String> 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;
}
}
}
@@ -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<ForwardUser.ForwardUserJSON> {
@@ -26,8 +27,10 @@ public class ForwardUser extends RCONMessage<ForwardUser.ForwardUserJSON> {
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<ForwardUser.ForwardUserJSON> {
static class ForwardUserJSON {
@Positive(message = "invalid user")
public int user_id;
@Positive(message = "invalid room")
public int room_id;
}
}
@@ -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<FriendRequest.JSON> {
public FriendRequest() {
@@ -18,6 +19,18 @@ public class FriendRequest extends RCONMessage<FriendRequest.JSON> {
@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<FriendRequest.JSON> {
static class JSON {
@Positive(message = "invalid user")
public int user_id;
@Positive(message = "invalid target")
public int target_id;
}
}
}
@@ -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<GiveBadge.GiveBadgeJSON> {
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<GiveBadge.GiveBadgeJSON> {
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<GiveBadge.GiveBadgeJSON> {
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;
}
}
@@ -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<GiveCredits.JSONGiveCredits> {
@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<GiveCredits.JSONGiveCredits> {
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<GiveCredits.JSONGiveCredits> {
static class JSONGiveCredits {
@Positive(message = "invalid user")
public int user_id;
@Positive(message = "invalid credits")
public int credits;
}
}
@@ -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<GivePixels.JSONGivePixels> {
@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<GivePixels.JSONGivePixels> {
static class JSONGivePixels {
@Positive(message = "invalid user")
public int user_id;
@Positive(message = "invalid pixels")
public int pixels;
}
}
@@ -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<GivePoints.JSONGivePoints> {
@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<GivePoints.JSONGivePoints> {
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;
}
}
@@ -21,6 +21,27 @@ public class GiveRespect extends RCONMessage<GiveRespect.JSONGiveRespect> {
@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<GiveRespect.JSONGiveRespect> {
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<GiveRespect.JSONGiveRespect> {
public int respect_received = 0;
public int daily_respects = 0;
}
}
}
@@ -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<GiveUserClothing.JSONGiveUserClothing> {
@@ -23,14 +24,35 @@ public class GiveUserClothing extends RCONMessage<GiveUserClothing.JSONGiveUserC
@Override
public void handle(Gson gson, GiveUserClothing.JSONGiveUserClothing object) {
if (object.user_id <= 0 || object.clothing_id <= 0) {
this.status = RCONMessage.STATUS_ERROR;
this.message = "invalid user or clothing";
return;
}
if (!userExists(object.user_id)) {
this.status = RCONMessage.HABBO_NOT_FOUND;
this.message = "user not found";
return;
}
if (!clothingExists(object.clothing_id)) {
this.status = RCONMessage.STATUS_ERROR;
this.message = "clothing not found";
return;
}
Habbo habbo = Emulator.getGameEnvironment().getHabboManager().getHabbo(object.user_id);
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO users_clothing (user_id, clothing_id) VALUES (?, ?)")) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT IGNORE INTO users_clothing (user_id, clothing_id) VALUES (?, ?)")) {
statement.setInt(1, object.user_id);
statement.setInt(2, object.clothing_id);
statement.execute();
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
this.status = RCONMessage.SYSTEM_ERROR;
this.message = "failed to grant clothing";
return;
}
if (habbo != null) {
@@ -42,6 +64,39 @@ public class GiveUserClothing extends RCONMessage<GiveUserClothing.JSONGiveUserC
client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FIGURESET_REDEEMED.key));
}
}
this.message = "granted clothing";
}
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 (SQLException e) {
LOGGER.error("Caught SQL exception", e);
return false;
}
}
private static boolean clothingExists(int clothingId) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT id FROM catalog_clothing WHERE id = ? LIMIT 1")) {
statement.setInt(1, clothingId);
try (ResultSet set = statement.executeQuery()) {
return set.next();
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
return false;
}
}
static class JSONGiveUserClothing {
@@ -6,6 +6,9 @@ import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.outgoing.generic.alerts.GenericAlertComposer;
import com.eu.habbo.messages.outgoing.generic.alerts.StaffAlertWithLinkComposer;
import com.google.gson.Gson;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.Map;
@@ -37,9 +40,13 @@ public class HotelAlert extends RCONMessage<HotelAlert.JSONHotelAlert> {
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 = "";
}
}
@@ -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<IgnoreUser.JSONIgnoreUser> {
@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<IgnoreUser.JSONIgnoreUser> {
static class JSONIgnoreUser {
@Positive(message = "invalid user")
public int user_id;
@Positive(message = "invalid target")
public int target_id;
}
}
}
@@ -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<ImageAlertUser.JSON> {
public ImageAlertUser() {
@@ -51,27 +55,39 @@ public class ImageAlertUser extends RCONMessage<ImageAlertUser.JSON> {
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 = "";
}
}
}
@@ -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<ImageHotelAlert.JSON> {
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 = "";
}
}
}
@@ -9,6 +9,7 @@ import org.slf4j.LoggerFactory;
public class ModifyUserSubscription extends RCONMessage<ModifyUserSubscription.JSON> {
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<ModifyUserSubscription.J
return;
}
int maxDuration = parseMaxDuration(Emulator.getConfig().getValue("rcon.subscription.max_duration_seconds", String.valueOf(DEFAULT_MAX_DURATION_SECONDS)));
if (json.action.equalsIgnoreCase("add") || json.action.equalsIgnoreCase("+") || json.action.equalsIgnoreCase("a")) {
if (json.duration < 1) {
if (!isValidDuration(json.duration, maxDuration)) {
this.status = RCONMessage.STATUS_ERROR;
this.message = "duration must be > 0";
this.message = "duration must be between 1 and " + maxDuration + " seconds";
return;
}
@@ -58,13 +60,13 @@ public class ModifyUserSubscription extends RCONMessage<ModifyUserSubscription.J
}
if (json.duration != -1) {
if (json.duration < 1) {
if (!isValidDuration(json.duration, maxDuration)) {
this.status = RCONMessage.STATUS_ERROR;
this.message = "duration must be > 0 or -1 to remove all time";
this.message = "duration must be between 1 and " + maxDuration + " seconds, or -1 to remove all time";
return;
}
s.addDuration(-json.duration);
s.addDuration(-Math.min(json.duration, s.getRemaining()));
this.status = RCONMessage.STATUS_OK;
this.message = "Successfully removed %time% seconds from %subscription% on %user%".replace("%time%", json.duration + "").replace("%user%", habbo.getUsername()).replace("%subscription%", json.type);
} else {
@@ -85,6 +87,22 @@ public class ModifyUserSubscription extends RCONMessage<ModifyUserSubscription.J
}
}
static boolean isValidDuration(int duration, int maxDuration) {
return duration >= 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<ModifyUserSubscription.J
public int duration = -1; // Time to add/remove in seconds. -1 means remove subscription entirely
}
}
}
@@ -3,6 +3,8 @@ package com.eu.habbo.messages.rcon;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.users.Habbo;
import com.google.gson.Gson;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -12,6 +14,7 @@ import java.sql.SQLException;
public class MuteUser extends RCONMessage<MuteUser.JSON> {
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<MuteUser.JSON> {
@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<MuteUser.JSON> {
}
} 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<MuteUser.JSON> {
}
}
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;
}
}
@@ -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<ProgressAchievement.ProgressAchievementJSON> {
static final int DEFAULT_MAX_PROGRESS = 10_000;
public ProgressAchievement() {
super(ProgressAchievementJSON.class);
@@ -14,6 +16,13 @@ public class ProgressAchievement extends RCONMessage<ProgressAchievement.Progres
@Override
public void handle(Gson gson, ProgressAchievementJSON json) {
int maxProgress = parseMaxProgress(Emulator.getConfig().getValue("rcon.achievement.max_progress", String.valueOf(DEFAULT_MAX_PROGRESS)));
if (json.progress > 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<ProgressAchievement.Progres
}
}
static int parseMaxProgress(String configured) {
try {
int parsed = Integer.parseInt(configured);
if (parsed > 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;
}
}
}
@@ -31,6 +31,17 @@ public abstract class RCONMessage<T> {
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<RCONMessage> {
@Override
@@ -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;
}
}
}
@@ -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<ConstraintViolation<Object>> violations = VALIDATOR.validate(payload);
return violations.stream()
.min(Comparator.comparing(violation -> violation.getPropertyPath().toString()))
.map(ConstraintViolation::getMessage)
.orElse(null);
}
}
@@ -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;
}
}
}
@@ -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<SendGift.SendGiftJSON> {
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<SendGift.SendGiftJSON> {
@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<SendGift.SendGiftJSON> {
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<SendGift.SendGiftJSON> {
public String message = "";
}
}
}
@@ -24,7 +24,9 @@ public class SetMotto extends RCONMessage<SetMotto.SetMottoJSON> {
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<SetMotto.SetMottoJSON> {
public String motto;
}
}
}
@@ -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<SetRank.JSONSetRank> {
@@ -12,6 +17,25 @@ public class SetRank extends RCONMessage<SetRank.JSONSetRank> {
@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<SetRank.JSONSetRank> {
}
}
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;
}
}
@@ -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;
}
}
}
@@ -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<StalkUser.StalkUserJSON> {
public StalkUser() {
@@ -44,14 +45,19 @@ public class StalkUser extends RCONMessage<StalkUser.StalkUserJSON> {
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;
}
}
@@ -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<TalkUser.JSON> {
public TalkUser() {
@@ -38,15 +41,20 @@ public class TalkUser extends RCONMessage<TalkUser.JSON> {
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;
}
}
@@ -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<UpdateUser.JSON> {
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<UpdateUser.JSON> {
@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<UpdateUser.JSON> {
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<UpdateUser.JSON> {
public boolean strip_unredeemed_clothing = false;
//More could be added in the future.
}
}
}
@@ -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<String, Class<? extends RCONMessage>> messages;
private final GsonBuilder gsonBuilder;
private final boolean rateLimitEnabled;
private final LoadingCache<String, RateLimiter> rateLimiters;
List<String> allowedAdresses = new ArrayList<>();
public RCONServer(String host, int port) throws Exception {
@@ -32,6 +40,16 @@ public class RCONServer extends Server {
this.gsonBuilder = new GsonBuilder();
this.gsonBuilder.registerTypeAdapter(RCONMessage.class, new RCONMessage.RCONMessageSerializer());
this.rateLimitEnabled = Emulator.getConfig().getBoolean("rcon.rate_limit.enabled", true);
RateLimiterConfig rateLimiterConfig = RateLimiterConfig.custom()
.limitForPeriod(Math.max(1, Emulator.getConfig().getInt("rcon.rate_limit.limit_for_period", 60)))
.limitRefreshPeriod(Duration.ofMillis(Math.max(100, Emulator.getConfig().getInt("rcon.rate_limit.refresh_period_ms", 1000))))
.timeoutDuration(Duration.ofMillis(Math.max(0, Emulator.getConfig().getInt("rcon.rate_limit.timeout_ms", 0))))
.build();
this.rateLimiters = Caffeine.newBuilder()
.maximumSize(512)
.expireAfterAccess(Duration.ofMinutes(10))
.build(address -> RateLimiter.of("rcon-" + address, rateLimiterConfig));
this.addRCONMessage("alertuser", AlertUser.class);
this.addRCONMessage("disconnect", DisconnectUser.class);
@@ -89,6 +107,11 @@ public class RCONServer extends Server {
}
public String handle(ChannelHandlerContext ctx, String key, String body) throws Exception {
if (!this.acquirePermit(ctx)) {
LOGGER.warn("RCON rate limit exceeded for {}", remoteAddress(ctx));
return "RATE_LIMITED";
}
Class<? extends RCONMessage> message = this.messages.get(key.replace("_", "").toLowerCase());
String result;
@@ -96,7 +119,10 @@ public class RCONServer extends Server {
try {
RCONMessage rcon = message.getDeclaredConstructor().newInstance();
Gson gson = this.gsonBuilder.create();
rcon.handle(gson, gson.fromJson(body, rcon.type));
Object payload = gson.fromJson(body, rcon.type);
if (rcon.validate(payload)) {
rcon.handle(gson, payload);
}
LOGGER.info("Handled RCON Message: {}", message.getSimpleName());
result = gson.toJson(rcon, RCONMessage.class);
@@ -118,4 +144,17 @@ public class RCONServer extends Server {
public List<String> 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();
}
}
@@ -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();
}
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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"));
}
}
@@ -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));
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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"));
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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<String, RateLimiter>"),
"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");
}
}