From 9ac50600f689aacf1c36428cf877240cb22d651f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 14:09:10 +0200 Subject: [PATCH 01/11] fix(housekeeping): enforce target rank ceiling --- .../HousekeepingBanUserEvent.java | 5 ++ .../HousekeepingForceDisconnectUserEvent.java | 5 ++ .../HousekeepingGiveCreditsEvent.java | 5 ++ .../HousekeepingGiveCurrencyEvent.java | 5 ++ .../HousekeepingGrantItemEvent.java | 5 ++ .../HousekeepingKickUserEvent.java | 5 ++ .../HousekeepingMuteUserEvent.java | 5 ++ .../HousekeepingResetUserPasswordEvent.java | 5 ++ .../HousekeepingSetHcSubscriptionEvent.java | 5 ++ .../HousekeepingTargetRankGuard.java | 23 ++++++++++ .../HousekeepingTradeLockUserEvent.java | 5 ++ .../HousekeepingUnbanUserEvent.java | 5 ++ ...usekeepingTargetRankGuardContractTest.java | 46 +++++++++++++++++++ 13 files changed, 124 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuard.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuardContractTest.java 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..1b637fed 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 @@ -41,6 +41,11 @@ public class HousekeepingBanUserEvent 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; + } + long durationLong = (long) hours * SECONDS_IN_HOUR; int duration = durationLong > MAX_DURATION_SECONDS ? MAX_DURATION_SECONDS : (int) durationLong; 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..9ab0338c 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 @@ -40,6 +40,11 @@ public class HousekeepingForceDisconnectUserEvent 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 (reason != null && !reason.isEmpty()) { target.alert(reason); } 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..9b949df7 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 @@ -33,6 +33,11 @@ public class HousekeepingGiveCreditsEvent 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; + } + 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..f141c4ce 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 @@ -42,6 +42,11 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler { return; } + if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) { + this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, 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/HousekeepingGrantItemEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGrantItemEvent.java index cf1ecb6b..15ece6ad 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,11 @@ 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 (quantity > MAX_QUANTITY_PER_CALL) { quantity = MAX_QUANTITY_PER_CALL; } 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..7f2006cb 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 @@ -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; 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..4b35c838 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 @@ -44,6 +44,11 @@ public class HousekeepingMuteUserEvent 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; + } + target.mute(minutes * SECONDS_IN_MINUTE, false); if (reason != null && !reason.isEmpty()) { 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..87fe3e5a 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; 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..508c2a7c 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; 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..ee0e4965 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuard.java @@ -0,0 +1,23 @@ +package com.eu.habbo.messages.incoming.housekeeping; + +import com.eu.habbo.Emulator; +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 targetInfo.getRank().getId() < operator.getHabboInfo().getRank().getId(); + } +} 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..2c0a5669 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 @@ -43,6 +43,11 @@ public class HousekeepingTradeLockUserEvent 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; + } + long durationLong = (long) hours * SECONDS_IN_HOUR; int duration = durationLong > MAX_DURATION_SECONDS ? MAX_DURATION_SECONDS : (int) durationLong; int lockedUntil = Emulator.getIntUnixTimestamp() + duration; 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..548351fa 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,6 +34,11 @@ 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. 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..ed127abd --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuardContractTest.java @@ -0,0 +1,46 @@ +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 privilegedUserActionsRejectPeerAndHigherRankTargets() throws Exception { + String guard = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuard.java")); + + assertTrue(guard.contains("targetInfo.getRank().getId() < operator.getHabboInfo().getRank().getId()"), + "Housekeeping user actions must only target lower-ranked users"); + } + + @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"); + } + } +} From 2b18ca2debf15194f9332bfc74bf8699eca621d0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 20:20:00 +0200 Subject: [PATCH 02/11] fix(housekeeping): allow core rank peer actions Keep the housekeeping rank ceiling for normal staff, but treat the highest configured rank as the core rank so rank 7 can act on other rank 7 users without opening peer actions for lower staff ranks. Tests: mvn '-Dtest=HousekeepingTargetRankGuardContractTest,HousekeepingMutationGuardTest,HousekeepingSetUserRankEventTest,HousekeepingTargetRankGuardContractTest' test --- .../housekeeping/HousekeepingTargetRankGuard.java | 15 ++++++++++++++- .../HousekeepingTargetRankGuardContractTest.java | 10 +++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) 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 index ee0e4965..e5623a7d 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -18,6 +19,18 @@ final class HousekeepingTargetRankGuard { return true; } - return targetInfo.getRank().getId() < operator.getHabboInfo().getRank().getId(); + int operatorRankId = operator.getHabboInfo().getRank().getId(); + int targetRankId = targetInfo.getRank().getId(); + + return targetRankId < operatorRankId || isCoreRank(operatorRankId) && targetRankId <= operatorRankId; + } + + 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/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuardContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuardContractTest.java index ed127abd..eaa3fc5f 100644 --- 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 @@ -24,11 +24,15 @@ class HousekeepingTargetRankGuardContractTest { ); @Test - void privilegedUserActionsRejectPeerAndHigherRankTargets() throws Exception { + void privilegedUserActionsRejectPeerRanksUnlessOperatorIsCoreRank() throws Exception { String guard = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuard.java")); - assertTrue(guard.contains("targetInfo.getRank().getId() < operator.getHabboInfo().getRank().getId()"), - "Housekeeping user actions must only target lower-ranked users"); + 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 From 98aab95d581d7341609d7240c8d30c1f33edee23 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 15:44:09 +0200 Subject: [PATCH 03/11] fix(housekeeping): align audit log schema Housekeeping audit writes used an obsolete housekeeping_log schema with operator_id, operator_name, target_user_id and ip columns, while the migration and list composer read actor_id, actor_name, target_type, target_id, target_label, action, detail and success. That made log inserts fail against migrated databases and made auto-created tables unreadable by the client. Align the writer and auto-create DDL with the action-log schema, preserve operator IP in detail, and add a contract test for schema drift. --- .../modtool/HousekeepingAuditLog.java | 51 ++++++++++--------- .../HousekeepingAuditLogContractTest.java | 29 +++++++++++ 2 files changed, 55 insertions(+), 25 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLogContractTest.java 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/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"); + } +} From dbcf139a52a7d0b5bb3a06bc35945392679cbd33 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 15:49:35 +0200 Subject: [PATCH 04/11] fix(housekeeping): audit sensitive actions Several privileged housekeeping handlers returned success without appending an audit entry, so the action log stayed incomplete even after the log table schema was fixed. Add audit writes for ban, mute, password reset, HC changes, trade lock, item grants, room ownership transfer, and hotel alerts, and cover the expected logging surface with a contract test. --- .../HousekeepingBanUserEvent.java | 5 +++ .../HousekeepingGrantItemEvent.java | 5 +++ .../HousekeepingMuteUserEvent.java | 5 +++ .../HousekeepingResetUserPasswordEvent.java | 5 +++ .../HousekeepingSendHotelAlertEvent.java | 5 +++ .../HousekeepingSetHcSubscriptionEvent.java | 5 +++ .../HousekeepingTradeLockUserEvent.java | 5 +++ ...ousekeepingTransferRoomOwnershipEvent.java | 5 +++ ...HousekeepingAuditCoverageContractTest.java | 33 +++++++++++++++++++ 9 files changed, 73 insertions(+) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingAuditCoverageContractTest.java 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 1b637fed..9d8faf2f 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 @@ -61,6 +61,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=" + (reason != null ? 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/HousekeepingGrantItemEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGrantItemEvent.java index 15ece6ad..77da4407 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 @@ -62,6 +62,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/HousekeepingMuteUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteUserEvent.java index 4b35c838..68f4d346 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 @@ -55,6 +55,11 @@ public class HousekeepingMuteUserEvent extends MessageHandler { 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=" + (reason != null ? 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 87fe3e5a..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 @@ -79,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/HousekeepingSendHotelAlertEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSendHotelAlertEvent.java index e3a83daa..0f397c35 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 @@ -53,6 +53,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=" + 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 508c2a7c..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 @@ -76,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/HousekeepingTradeLockUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTradeLockUserEvent.java index 2c0a5669..5547da07 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 @@ -77,6 +77,11 @@ public class HousekeepingTradeLockUserEvent extends MessageHandler { } } + 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=" + (reason != null ? 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..eba00fb2 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 @@ -62,6 +62,11 @@ public class HousekeepingTransferRoomOwnershipEvent extends MessageHandler { return; } + 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/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..01981c7a --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingAuditCoverageContractTest.java @@ -0,0 +1,33 @@ +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", + "HousekeepingResetUserPasswordEvent.java", + "HousekeepingSetHcSubscriptionEvent.java", + "HousekeepingTradeLockUserEvent.java", + "HousekeepingGrantItemEvent.java", + "HousekeepingTransferRoomOwnershipEvent.java", + "HousekeepingSendHotelAlertEvent.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"); + } + } +} From 79d734ef26fc48bc22bc816db6721ceb7f429d0a Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 15:55:28 +0200 Subject: [PATCH 05/11] fix(housekeeping): audit room and session actions The first audit coverage pass covered economy/account-impacting HK actions, but room and session mutators still returned success without an audit row. Add audit entries for room deletion, force disconnect, room kicks, user kicks, room mute, room state changes, and successful unbans, and extend the coverage contract to keep these privileged actions tracked. --- .../housekeeping/HousekeepingDeleteRoomEvent.java | 5 +++++ .../HousekeepingForceDisconnectUserEvent.java | 5 +++++ .../housekeeping/HousekeepingKickAllFromRoomEvent.java | 5 +++++ .../incoming/housekeeping/HousekeepingKickUserEvent.java | 5 +++++ .../incoming/housekeeping/HousekeepingMuteRoomEvent.java | 5 +++++ .../housekeeping/HousekeepingRoomStateEvent.java | 5 +++++ .../housekeeping/HousekeepingUnbanUserEvent.java | 7 +++++++ .../HousekeepingAuditCoverageContractTest.java | 9 ++++++++- 8 files changed, 45 insertions(+), 1 deletion(-) 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..52702f23 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 @@ -63,6 +63,11 @@ public class HousekeepingDeleteRoomEvent extends MessageHandler { return; } + 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/HousekeepingForceDisconnectUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingForceDisconnectUserEvent.java index 9ab0338c..785523b3 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 @@ -52,6 +52,11 @@ public class HousekeepingForceDisconnectUserEvent extends MessageHandler { // 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=" + (reason != null ? 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/HousekeepingKickAllFromRoomEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingKickAllFromRoomEvent.java index 76ae8e82..72644201 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 @@ -36,6 +36,11 @@ public class HousekeepingKickAllFromRoomEvent extends MessageHandler { 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 7f2006cb..93ed2992 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 @@ -60,6 +60,11 @@ public class HousekeepingKickUserEvent extends MessageHandler { target.alert(reason); } + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "reason=" + (reason != null ? 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/HousekeepingMuteRoomEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteRoomEvent.java index b471affc..1d79f96f 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 @@ -45,6 +45,11 @@ public class HousekeepingMuteRoomEvent extends MessageHandler { 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/HousekeepingRoomStateEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomStateEvent.java index ea902db8..3cf5f984 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 @@ -44,6 +44,11 @@ public class HousekeepingRoomStateEvent extends MessageHandler { 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/HousekeepingUnbanUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingUnbanUserEvent.java index 548351fa..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 @@ -44,6 +44,13 @@ public class HousekeepingUnbanUserEvent extends MessageHandler { // 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/messages/incoming/housekeeping/HousekeepingAuditCoverageContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingAuditCoverageContractTest.java index 01981c7a..9171a4cd 100644 --- 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 @@ -17,7 +17,14 @@ class HousekeepingAuditCoverageContractTest { "HousekeepingTradeLockUserEvent.java", "HousekeepingGrantItemEvent.java", "HousekeepingTransferRoomOwnershipEvent.java", - "HousekeepingSendHotelAlertEvent.java" + "HousekeepingSendHotelAlertEvent.java", + "HousekeepingDeleteRoomEvent.java", + "HousekeepingForceDisconnectUserEvent.java", + "HousekeepingKickAllFromRoomEvent.java", + "HousekeepingKickUserEvent.java", + "HousekeepingMuteRoomEvent.java", + "HousekeepingRoomStateEvent.java", + "HousekeepingUnbanUserEvent.java" ); @Test From 4b81997e62553dcc94a110db5b27c86b5e55630e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 16:04:27 +0200 Subject: [PATCH 06/11] test(housekeeping): cover rank and currency audit logs Rank changes and manual currency grants are among the highest-risk housekeeping actions. They already write audit entries, but the coverage contract did not list them, so a future regression could silently remove those logs. Extend the contract test to require audit logging for credit grants, currency grants, and rank changes. --- .../housekeeping/HousekeepingAuditCoverageContractTest.java | 3 +++ 1 file changed, 3 insertions(+) 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 index 9171a4cd..5a81cae4 100644 --- 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 @@ -12,7 +12,10 @@ 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", From fe0ba3b9e9eb185b821579b40b9967de917a6a18 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 17:12:58 +0200 Subject: [PATCH 07/11] fix(housekeeping): validate grant mutations --- .../HousekeepingGiveCreditsEvent.java | 3 +- .../HousekeepingGiveCurrencyEvent.java | 8 ++- .../HousekeepingGrantItemEvent.java | 10 ++++ .../HousekeepingMutationGuard.java | 46 ++++++++++++++++ ...HousekeepingGrantMutationContractTest.java | 53 +++++++++++++++++++ .../HousekeepingMutationGuardTest.java | 24 +++++++++ 6 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMutationGuard.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGrantMutationContractTest.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMutationGuardTest.java 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 9b949df7..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,7 +27,7 @@ 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; } 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 f141c4ce..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,7 +36,7 @@ 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; } @@ -47,6 +46,11 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler { 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 77da4407..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 @@ -45,6 +45,16 @@ public class HousekeepingGrantItemEvent extends MessageHandler { 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; } 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/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/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)); + } +} From d9cf70910fcdb76baf8d9996666703addfcf39fd Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 17:21:37 +0200 Subject: [PATCH 08/11] fix(housekeeping): cap sanction durations safely --- .../HousekeepingBanUserEvent.java | 6 +-- .../HousekeepingMuteUserEvent.java | 3 +- .../HousekeepingSanctionDuration.java | 37 +++++++++++++++++ .../HousekeepingTradeLockUserEvent.java | 7 +--- ...sekeepingSanctionDurationContractTest.java | 40 +++++++++++++++++++ .../HousekeepingSanctionDurationTest.java | 21 ++++++++++ 6 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDuration.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDurationContractTest.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSanctionDurationTest.java 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 9d8faf2f..4d6e49f4 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() { @@ -46,8 +43,7 @@ public class HousekeepingBanUserEvent extends MessageHandler { return; } - long durationLong = (long) hours * SECONDS_IN_HOUR; - int duration = durationLong > MAX_DURATION_SECONDS ? MAX_DURATION_SECONDS : (int) durationLong; + int duration = HousekeepingSanctionDuration.secondsFromHours(hours); List bans = Emulator.getGameEnvironment().getModToolManager() .ban(userId, this.client.getHabbo(), reason != null ? reason : "", duration, ModToolBanType.ACCOUNT, 0); 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 68f4d346..ac019305 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() { @@ -49,7 +48,7 @@ public class HousekeepingMuteUserEvent extends MessageHandler { return; } - target.mute(minutes * SECONDS_IN_MINUTE, false); + target.mute(HousekeepingSanctionDuration.secondsFromMinutes(minutes), false); if (reason != null && !reason.isEmpty()) { target.alert(reason); 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/HousekeepingTradeLockUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTradeLockUserEvent.java index 5547da07..0b284115 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() { @@ -48,9 +46,8 @@ public class HousekeepingTradeLockUserEvent extends MessageHandler { return; } - long durationLong = (long) hours * SECONDS_IN_HOUR; - int duration = durationLong > MAX_DURATION_SECONDS ? MAX_DURATION_SECONDS : (int) durationLong; - int lockedUntil = Emulator.getIntUnixTimestamp() + duration; + 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")) { 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)); + } +} From 31027095ec622c42a7332a268106ae5d777c5a26 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 21:55:19 +0200 Subject: [PATCH 09/11] fix(housekeeping): enforce rank ceilings on rank changes --- .../HousekeepingSetUserRankEvent.java | 15 +++++++-------- .../HousekeepingTargetRankGuard.java | 13 ++++++++++++- .../HousekeepingTargetRankGuardContractTest.java | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) 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 index e5623a7d..3488829a 100644 --- 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 @@ -19,12 +19,23 @@ final class HousekeepingTargetRankGuard { 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(); - int targetRankId = targetInfo.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()) { 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 index eaa3fc5f..56ffd75d 100644 --- 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 @@ -27,6 +27,10 @@ class HousekeepingTargetRankGuardContractTest { 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"), @@ -47,4 +51,16 @@ class HousekeepingTargetRankGuardContractTest { 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"); + } } From 93c4565660b65fcb8e9cff650f0d91b8589730c8 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 22:14:41 +0200 Subject: [PATCH 10/11] fix(housekeeping): bound staff supplied text --- .../HousekeepingBanUserEvent.java | 8 +-- .../HousekeepingFindUserByNameEvent.java | 4 +- .../HousekeepingForceDisconnectUserEvent.java | 8 +-- .../housekeeping/HousekeepingInputGuard.java | 27 ++++++++++ .../HousekeepingKickUserEvent.java | 8 +-- .../HousekeepingMuteUserEvent.java | 8 +-- .../HousekeepingSearchRoomsEvent.java | 7 +-- .../HousekeepingSendHotelAlertEvent.java | 11 ++-- .../HousekeepingTradeLockUserEvent.java | 8 +-- .../HousekeepingInputGuardContractTest.java | 53 +++++++++++++++++++ .../HousekeepingInputGuardTest.java | 32 +++++++++++ 11 files changed, 144 insertions(+), 30 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuard.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuardContractTest.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingInputGuardTest.java 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 4d6e49f4..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 @@ -30,10 +30,10 @@ 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; } @@ -46,7 +46,7 @@ public class HousekeepingBanUserEvent extends MessageHandler { 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")); @@ -60,7 +60,7 @@ public class HousekeepingBanUserEvent extends MessageHandler { com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( this.client.getHabbo().getHabboInfo().getId(), this.client.getHabbo().getHabboInfo().getUsername(), - ACTION_KEY, userId, "hours=" + hours + " reason=" + (reason != null ? reason : ""), + 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/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 785523b3..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; } @@ -45,7 +45,7 @@ public class HousekeepingForceDisconnectUserEvent extends MessageHandler { return; } - if (reason != null && !reason.isEmpty()) { + if (!reason.isEmpty()) { target.alert(reason); } @@ -55,7 +55,7 @@ public class HousekeepingForceDisconnectUserEvent extends MessageHandler { com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( this.client.getHabbo().getHabboInfo().getId(), this.client.getHabbo().getHabboInfo().getUsername(), - ACTION_KEY, userId, "reason=" + (reason != null ? reason : ""), + 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/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/HousekeepingKickUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingKickUserEvent.java index 93ed2992..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; } @@ -56,14 +56,14 @@ 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=" + (reason != null ? reason : ""), + 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/HousekeepingMuteUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingMuteUserEvent.java index ac019305..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 @@ -28,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; } @@ -50,14 +50,14 @@ public class HousekeepingMuteUserEvent extends MessageHandler { target.mute(HousekeepingSanctionDuration.secondsFromMinutes(minutes), false); - 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, "minutes=" + minutes + " reason=" + (reason != null ? reason : ""), + 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/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 0f397c35..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(); @@ -56,7 +61,7 @@ public class HousekeepingSendHotelAlertEvent extends MessageHandler { com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( this.client.getHabbo().getHabboInfo().getId(), this.client.getHabbo().getHabboInfo().getUsername(), - ACTION_KEY, 0, "reached=" + reached + " message=" + message, + 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/HousekeepingTradeLockUserEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTradeLockUserEvent.java index 0b284115..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 @@ -34,9 +34,9 @@ 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; } @@ -69,7 +69,7 @@ public class HousekeepingTradeLockUserEvent extends MessageHandler { if (online != null) { online.getHabboStats().setAllowTrade(false); - if (reason != null && !reason.isEmpty()) { + if (!reason.isEmpty()) { online.alert(reason); } } @@ -77,7 +77,7 @@ public class HousekeepingTradeLockUserEvent extends MessageHandler { 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=" + (reason != null ? reason : ""), + 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/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()); + } +} From ec24283e0ff39416b2d904cfcd7919adb3a08270 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 22:17:47 +0200 Subject: [PATCH 11/11] fix(housekeeping): protect room owner mutations --- .../HousekeepingDeleteRoomEvent.java | 18 +++++-- .../HousekeepingKickAllFromRoomEvent.java | 5 ++ .../HousekeepingMuteRoomEvent.java | 7 ++- .../housekeeping/HousekeepingRoomGuard.java | 13 +++++ .../HousekeepingRoomStateEvent.java | 5 ++ ...ousekeepingTransferRoomOwnershipEvent.java | 17 +++++++ .../HousekeepingRoomGuardContractTest.java | 47 +++++++++++++++++++ 7 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomGuard.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingRoomGuardContractTest.java 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 52702f23..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,11 @@ 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(), 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 72644201..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,6 +34,11 @@ 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( 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 1d79f96f..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,6 +43,11 @@ 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( 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 3cf5f984..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,6 +41,11 @@ 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(); 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 eba00fb2..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,9 @@ 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(), 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"); + } +}