diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLog.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLog.java index 0507115e..15a73f32 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLog.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLog.java @@ -11,9 +11,8 @@ import java.sql.Statement; /** * Append-only audit trail for privileged housekeeping/admin actions (rank grants, - * currency grants, etc.). There was previously no record of which operator did - * what to whom. Writes are dispatched off the calling thread; the backing table - * is created on first use so no manual migration is required. + * currency grants, etc.). Writes are dispatched off the calling thread; the + * backing table is created on first use so no manual migration is required. */ public final class HousekeepingAuditLog { @@ -43,24 +42,26 @@ public final class HousekeepingAuditLog { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( - "INSERT INTO housekeeping_log (operator_id, operator_name, action, target_user_id, detail, ip, timestamp) " + - "VALUES (?, ?, ?, ?, ?, ?, ?)")) { - statement.setInt(1, operatorId); - statement.setString(2, operatorName != null ? operatorName : ""); - statement.setString(3, action != null ? action : ""); + "INSERT INTO housekeeping_log (timestamp, actor_id, actor_name, target_type, target_id, target_label, action, detail, success) " + + "VALUES (?, ?, ?, 'user', ?, '', ?, ?, 1)")) { + statement.setInt(1, Emulator.getIntUnixTimestamp()); + statement.setInt(2, operatorId); + statement.setString(3, operatorName != null ? operatorName : ""); statement.setInt(4, targetUserId); - statement.setString(5, truncate(detail)); - statement.setString(6, ip != null ? ip : ""); - statement.setInt(7, Emulator.getIntUnixTimestamp()); + statement.setString(5, action != null ? action : ""); + statement.setString(6, truncate(detail, ip)); statement.execute(); } catch (SQLException e) { LOGGER.error("Failed to write housekeeping audit log entry", e); } } - private static String truncate(String detail) { - if (detail == null) return ""; - return detail.length() > 512 ? detail.substring(0, 512) : detail; + private static String truncate(String detail, String ip) { + String value = detail == null ? "" : detail; + if (ip != null && !ip.isEmpty()) { + value = value.isEmpty() ? "ip=" + ip : value + " ip=" + ip; + } + return value.length() > 500 ? value.substring(0, 500) : value; } private static void ensureTable() { @@ -75,19 +76,19 @@ public final class HousekeepingAuditLog { Statement statement = connection.createStatement()) { statement.execute( "CREATE TABLE IF NOT EXISTS housekeeping_log (" + - "id INT UNSIGNED NOT NULL AUTO_INCREMENT, " + - "operator_id INT NOT NULL, " + - "operator_name VARCHAR(64) NOT NULL DEFAULT '', " + - "action VARCHAR(64) NOT NULL, " + - "target_user_id INT NOT NULL DEFAULT 0, " + - "detail VARCHAR(512) NOT NULL DEFAULT '', " + - "ip VARCHAR(64) NOT NULL DEFAULT '', " + + "id INT NOT NULL AUTO_INCREMENT, " + "timestamp INT NOT NULL, " + + "actor_id INT NOT NULL, " + + "actor_name VARCHAR(64) NOT NULL DEFAULT '', " + + "target_type VARCHAR(16) NOT NULL DEFAULT 'user', " + + "target_id INT NOT NULL DEFAULT 0, " + + "target_label VARCHAR(128) NOT NULL DEFAULT '', " + + "action VARCHAR(64) NOT NULL DEFAULT '', " + + "detail VARCHAR(500) NOT NULL DEFAULT '', " + + "success TINYINT NOT NULL DEFAULT 1, " + "PRIMARY KEY (id), " + - "KEY idx_operator (operator_id), " + - "KEY idx_target (target_user_id), " + - "KEY idx_timestamp (timestamp)" + - ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + "KEY timestamp (timestamp)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); tableReady = true; } catch (SQLException e) { LOGGER.error("Failed to create housekeeping_log table", e); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingBanUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingBanUserEvent.java index 4b2c1619..4168ec95 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingBanUserEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingBanUserEvent.java @@ -17,9 +17,6 @@ import java.util.List; */ public class HousekeepingBanUserEvent extends MessageHandler { private static final String ACTION_KEY = "user.ban"; - private static final int SECONDS_IN_HOUR = 3600; - // 100-year ceiling, matches ModToolSanctionBanEvent's permanent ban. - private static final int MAX_DURATION_SECONDS = 100 * 365 * 24 * 3600; @Override public int getRatelimit() { @@ -33,19 +30,23 @@ public class HousekeepingBanUserEvent extends MessageHandler { } int userId = this.packet.readInt(); - String reason = this.packet.readString(); + String reason = HousekeepingInputGuard.normalize(this.packet.readString()); int hours = this.packet.readInt(); - if (userId <= 0 || hours <= 0) { + if (userId <= 0 || hours <= 0 || !HousekeepingInputGuard.isWithinLimit(reason, HousekeepingInputGuard.MAX_REASON_LENGTH)) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input")); return; } - long durationLong = (long) hours * SECONDS_IN_HOUR; - int duration = durationLong > MAX_DURATION_SECONDS ? MAX_DURATION_SECONDS : (int) durationLong; + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + + int duration = HousekeepingSanctionDuration.secondsFromHours(hours); List bans = Emulator.getGameEnvironment().getModToolManager() - .ban(userId, this.client.getHabbo(), reason != null ? reason : "", duration, ModToolBanType.ACCOUNT, 0); + .ban(userId, this.client.getHabbo(), reason, duration, ModToolBanType.ACCOUNT, 0); if (bans == null || bans.isEmpty()) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.ban_failed")); @@ -56,6 +57,11 @@ public class HousekeepingBanUserEvent extends MessageHandler { // object, so we return the target user id as the actionId — it's // the only stable handle the client can use until a dedicated // housekeeping_log row id supersedes it. + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "hours=" + hours + " reason=" + HousekeepingInputGuard.auditValue(reason), + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingDeleteRoomEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingDeleteRoomEvent.java index 9e85deb5..9de0abb8 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingDeleteRoomEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingDeleteRoomEvent.java @@ -42,11 +42,14 @@ public class HousekeepingDeleteRoomEvent extends MessageHandler { Room room = Emulator.getGameEnvironment().getRoomManager().loadRoom(roomId, false); - if (room != null) { - room.ejectAll(); - room.preventUnloading = false; - room.dispose(); - Emulator.getGameEnvironment().getRoomManager().uncacheRoom(room); + if (room == null) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.room_not_found")); + return; + } + + if (!HousekeepingRoomGuard.canManageRoom(this.client.getHabbo(), room)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; } try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); @@ -63,6 +66,16 @@ public class HousekeepingDeleteRoomEvent extends MessageHandler { return; } + room.ejectAll(); + room.preventUnloading = false; + room.dispose(); + Emulator.getGameEnvironment().getRoomManager().uncacheRoom(room); + + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, 0, "roomId=" + roomId, + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, roomId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingFindUserByNameEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingFindUserByNameEvent.java index 070733c6..f226f725 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingFindUserByNameEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingFindUserByNameEvent.java @@ -20,9 +20,9 @@ public class HousekeepingFindUserByNameEvent extends MessageHandler { return; } - String username = this.packet.readString(); + String username = HousekeepingInputGuard.normalize(this.packet.readString()); - if (username == null || username.isEmpty()) { + if (username.isEmpty() || !HousekeepingInputGuard.isWithinLimit(username, HousekeepingInputGuard.MAX_LOOKUP_LENGTH)) { this.client.sendResponse(new HousekeepingUserDetailComposer(null)); return; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingForceDisconnectUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingForceDisconnectUserEvent.java index 8e642ffa..7639f1df 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingForceDisconnectUserEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingForceDisconnectUserEvent.java @@ -26,9 +26,9 @@ public class HousekeepingForceDisconnectUserEvent extends MessageHandler { } int userId = this.packet.readInt(); - String reason = this.packet.readString(); + String reason = HousekeepingInputGuard.normalize(this.packet.readString()); - if (userId <= 0) { + if (userId <= 0 || !HousekeepingInputGuard.isWithinLimit(reason, HousekeepingInputGuard.MAX_REASON_LENGTH)) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input")); return; } @@ -40,13 +40,23 @@ public class HousekeepingForceDisconnectUserEvent extends MessageHandler { return; } - if (reason != null && !reason.isEmpty()) { + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + + if (!reason.isEmpty()) { target.alert(reason); } // ACK first so the action result lands before the target's socket // closes (otherwise an alerted user on the same emulator thread may // already be torn down when we try to write). + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "reason=" + HousekeepingInputGuard.auditValue(reason), + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); target.disconnect(); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCreditsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCreditsEvent.java index c47bad24..1cc102ad 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCreditsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCreditsEvent.java @@ -12,7 +12,6 @@ import java.sql.SQLException; public class HousekeepingGiveCreditsEvent extends MessageHandler { private static final String ACTION_KEY = "user.give_credits"; - private static final int MAX_GRANT = 1_000_000_000; @Override public int getRatelimit() { @@ -28,11 +27,16 @@ public class HousekeepingGiveCreditsEvent extends MessageHandler { int userId = this.packet.readInt(); int amount = this.packet.readInt(); - if (userId <= 0 || amount == 0 || amount < -MAX_GRANT || amount > MAX_GRANT) { + if (userId <= 0 || !HousekeepingMutationGuard.isPositiveGrantAmount(amount)) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input")); return; } + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + Habbo online = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); if (online != null) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCurrencyEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCurrencyEvent.java index 5e5053a1..f31e1934 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCurrencyEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCurrencyEvent.java @@ -18,7 +18,6 @@ import java.sql.SQLException; */ public class HousekeepingGiveCurrencyEvent extends MessageHandler { private static final int CURRENCY_DUCKETS = 0; - private static final int MAX_GRANT = 1_000_000_000; @Override public int getRatelimit() { @@ -37,11 +36,21 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler { String actionKey = "user.give_currency_" + currencyType; - if (userId <= 0 || amount == 0 || amount < -MAX_GRANT || amount > MAX_GRANT) { + if (userId <= 0 || !HousekeepingMutationGuard.isCurrencyType(currencyType) || !HousekeepingMutationGuard.isPositiveGrantAmount(amount)) { this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.invalid_input")); return; } + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.rank_too_high")); + return; + } + + if (!HousekeepingMutationGuard.userExists(userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.user_not_found")); + return; + } + Habbo online = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); if (online != null) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGrantItemEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGrantItemEvent.java index cf1ecb6b..6d44acf4 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGrantItemEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGrantItemEvent.java @@ -40,6 +40,21 @@ public class HousekeepingGrantItemEvent extends MessageHandler { return; } + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + + if (!HousekeepingMutationGuard.userExists(userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.user_not_found")); + return; + } + + if (!HousekeepingMutationGuard.itemExists(itemId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.item_not_found")); + return; + } + if (quantity > MAX_QUANTITY_PER_CALL) { quantity = MAX_QUANTITY_PER_CALL; } @@ -57,6 +72,11 @@ public class HousekeepingGrantItemEvent extends MessageHandler { return; } + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "itemId=" + itemId + " quantity=" + quantity, + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuard.java new file mode 100644 index 00000000..b6232506 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuard.java @@ -0,0 +1,27 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +final class HousekeepingInputGuard { + static final int MAX_LOOKUP_LENGTH = 64; + static final int MAX_REASON_LENGTH = 500; + static final int MAX_ALERT_LENGTH = 1000; + + private HousekeepingInputGuard() { + } + + static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + static boolean isWithinLimit(String value, int maxLength) { + return value != null && value.length() <= maxLength; + } + + static String auditValue(String value) { + String normalized = normalize(value) + .replace('\r', ' ') + .replace('\n', ' ') + .replace('\t', ' '); + + return normalized.length() > MAX_REASON_LENGTH ? normalized.substring(0, MAX_REASON_LENGTH) : normalized; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingKickAllFromRoomEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingKickAllFromRoomEvent.java index 76ae8e82..20867ab0 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingKickAllFromRoomEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingKickAllFromRoomEvent.java @@ -34,8 +34,18 @@ public class HousekeepingKickAllFromRoomEvent extends MessageHandler { return; } + if (!HousekeepingRoomGuard.canManageRoom(this.client.getHabbo(), room)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + room.ejectAll(); + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, 0, "roomId=" + roomId, + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, roomId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingKickUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingKickUserEvent.java index 57bc8341..60e15f54 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingKickUserEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingKickUserEvent.java @@ -28,9 +28,9 @@ public class HousekeepingKickUserEvent extends MessageHandler { } int userId = this.packet.readInt(); - String reason = this.packet.readString(); + String reason = HousekeepingInputGuard.normalize(this.packet.readString()); - if (userId <= 0) { + if (userId <= 0 || !HousekeepingInputGuard.isWithinLimit(reason, HousekeepingInputGuard.MAX_REASON_LENGTH)) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input")); return; } @@ -42,6 +42,11 @@ public class HousekeepingKickUserEvent extends MessageHandler { return; } + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + if (target.hasPermission(Permission.ACC_UNKICKABLE)) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.target_unkickable")); return; @@ -51,10 +56,15 @@ public class HousekeepingKickUserEvent extends MessageHandler { Emulator.getGameEnvironment().getRoomManager().leaveRoom(target, target.getHabboInfo().getCurrentRoom()); } - if (reason != null && !reason.isEmpty()) { + if (!reason.isEmpty()) { target.alert(reason); } + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "reason=" + HousekeepingInputGuard.auditValue(reason), + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMutationGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMutationGuard.java new file mode 100644 index 00000000..553f7f83 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMutationGuard.java @@ -0,0 +1,46 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import com.eu.habbo.Emulator; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +final class HousekeepingMutationGuard { + static final int MAX_GRANT = 1_000_000_000; + + private HousekeepingMutationGuard() { + } + + static boolean isPositiveGrantAmount(int amount) { + return amount > 0 && amount <= MAX_GRANT; + } + + static boolean isCurrencyType(int currencyType) { + return currencyType >= 0; + } + + static boolean userExists(int userId) { + if (userId <= 0) { + return false; + } + + 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; + } + } + + static boolean itemExists(int itemId) { + return itemId > 0 && Emulator.getGameEnvironment().getItemManager().getItem(itemId) != null; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteRoomEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteRoomEvent.java index b471affc..f707e1f6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteRoomEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteRoomEvent.java @@ -31,7 +31,7 @@ public class HousekeepingMuteRoomEvent extends MessageHandler { int roomId = this.packet.readInt(); int minutes = this.packet.readInt(); - if (roomId <= 0) { + if (roomId <= 0 || minutes < 0) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input")); return; } @@ -43,8 +43,18 @@ public class HousekeepingMuteRoomEvent extends MessageHandler { return; } + if (!HousekeepingRoomGuard.canManageRoom(this.client.getHabbo(), room)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + room.setMuted(minutes > 0); + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, 0, "roomId=" + roomId + " minutes=" + minutes + " muted=" + (minutes > 0), + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, roomId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteUserEvent.java index 43f12eb1..4fe6bd31 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteUserEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteUserEvent.java @@ -15,7 +15,6 @@ import com.eu.habbo.messages.outgoing.housekeeping.HousekeepingActionResultCompo */ public class HousekeepingMuteUserEvent extends MessageHandler { private static final String ACTION_KEY = "user.mute"; - private static final int SECONDS_IN_MINUTE = 60; @Override public int getRatelimit() { @@ -29,10 +28,10 @@ public class HousekeepingMuteUserEvent extends MessageHandler { } int userId = this.packet.readInt(); - String reason = this.packet.readString(); + String reason = HousekeepingInputGuard.normalize(this.packet.readString()); int minutes = this.packet.readInt(); - if (userId <= 0 || minutes <= 0) { + if (userId <= 0 || minutes <= 0 || !HousekeepingInputGuard.isWithinLimit(reason, HousekeepingInputGuard.MAX_REASON_LENGTH)) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input")); return; } @@ -44,12 +43,22 @@ public class HousekeepingMuteUserEvent extends MessageHandler { return; } - target.mute(minutes * SECONDS_IN_MINUTE, false); + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } - if (reason != null && !reason.isEmpty()) { + target.mute(HousekeepingSanctionDuration.secondsFromMinutes(minutes), false); + + if (!reason.isEmpty()) { target.alert(reason); } + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "minutes=" + minutes + " reason=" + HousekeepingInputGuard.auditValue(reason), + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingResetUserPasswordEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingResetUserPasswordEvent.java index 14142002..87cf140b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingResetUserPasswordEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingResetUserPasswordEvent.java @@ -46,6 +46,11 @@ public class HousekeepingResetUserPasswordEvent extends MessageHandler { return; } + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + String plain = randomPassword(); String hash; @@ -74,6 +79,11 @@ public class HousekeepingResetUserPasswordEvent extends MessageHandler { // Plaintext flows through `message` — the client surfaces it via the // status banner so the operator can read it once. SSL is on the // operator: the only secure transport for the WS is wss://. + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "password_reset=1", + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, plain)); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomGuard.java new file mode 100644 index 00000000..ec604b31 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomGuard.java @@ -0,0 +1,13 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import com.eu.habbo.habbohotel.rooms.Room; +import com.eu.habbo.habbohotel.users.Habbo; + +final class HousekeepingRoomGuard { + private HousekeepingRoomGuard() { + } + + static boolean canManageRoom(Habbo operator, Room room) { + return room != null && HousekeepingTargetRankGuard.canTargetUser(operator, room.getOwnerId()); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomStateEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomStateEvent.java index ea902db8..d7eb5645 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomStateEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomStateEvent.java @@ -41,9 +41,19 @@ public class HousekeepingRoomStateEvent extends MessageHandler { return; } + if (!HousekeepingRoomGuard.canManageRoom(this.client.getHabbo(), room)) { + this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.rank_too_high")); + return; + } + room.setState(open ? RoomState.OPEN : RoomState.LOCKED); room.save(); + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + actionKey, 0, "roomId=" + roomId + " open=" + open, + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, true, roomId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDuration.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDuration.java new file mode 100644 index 00000000..8585d9c4 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDuration.java @@ -0,0 +1,37 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +final class HousekeepingSanctionDuration { + static final int SECONDS_IN_MINUTE = 60; + static final int SECONDS_IN_HOUR = 3600; + static final int MAX_SECONDS = Integer.MAX_VALUE; + + private HousekeepingSanctionDuration() { + } + + static int secondsFromHours(int hours) { + if (hours <= 0) { + return 0; + } + + long seconds = (long) hours * SECONDS_IN_HOUR; + return seconds > MAX_SECONDS ? MAX_SECONDS : (int) seconds; + } + + static int secondsFromMinutes(int minutes) { + if (minutes <= 0) { + return 0; + } + + long seconds = (long) minutes * SECONDS_IN_MINUTE; + return seconds > MAX_SECONDS ? MAX_SECONDS : (int) seconds; + } + + static int unixUntil(int now, int durationSeconds) { + if (durationSeconds <= 0) { + return now; + } + + long until = (long) now + durationSeconds; + return until > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) until; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSearchRoomsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSearchRoomsEvent.java index bccf6588..db45e999 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSearchRoomsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSearchRoomsEvent.java @@ -36,14 +36,11 @@ public class HousekeepingSearchRoomsEvent extends MessageHandler { return; } - String query = this.packet.readString(); + String query = HousekeepingInputGuard.normalize(this.packet.readString()); boolean exactMatch = this.packet.readBoolean(); int limit = Math.min(Math.max(this.packet.readInt(), 1), HARD_LIMIT); - if (query == null) query = ""; - query = query.trim(); - - if (query.isEmpty()) { + if (query.isEmpty() || !HousekeepingInputGuard.isWithinLimit(query, HousekeepingInputGuard.MAX_LOOKUP_LENGTH)) { this.client.sendResponse(new HousekeepingRoomListComposer(new ArrayList<>())); return; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSendHotelAlertEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSendHotelAlertEvent.java index e3a83daa..7c2c9c49 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSendHotelAlertEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSendHotelAlertEvent.java @@ -31,13 +31,18 @@ public class HousekeepingSendHotelAlertEvent extends MessageHandler { return; } - String message = this.packet.readString(); + String message = HousekeepingInputGuard.normalize(this.packet.readString()); - if (message == null || message.trim().isEmpty()) { + if (message.isEmpty()) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.alert_empty")); return; } + if (!HousekeepingInputGuard.isWithinLimit(message, HousekeepingInputGuard.MAX_ALERT_LENGTH)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.input_too_long")); + return; + } + String body = message + "\r\n-" + this.client.getHabbo().getHabboInfo().getUsername(); ServerMessage broadcast = new StaffAlertWithLinkComposer(body, "").compose(); @@ -53,6 +58,11 @@ public class HousekeepingSendHotelAlertEvent extends MessageHandler { reached++; } + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, 0, "reached=" + reached + " message=" + HousekeepingInputGuard.auditValue(message), + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, reached, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetHcSubscriptionEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetHcSubscriptionEvent.java index 9facf4f4..7d9f13de 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetHcSubscriptionEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetHcSubscriptionEvent.java @@ -38,6 +38,11 @@ public class HousekeepingSetHcSubscriptionEvent extends MessageHandler { return; } + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + int now = Emulator.getIntUnixTimestamp(); int newExpire; @@ -71,6 +76,11 @@ public class HousekeepingSetHcSubscriptionEvent extends MessageHandler { return; } + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "days=" + days + " expire=" + newExpire, + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetUserRankEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetUserRankEvent.java index db100eb8..6fba7de9 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetUserRankEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetUserRankEvent.java @@ -45,13 +45,7 @@ public class HousekeepingSetUserRankEvent extends MessageHandler { Rank rank = permissions.getRank(rankId); - // Rank-ceiling guard: an operator must never be able to grant a rank - // above their own, nor modify a user who already outranks them. This - // mirrors GiveRankCommand and prevents privilege escalation through - // the housekeeping path (including self-promotion). - int operatorRankId = this.client.getHabbo().getHabboInfo().getRank().getId(); - - if (rank.getId() > operatorRankId) { + if (!HousekeepingTargetRankGuard.canAssignRank(this.client.getHabbo(), rank.getId())) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); return; } @@ -77,7 +71,12 @@ public class HousekeepingSetUserRankEvent extends MessageHandler { } } - if (targetRankId > operatorRankId) { + if (targetRankId <= 0) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.user_not_found")); + return; + } + + if (!HousekeepingTargetRankGuard.canTargetRank(this.client.getHabbo(), targetRankId)) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); return; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuard.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuard.java new file mode 100644 index 00000000..3488829a --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuard.java @@ -0,0 +1,47 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.permissions.Rank; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.users.HabboInfo; + +final class HousekeepingTargetRankGuard { + private HousekeepingTargetRankGuard() { + } + + static boolean canTargetUser(Habbo operator, int targetUserId) { + if (operator == null || targetUserId <= 0) { + return false; + } + + HabboInfo targetInfo = Emulator.getGameEnvironment().getHabboManager().getHabboInfo(targetUserId); + if (targetInfo == null) { + return true; + } + + return canTargetRank(operator, targetInfo.getRank().getId()); + } + + static boolean canTargetRank(Habbo operator, int targetRankId) { + if (operator == null || targetRankId <= 0) { + return false; + } + + int operatorRankId = operator.getHabboInfo().getRank().getId(); + + return targetRankId < operatorRankId || isCoreRank(operatorRankId) && targetRankId <= operatorRankId; + } + + static boolean canAssignRank(Habbo operator, int rankId) { + return canTargetRank(operator, rankId); + } + + private static boolean isCoreRank(int rankId) { + int highestRankId = 0; + for (Rank rank : Emulator.getGameEnvironment().getPermissionsManager().getAllRanks()) { + highestRankId = Math.max(highestRankId, rank.getId()); + } + + return highestRankId > 0 && rankId >= highestRankId; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTradeLockUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTradeLockUserEvent.java index cf961c15..98b1ddbe 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTradeLockUserEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTradeLockUserEvent.java @@ -20,8 +20,6 @@ import java.sql.SQLException; */ public class HousekeepingTradeLockUserEvent extends MessageHandler { private static final String ACTION_KEY = "user.trade_lock"; - private static final int SECONDS_IN_HOUR = 3600; - private static final int MAX_DURATION_SECONDS = 100 * 365 * 24 * 3600; @Override public int getRatelimit() { @@ -36,16 +34,20 @@ public class HousekeepingTradeLockUserEvent extends MessageHandler { int userId = this.packet.readInt(); int hours = this.packet.readInt(); - String reason = this.packet.readString(); + String reason = HousekeepingInputGuard.normalize(this.packet.readString()); - if (userId <= 0 || hours <= 0) { + if (userId <= 0 || hours <= 0 || !HousekeepingInputGuard.isWithinLimit(reason, HousekeepingInputGuard.MAX_REASON_LENGTH)) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input")); return; } - long durationLong = (long) hours * SECONDS_IN_HOUR; - int duration = durationLong > MAX_DURATION_SECONDS ? MAX_DURATION_SECONDS : (int) durationLong; - int lockedUntil = Emulator.getIntUnixTimestamp() + duration; + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + + int duration = HousekeepingSanctionDuration.secondsFromHours(hours); + int lockedUntil = HousekeepingSanctionDuration.unixUntil(Emulator.getIntUnixTimestamp(), duration); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET trade_locked_until = ? WHERE user_id = ? LIMIT 1")) { @@ -67,11 +69,16 @@ public class HousekeepingTradeLockUserEvent extends MessageHandler { if (online != null) { online.getHabboStats().setAllowTrade(false); - if (reason != null && !reason.isEmpty()) { + if (!reason.isEmpty()) { online.alert(reason); } } + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "hours=" + hours + " lockedUntil=" + lockedUntil + " reason=" + HousekeepingInputGuard.auditValue(reason), + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTransferRoomOwnershipEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTransferRoomOwnershipEvent.java index 70c6bf80..b038d347 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTransferRoomOwnershipEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTransferRoomOwnershipEvent.java @@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.housekeeping; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.habbohotel.users.HabboInfo; import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.outgoing.housekeeping.HousekeepingActionResultComposer; @@ -39,6 +40,19 @@ public class HousekeepingTransferRoomOwnershipEvent extends MessageHandler { return; } + Room room = Emulator.getGameEnvironment().getRoomManager().loadRoom(roomId, false); + + if (room == null) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.room_not_found")); + return; + } + + if (!HousekeepingRoomGuard.canManageRoom(this.client.getHabbo(), room) || + !HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), newOwnerId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + HabboInfo newOwner = Emulator.getGameEnvironment().getHabboManager().getHabboInfo(newOwnerId); if (newOwner == null) { @@ -62,6 +76,14 @@ public class HousekeepingTransferRoomOwnershipEvent extends MessageHandler { return; } + room.setOwnerId(newOwnerId); + room.setOwnerName(newOwner.getUsername()); + + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, newOwnerId, "roomId=" + roomId + " newOwner=" + newOwner.getUsername(), + this.client.getHabbo().getHabboInfo().getIpLogin()); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, roomId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingUnbanUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingUnbanUserEvent.java index 74ed38e4..8c785c93 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingUnbanUserEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingUnbanUserEvent.java @@ -34,11 +34,23 @@ public class HousekeepingUnbanUserEvent extends MessageHandler { return; } + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + // ModToolManager.unban only takes a username; the SQL UPDATE // happens against active bans (ban_expire > now), so calling it // on a never-banned user is a benign no-op that returns false. boolean cleared = Emulator.getGameEnvironment().getModToolManager().unban(info.getUsername()); + if (cleared) { + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "username=" + info.getUsername(), + this.client.getHabbo().getHabboInfo().getIpLogin()); + } this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, cleared, cleared ? userId : 0, cleared ? "" : "housekeeping.error.no_active_ban")); } } diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLogContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLogContractTest.java new file mode 100644 index 00000000..c46cf66e --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLogContractTest.java @@ -0,0 +1,29 @@ +package com.eu.habbo.habbohotel.modtool; + +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 HousekeepingAuditLogContractTest { + private static String auditLogSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLog.java")); + } + + @Test + void writerUsesActionLogSchemaReadByHousekeepingClient() throws Exception { + String source = auditLogSource(); + + assertTrue(source.contains("actor_id"), "housekeeping_log writer must persist actor_id"); + assertTrue(source.contains("actor_name"), "housekeeping_log writer must persist actor_name"); + assertTrue(source.contains("target_type"), "housekeeping_log writer must persist target_type"); + assertTrue(source.contains("target_id"), "housekeeping_log writer must persist target_id"); + assertTrue(source.contains("target_label"), "housekeeping_log writer must persist target_label"); + assertTrue(source.contains("success"), "housekeeping_log writer must persist success"); + assertFalse(source.contains("operator_id"), "housekeeping_log writer must not use the obsolete operator_id schema"); + assertFalse(source.contains("target_user_id"), "housekeeping_log writer must not use the obsolete target_user_id schema"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingAuditCoverageContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingAuditCoverageContractTest.java new file mode 100644 index 00000000..5a81cae4 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingAuditCoverageContractTest.java @@ -0,0 +1,43 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HousekeepingAuditCoverageContractTest { + private static final List SENSITIVE_HANDLERS = List.of( + "HousekeepingBanUserEvent.java", + "HousekeepingMuteUserEvent.java", + "HousekeepingGiveCreditsEvent.java", + "HousekeepingGiveCurrencyEvent.java", + "HousekeepingResetUserPasswordEvent.java", + "HousekeepingSetUserRankEvent.java", + "HousekeepingSetHcSubscriptionEvent.java", + "HousekeepingTradeLockUserEvent.java", + "HousekeepingGrantItemEvent.java", + "HousekeepingTransferRoomOwnershipEvent.java", + "HousekeepingSendHotelAlertEvent.java", + "HousekeepingDeleteRoomEvent.java", + "HousekeepingForceDisconnectUserEvent.java", + "HousekeepingKickAllFromRoomEvent.java", + "HousekeepingKickUserEvent.java", + "HousekeepingMuteRoomEvent.java", + "HousekeepingRoomStateEvent.java", + "HousekeepingUnbanUserEvent.java" + ); + + @Test + void sensitiveHousekeepingActionsWriteAuditEntries() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping"); + + for (String handler : SENSITIVE_HANDLERS) { + String source = Files.readString(base.resolve(handler)); + assertTrue(source.contains("HousekeepingAuditLog.log"), + handler + " must append a housekeeping audit log entry after successful privileged actions"); + } + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGrantMutationContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGrantMutationContractTest.java new file mode 100644 index 00000000..3199513b --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGrantMutationContractTest.java @@ -0,0 +1,53 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HousekeepingGrantMutationContractTest { + private static final Path CREDITS_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCreditsEvent.java"); + private static final Path CURRENCY_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCurrencyEvent.java"); + private static final Path GRANT_ITEM_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGrantItemEvent.java"); + + @Test + void housekeepingGrantsRejectNegativeOrOversizedAmountsServerSide() throws IOException { + String credits = Files.readString(CREDITS_SOURCE); + String currency = Files.readString(CURRENCY_SOURCE); + + assertTrue(credits.contains("HousekeepingMutationGuard.isPositiveGrantAmount(amount)"), + "credit grants must only accept positive bounded amounts"); + assertTrue(currency.contains("HousekeepingMutationGuard.isPositiveGrantAmount(amount)"), + "currency grants must only accept positive bounded amounts"); + } + + @Test + void housekeepingCurrencyGrantsRejectInvalidTypesAndMissingUsers() throws IOException { + String currency = Files.readString(CURRENCY_SOURCE); + + assertTrue(currency.contains("HousekeepingMutationGuard.isCurrencyType(currencyType)"), + "currency grants must reject negative currency types"); + assertTrue(currency.contains("HousekeepingMutationGuard.userExists(userId)"), + "offline currency grants must not create orphan users_currency rows"); + } + + @Test + void housekeepingItemGrantsRequireRealUsersAndItemsBeforeInsert() throws IOException { + String grantItem = Files.readString(GRANT_ITEM_SOURCE); + + int userCheck = grantItem.indexOf("HousekeepingMutationGuard.userExists(userId)"); + int itemCheck = grantItem.indexOf("HousekeepingMutationGuard.itemExists(itemId)"); + int insert = grantItem.indexOf("INSERT INTO items"); + + assertTrue(userCheck >= 0, "item grants must check the target user exists"); + assertTrue(itemCheck >= 0, "item grants must check the item base exists"); + assertTrue(userCheck < insert, "target user must be validated before item insert"); + assertTrue(itemCheck < insert, "item base must be validated before item insert"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuardContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuardContractTest.java new file mode 100644 index 00000000..4db64e28 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuardContractTest.java @@ -0,0 +1,53 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HousekeepingInputGuardContractTest { + @Test + void stringDrivenHousekeepingHandlersUseSharedLimits() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping"); + + for (String handler : List.of( + "HousekeepingBanUserEvent.java", + "HousekeepingForceDisconnectUserEvent.java", + "HousekeepingKickUserEvent.java", + "HousekeepingMuteUserEvent.java", + "HousekeepingTradeLockUserEvent.java", + "HousekeepingSendHotelAlertEvent.java", + "HousekeepingSearchRoomsEvent.java", + "HousekeepingFindUserByNameEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("HousekeepingInputGuard.normalize"), + handler + " must normalize client-provided strings before use"); + assertTrue(source.contains("HousekeepingInputGuard.isWithinLimit"), + handler + " must bound client-provided strings before expensive work or broadcast"); + } + } + + @Test + void auditedFreeTextIsSanitizedBeforePersistence() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping"); + + for (String handler : List.of( + "HousekeepingBanUserEvent.java", + "HousekeepingForceDisconnectUserEvent.java", + "HousekeepingKickUserEvent.java", + "HousekeepingMuteUserEvent.java", + "HousekeepingTradeLockUserEvent.java", + "HousekeepingSendHotelAlertEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("HousekeepingInputGuard.auditValue"), + handler + " must collapse control whitespace before writing free text to audit detail"); + } + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuardTest.java new file mode 100644 index 00000000..8e73304b --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuardTest.java @@ -0,0 +1,32 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import org.junit.jupiter.api.Test; + +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 HousekeepingInputGuardTest { + @Test + void normalizesNullableText() { + assertEquals("", HousekeepingInputGuard.normalize(null)); + assertEquals("hello", HousekeepingInputGuard.normalize(" hello ")); + } + + @Test + void enforcesInclusiveLengthLimits() { + assertTrue(HousekeepingInputGuard.isWithinLimit("abc", 3)); + assertFalse(HousekeepingInputGuard.isWithinLimit("abcd", 3)); + assertFalse(HousekeepingInputGuard.isWithinLimit(null, 3)); + } + + @Test + void auditValuesCollapseControlWhitespaceAndCapLength() { + String value = HousekeepingInputGuard.auditValue(" one\r\ntwo\tthree "); + + assertEquals("one two three", value); + + String oversized = "x".repeat(HousekeepingInputGuard.MAX_REASON_LENGTH + 1); + assertEquals(HousekeepingInputGuard.MAX_REASON_LENGTH, HousekeepingInputGuard.auditValue(oversized).length()); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMutationGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMutationGuardTest.java new file mode 100644 index 00000000..72608f01 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMutationGuardTest.java @@ -0,0 +1,24 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HousekeepingMutationGuardTest { + @Test + void positiveGrantAmountsMustBeStrictlyPositiveAndBounded() { + assertFalse(HousekeepingMutationGuard.isPositiveGrantAmount(-1)); + assertFalse(HousekeepingMutationGuard.isPositiveGrantAmount(0)); + assertTrue(HousekeepingMutationGuard.isPositiveGrantAmount(1)); + assertTrue(HousekeepingMutationGuard.isPositiveGrantAmount(HousekeepingMutationGuard.MAX_GRANT)); + assertFalse(HousekeepingMutationGuard.isPositiveGrantAmount(HousekeepingMutationGuard.MAX_GRANT + 1)); + } + + @Test + void currencyTypesCannotBeNegative() { + assertFalse(HousekeepingMutationGuard.isCurrencyType(-1)); + assertTrue(HousekeepingMutationGuard.isCurrencyType(0)); + assertTrue(HousekeepingMutationGuard.isCurrencyType(101)); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomGuardContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomGuardContractTest.java new file mode 100644 index 00000000..d29e41af --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomGuardContractTest.java @@ -0,0 +1,47 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HousekeepingRoomGuardContractTest { + @Test + void destructiveRoomActionsRespectOwnerRankCeiling() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping"); + + for (String handler : List.of( + "HousekeepingDeleteRoomEvent.java", + "HousekeepingKickAllFromRoomEvent.java", + "HousekeepingMuteRoomEvent.java", + "HousekeepingRoomStateEvent.java", + "HousekeepingTransferRoomOwnershipEvent.java" + )) { + String source = Files.readString(base.resolve(handler)); + + assertTrue(source.contains("HousekeepingRoomGuard.canManageRoom(this.client.getHabbo(), room)"), + handler + " must reject room mutations when the room owner is peer-or-higher ranked"); + assertTrue(source.contains("housekeeping.error.rank_too_high"), + handler + " must surface a rank-ceiling error for protected room owners"); + } + } + + @Test + void roomGuardDelegatesToTargetRankGuard() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomGuard.java")); + + assertTrue(source.contains("HousekeepingTargetRankGuard.canTargetUser(operator, room.getOwnerId())"), + "room-owner checks must use the same core-rank peer override as user moderation"); + } + + @Test + void roomMuteRejectsNegativeDurations() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteRoomEvent.java")); + + assertTrue(source.contains("minutes < 0"), + "room mute should reject negative duration values instead of treating them as unmute"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDurationContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDurationContractTest.java new file mode 100644 index 00000000..3fb49a17 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDurationContractTest.java @@ -0,0 +1,40 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +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 HousekeepingSanctionDurationContractTest { + private static final Path BAN_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingBanUserEvent.java"); + private static final Path MUTE_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteUserEvent.java"); + private static final Path TRADE_LOCK_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTradeLockUserEvent.java"); + + @Test + void sanctionsUseSharedOverflowSafeDurationHelpers() throws IOException { + String ban = Files.readString(BAN_SOURCE); + String mute = Files.readString(MUTE_SOURCE); + String tradeLock = Files.readString(TRADE_LOCK_SOURCE); + + assertTrue(ban.contains("HousekeepingSanctionDuration.secondsFromHours(hours)")); + assertTrue(mute.contains("HousekeepingSanctionDuration.secondsFromMinutes(minutes)")); + assertTrue(tradeLock.contains("HousekeepingSanctionDuration.secondsFromHours(hours)")); + assertTrue(tradeLock.contains("HousekeepingSanctionDuration.unixUntil(")); + } + + @Test + void sanctionsDoNotUseOverflowProneIntDurationConstants() throws IOException { + String ban = Files.readString(BAN_SOURCE); + String tradeLock = Files.readString(TRADE_LOCK_SOURCE); + + assertFalse(ban.contains("100 * 365 * 24 * 3600")); + assertFalse(tradeLock.contains("100 * 365 * 24 * 3600")); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDurationTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDurationTest.java new file mode 100644 index 00000000..f412f350 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDurationTest.java @@ -0,0 +1,21 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HousekeepingSanctionDurationTest { + @Test + void convertsHoursAndMinutesWithoutIntegerOverflow() { + assertEquals(3600, HousekeepingSanctionDuration.secondsFromHours(1)); + assertEquals(60, HousekeepingSanctionDuration.secondsFromMinutes(1)); + assertEquals(Integer.MAX_VALUE, HousekeepingSanctionDuration.secondsFromHours(Integer.MAX_VALUE)); + assertEquals(Integer.MAX_VALUE, HousekeepingSanctionDuration.secondsFromMinutes(Integer.MAX_VALUE)); + } + + @Test + void capsUnixTimestampInsteadOfWrapping() { + assertEquals(1_000_060, HousekeepingSanctionDuration.unixUntil(1_000_000, 60)); + assertEquals(Integer.MAX_VALUE, HousekeepingSanctionDuration.unixUntil(Integer.MAX_VALUE - 10, 60)); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuardContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuardContractTest.java new file mode 100644 index 00000000..56ffd75d --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuardContractTest.java @@ -0,0 +1,66 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HousekeepingTargetRankGuardContractTest { + private static final List RANK_GUARDED_HANDLERS = List.of( + "HousekeepingBanUserEvent.java", + "HousekeepingForceDisconnectUserEvent.java", + "HousekeepingGiveCreditsEvent.java", + "HousekeepingGiveCurrencyEvent.java", + "HousekeepingGrantItemEvent.java", + "HousekeepingKickUserEvent.java", + "HousekeepingMuteUserEvent.java", + "HousekeepingResetUserPasswordEvent.java", + "HousekeepingSetHcSubscriptionEvent.java", + "HousekeepingTradeLockUserEvent.java", + "HousekeepingUnbanUserEvent.java" + ); + + @Test + void privilegedUserActionsRejectPeerRanksUnlessOperatorIsCoreRank() throws Exception { + String guard = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuard.java")); + + assertTrue(guard.contains("static boolean canTargetRank(Habbo operator, int targetRankId)"), + "rank comparison should be reusable for online and offline housekeeping targets"); + assertTrue(guard.contains("static boolean canAssignRank(Habbo operator, int rankId)"), + "rank assignment should use the same peer/core ceiling as target moderation"); + assertTrue(guard.contains("targetRankId < operatorRankId"), + "non-core housekeeping operators must only target lower-ranked users"); + assertTrue(guard.contains("isCoreRank(operatorRankId) && targetRankId <= operatorRankId"), + "the highest/core rank should be allowed to target peer ranks"); + assertTrue(guard.contains("private static boolean isCoreRank(int rankId)"), + "core-rank detection should be centralized in the target-rank guard"); + } + + @Test + void sensitiveHousekeepingUserActionsUseRankGuard() throws Exception { + Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping"); + + for (String handler : RANK_GUARDED_HANDLERS) { + String source = Files.readString(base.resolve(handler)); + assertTrue(source.contains("HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)"), + handler + " must reject equal or higher-ranked targets before applying privileged user actions"); + assertTrue(source.contains("housekeeping.error.rank_too_high"), + handler + " must return a rank-ceiling error when the target cannot be managed"); + } + } + + @Test + void housekeepingRankChangesUseCentralRankCeilings() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetUserRankEvent.java")); + + assertTrue(source.contains("HousekeepingTargetRankGuard.canAssignRank(this.client.getHabbo(), rank.getId())"), + "housekeeping rank assignment must not grant peer-or-higher ranks to non-core operators"); + assertTrue(source.contains("HousekeepingTargetRankGuard.canTargetRank(this.client.getHabbo(), targetRankId)"), + "housekeeping rank assignment must not modify peer-or-higher ranked targets for non-core operators"); + assertTrue(source.contains("housekeeping.error.user_not_found"), + "rank changes must reject missing offline users instead of reporting success for a zero-row update"); + } +}