Merge pull request #203 from simoleo89/fix/housekeeping-core-peer-rank

fix(housekeeping): harden privileged staff actions
This commit is contained in:
DuckieTM
2026-06-15 07:24:55 +02:00
committed by GitHub
36 changed files with 836 additions and 79 deletions
@@ -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);
@@ -17,9 +17,6 @@ import java.util.List;
*/
public class HousekeepingBanUserEvent extends MessageHandler {
private static final String ACTION_KEY = "user.ban";
private static final int SECONDS_IN_HOUR = 3600;
// 100-year ceiling, matches ModToolSanctionBanEvent's permanent ban.
private static final int MAX_DURATION_SECONDS = 100 * 365 * 24 * 3600;
@Override
public int getRatelimit() {
@@ -33,19 +30,23 @@ public class HousekeepingBanUserEvent extends MessageHandler {
}
int userId = this.packet.readInt();
String reason = this.packet.readString();
String reason = HousekeepingInputGuard.normalize(this.packet.readString());
int hours = this.packet.readInt();
if (userId <= 0 || hours <= 0) {
if (userId <= 0 || hours <= 0 || !HousekeepingInputGuard.isWithinLimit(reason, HousekeepingInputGuard.MAX_REASON_LENGTH)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input"));
return;
}
long durationLong = (long) hours * SECONDS_IN_HOUR;
int duration = durationLong > MAX_DURATION_SECONDS ? MAX_DURATION_SECONDS : (int) durationLong;
if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
int duration = HousekeepingSanctionDuration.secondsFromHours(hours);
List<ModToolBan> bans = Emulator.getGameEnvironment().getModToolManager()
.ban(userId, this.client.getHabbo(), reason != null ? reason : "", duration, ModToolBanType.ACCOUNT, 0);
.ban(userId, this.client.getHabbo(), reason, duration, ModToolBanType.ACCOUNT, 0);
if (bans == null || bans.isEmpty()) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.ban_failed"));
@@ -56,6 +57,11 @@ public class HousekeepingBanUserEvent extends MessageHandler {
// object, so we return the target user id as the actionId — it's
// the only stable handle the client can use until a dedicated
// housekeeping_log row id supersedes it.
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, userId, "hours=" + hours + " reason=" + HousekeepingInputGuard.auditValue(reason),
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, ""));
}
}
@@ -42,11 +42,14 @@ public class HousekeepingDeleteRoomEvent extends MessageHandler {
Room room = Emulator.getGameEnvironment().getRoomManager().loadRoom(roomId, false);
if (room != null) {
room.ejectAll();
room.preventUnloading = false;
room.dispose();
Emulator.getGameEnvironment().getRoomManager().uncacheRoom(room);
if (room == null) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.room_not_found"));
return;
}
if (!HousekeepingRoomGuard.canManageRoom(this.client.getHabbo(), room)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
@@ -63,6 +66,16 @@ public class HousekeepingDeleteRoomEvent extends MessageHandler {
return;
}
room.ejectAll();
room.preventUnloading = false;
room.dispose();
Emulator.getGameEnvironment().getRoomManager().uncacheRoom(room);
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, 0, "roomId=" + roomId,
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, roomId, ""));
}
}
@@ -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;
}
@@ -26,9 +26,9 @@ public class HousekeepingForceDisconnectUserEvent extends MessageHandler {
}
int userId = this.packet.readInt();
String reason = this.packet.readString();
String reason = HousekeepingInputGuard.normalize(this.packet.readString());
if (userId <= 0) {
if (userId <= 0 || !HousekeepingInputGuard.isWithinLimit(reason, HousekeepingInputGuard.MAX_REASON_LENGTH)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input"));
return;
}
@@ -40,13 +40,23 @@ public class HousekeepingForceDisconnectUserEvent extends MessageHandler {
return;
}
if (reason != null && !reason.isEmpty()) {
if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
if (!reason.isEmpty()) {
target.alert(reason);
}
// ACK first so the action result lands before the target's socket
// closes (otherwise an alerted user on the same emulator thread may
// already be torn down when we try to write).
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, userId, "reason=" + HousekeepingInputGuard.auditValue(reason),
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, ""));
target.disconnect();
@@ -12,7 +12,6 @@ import java.sql.SQLException;
public class HousekeepingGiveCreditsEvent extends MessageHandler {
private static final String ACTION_KEY = "user.give_credits";
private static final int MAX_GRANT = 1_000_000_000;
@Override
public int getRatelimit() {
@@ -28,11 +27,16 @@ public class HousekeepingGiveCreditsEvent extends MessageHandler {
int userId = this.packet.readInt();
int amount = this.packet.readInt();
if (userId <= 0 || amount == 0 || amount < -MAX_GRANT || amount > MAX_GRANT) {
if (userId <= 0 || !HousekeepingMutationGuard.isPositiveGrantAmount(amount)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input"));
return;
}
if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
Habbo online = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId);
if (online != null) {
@@ -18,7 +18,6 @@ import java.sql.SQLException;
*/
public class HousekeepingGiveCurrencyEvent extends MessageHandler {
private static final int CURRENCY_DUCKETS = 0;
private static final int MAX_GRANT = 1_000_000_000;
@Override
public int getRatelimit() {
@@ -37,11 +36,21 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler {
String actionKey = "user.give_currency_" + currencyType;
if (userId <= 0 || amount == 0 || amount < -MAX_GRANT || amount > MAX_GRANT) {
if (userId <= 0 || !HousekeepingMutationGuard.isCurrencyType(currencyType) || !HousekeepingMutationGuard.isPositiveGrantAmount(amount)) {
this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.invalid_input"));
return;
}
if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.rank_too_high"));
return;
}
if (!HousekeepingMutationGuard.userExists(userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.user_not_found"));
return;
}
Habbo online = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId);
if (online != null) {
@@ -40,6 +40,21 @@ public class HousekeepingGrantItemEvent extends MessageHandler {
return;
}
if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
if (!HousekeepingMutationGuard.userExists(userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.user_not_found"));
return;
}
if (!HousekeepingMutationGuard.itemExists(itemId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.item_not_found"));
return;
}
if (quantity > MAX_QUANTITY_PER_CALL) {
quantity = MAX_QUANTITY_PER_CALL;
}
@@ -57,6 +72,11 @@ public class HousekeepingGrantItemEvent extends MessageHandler {
return;
}
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, userId, "itemId=" + itemId + " quantity=" + quantity,
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, ""));
}
}
@@ -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;
}
}
@@ -34,8 +34,18 @@ public class HousekeepingKickAllFromRoomEvent extends MessageHandler {
return;
}
if (!HousekeepingRoomGuard.canManageRoom(this.client.getHabbo(), room)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
room.ejectAll();
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, 0, "roomId=" + roomId,
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, roomId, ""));
}
}
@@ -28,9 +28,9 @@ public class HousekeepingKickUserEvent extends MessageHandler {
}
int userId = this.packet.readInt();
String reason = this.packet.readString();
String reason = HousekeepingInputGuard.normalize(this.packet.readString());
if (userId <= 0) {
if (userId <= 0 || !HousekeepingInputGuard.isWithinLimit(reason, HousekeepingInputGuard.MAX_REASON_LENGTH)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input"));
return;
}
@@ -42,6 +42,11 @@ public class HousekeepingKickUserEvent extends MessageHandler {
return;
}
if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
if (target.hasPermission(Permission.ACC_UNKICKABLE)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.target_unkickable"));
return;
@@ -51,10 +56,15 @@ public class HousekeepingKickUserEvent extends MessageHandler {
Emulator.getGameEnvironment().getRoomManager().leaveRoom(target, target.getHabboInfo().getCurrentRoom());
}
if (reason != null && !reason.isEmpty()) {
if (!reason.isEmpty()) {
target.alert(reason);
}
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, userId, "reason=" + HousekeepingInputGuard.auditValue(reason),
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, ""));
}
}
@@ -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;
}
}
@@ -31,7 +31,7 @@ public class HousekeepingMuteRoomEvent extends MessageHandler {
int roomId = this.packet.readInt();
int minutes = this.packet.readInt();
if (roomId <= 0) {
if (roomId <= 0 || minutes < 0) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input"));
return;
}
@@ -43,8 +43,18 @@ public class HousekeepingMuteRoomEvent extends MessageHandler {
return;
}
if (!HousekeepingRoomGuard.canManageRoom(this.client.getHabbo(), room)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
room.setMuted(minutes > 0);
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, 0, "roomId=" + roomId + " minutes=" + minutes + " muted=" + (minutes > 0),
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, roomId, ""));
}
}
@@ -15,7 +15,6 @@ import com.eu.habbo.messages.outgoing.housekeeping.HousekeepingActionResultCompo
*/
public class HousekeepingMuteUserEvent extends MessageHandler {
private static final String ACTION_KEY = "user.mute";
private static final int SECONDS_IN_MINUTE = 60;
@Override
public int getRatelimit() {
@@ -29,10 +28,10 @@ public class HousekeepingMuteUserEvent extends MessageHandler {
}
int userId = this.packet.readInt();
String reason = this.packet.readString();
String reason = HousekeepingInputGuard.normalize(this.packet.readString());
int minutes = this.packet.readInt();
if (userId <= 0 || minutes <= 0) {
if (userId <= 0 || minutes <= 0 || !HousekeepingInputGuard.isWithinLimit(reason, HousekeepingInputGuard.MAX_REASON_LENGTH)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input"));
return;
}
@@ -44,12 +43,22 @@ public class HousekeepingMuteUserEvent extends MessageHandler {
return;
}
target.mute(minutes * SECONDS_IN_MINUTE, false);
if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
if (reason != null && !reason.isEmpty()) {
target.mute(HousekeepingSanctionDuration.secondsFromMinutes(minutes), false);
if (!reason.isEmpty()) {
target.alert(reason);
}
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, userId, "minutes=" + minutes + " reason=" + HousekeepingInputGuard.auditValue(reason),
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, ""));
}
}
@@ -46,6 +46,11 @@ public class HousekeepingResetUserPasswordEvent extends MessageHandler {
return;
}
if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
String plain = randomPassword();
String hash;
@@ -74,6 +79,11 @@ public class HousekeepingResetUserPasswordEvent extends MessageHandler {
// Plaintext flows through `message` — the client surfaces it via the
// status banner so the operator can read it once. SSL is on the
// operator: the only secure transport for the WS is wss://.
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, userId, "password_reset=1",
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, plain));
}
@@ -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());
}
}
@@ -41,9 +41,19 @@ public class HousekeepingRoomStateEvent extends MessageHandler {
return;
}
if (!HousekeepingRoomGuard.canManageRoom(this.client.getHabbo(), room)) {
this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.rank_too_high"));
return;
}
room.setState(open ? RoomState.OPEN : RoomState.LOCKED);
room.save();
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
actionKey, 0, "roomId=" + roomId + " open=" + open,
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, true, roomId, ""));
}
}
@@ -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;
}
}
@@ -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;
}
@@ -31,13 +31,18 @@ public class HousekeepingSendHotelAlertEvent extends MessageHandler {
return;
}
String message = this.packet.readString();
String message = HousekeepingInputGuard.normalize(this.packet.readString());
if (message == null || message.trim().isEmpty()) {
if (message.isEmpty()) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.alert_empty"));
return;
}
if (!HousekeepingInputGuard.isWithinLimit(message, HousekeepingInputGuard.MAX_ALERT_LENGTH)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.input_too_long"));
return;
}
String body = message + "\r\n-" + this.client.getHabbo().getHabboInfo().getUsername();
ServerMessage broadcast = new StaffAlertWithLinkComposer(body, "").compose();
@@ -53,6 +58,11 @@ public class HousekeepingSendHotelAlertEvent extends MessageHandler {
reached++;
}
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, 0, "reached=" + reached + " message=" + HousekeepingInputGuard.auditValue(message),
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, reached, ""));
}
}
@@ -38,6 +38,11 @@ public class HousekeepingSetHcSubscriptionEvent extends MessageHandler {
return;
}
if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
int now = Emulator.getIntUnixTimestamp();
int newExpire;
@@ -71,6 +76,11 @@ public class HousekeepingSetHcSubscriptionEvent extends MessageHandler {
return;
}
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, userId, "days=" + days + " expire=" + newExpire,
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, ""));
}
}
@@ -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;
}
@@ -0,0 +1,47 @@
package com.eu.habbo.messages.incoming.housekeeping;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.permissions.Rank;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.habbohotel.users.HabboInfo;
final class HousekeepingTargetRankGuard {
private HousekeepingTargetRankGuard() {
}
static boolean canTargetUser(Habbo operator, int targetUserId) {
if (operator == null || targetUserId <= 0) {
return false;
}
HabboInfo targetInfo = Emulator.getGameEnvironment().getHabboManager().getHabboInfo(targetUserId);
if (targetInfo == null) {
return true;
}
return canTargetRank(operator, targetInfo.getRank().getId());
}
static boolean canTargetRank(Habbo operator, int targetRankId) {
if (operator == null || targetRankId <= 0) {
return false;
}
int operatorRankId = operator.getHabboInfo().getRank().getId();
return targetRankId < operatorRankId || isCoreRank(operatorRankId) && targetRankId <= operatorRankId;
}
static boolean canAssignRank(Habbo operator, int rankId) {
return canTargetRank(operator, rankId);
}
private static boolean isCoreRank(int rankId) {
int highestRankId = 0;
for (Rank rank : Emulator.getGameEnvironment().getPermissionsManager().getAllRanks()) {
highestRankId = Math.max(highestRankId, rank.getId());
}
return highestRankId > 0 && rankId >= highestRankId;
}
}
@@ -20,8 +20,6 @@ import java.sql.SQLException;
*/
public class HousekeepingTradeLockUserEvent extends MessageHandler {
private static final String ACTION_KEY = "user.trade_lock";
private static final int SECONDS_IN_HOUR = 3600;
private static final int MAX_DURATION_SECONDS = 100 * 365 * 24 * 3600;
@Override
public int getRatelimit() {
@@ -36,16 +34,20 @@ public class HousekeepingTradeLockUserEvent extends MessageHandler {
int userId = this.packet.readInt();
int hours = this.packet.readInt();
String reason = this.packet.readString();
String reason = HousekeepingInputGuard.normalize(this.packet.readString());
if (userId <= 0 || hours <= 0) {
if (userId <= 0 || hours <= 0 || !HousekeepingInputGuard.isWithinLimit(reason, HousekeepingInputGuard.MAX_REASON_LENGTH)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input"));
return;
}
long durationLong = (long) hours * SECONDS_IN_HOUR;
int duration = durationLong > MAX_DURATION_SECONDS ? MAX_DURATION_SECONDS : (int) durationLong;
int lockedUntil = Emulator.getIntUnixTimestamp() + duration;
if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
int duration = HousekeepingSanctionDuration.secondsFromHours(hours);
int lockedUntil = HousekeepingSanctionDuration.unixUntil(Emulator.getIntUnixTimestamp(), duration);
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET trade_locked_until = ? WHERE user_id = ? LIMIT 1")) {
@@ -67,11 +69,16 @@ public class HousekeepingTradeLockUserEvent extends MessageHandler {
if (online != null) {
online.getHabboStats().setAllowTrade(false);
if (reason != null && !reason.isEmpty()) {
if (!reason.isEmpty()) {
online.alert(reason);
}
}
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, userId, "hours=" + hours + " lockedUntil=" + lockedUntil + " reason=" + HousekeepingInputGuard.auditValue(reason),
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, ""));
}
}
@@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.housekeeping;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.permissions.Permission;
import com.eu.habbo.habbohotel.rooms.Room;
import com.eu.habbo.habbohotel.users.HabboInfo;
import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.housekeeping.HousekeepingActionResultComposer;
@@ -39,6 +40,19 @@ public class HousekeepingTransferRoomOwnershipEvent extends MessageHandler {
return;
}
Room room = Emulator.getGameEnvironment().getRoomManager().loadRoom(roomId, false);
if (room == null) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.room_not_found"));
return;
}
if (!HousekeepingRoomGuard.canManageRoom(this.client.getHabbo(), room) ||
!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), newOwnerId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
HabboInfo newOwner = Emulator.getGameEnvironment().getHabboManager().getHabboInfo(newOwnerId);
if (newOwner == null) {
@@ -62,6 +76,14 @@ public class HousekeepingTransferRoomOwnershipEvent extends MessageHandler {
return;
}
room.setOwnerId(newOwnerId);
room.setOwnerName(newOwner.getUsername());
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, newOwnerId, "roomId=" + roomId + " newOwner=" + newOwner.getUsername(),
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, roomId, ""));
}
}
@@ -34,11 +34,23 @@ public class HousekeepingUnbanUserEvent extends MessageHandler {
return;
}
if (!HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
// ModToolManager.unban only takes a username; the SQL UPDATE
// happens against active bans (ban_expire > now), so calling it
// on a never-banned user is a benign no-op that returns false.
boolean cleared = Emulator.getGameEnvironment().getModToolManager().unban(info.getUsername());
if (cleared) {
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, userId, "username=" + info.getUsername(),
this.client.getHabbo().getHabboInfo().getIpLogin());
}
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, cleared, cleared ? userId : 0, cleared ? "" : "housekeeping.error.no_active_ban"));
}
}
@@ -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");
}
}
@@ -0,0 +1,43 @@
package com.eu.habbo.messages.incoming.housekeeping;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HousekeepingAuditCoverageContractTest {
private static final List<String> SENSITIVE_HANDLERS = List.of(
"HousekeepingBanUserEvent.java",
"HousekeepingMuteUserEvent.java",
"HousekeepingGiveCreditsEvent.java",
"HousekeepingGiveCurrencyEvent.java",
"HousekeepingResetUserPasswordEvent.java",
"HousekeepingSetUserRankEvent.java",
"HousekeepingSetHcSubscriptionEvent.java",
"HousekeepingTradeLockUserEvent.java",
"HousekeepingGrantItemEvent.java",
"HousekeepingTransferRoomOwnershipEvent.java",
"HousekeepingSendHotelAlertEvent.java",
"HousekeepingDeleteRoomEvent.java",
"HousekeepingForceDisconnectUserEvent.java",
"HousekeepingKickAllFromRoomEvent.java",
"HousekeepingKickUserEvent.java",
"HousekeepingMuteRoomEvent.java",
"HousekeepingRoomStateEvent.java",
"HousekeepingUnbanUserEvent.java"
);
@Test
void sensitiveHousekeepingActionsWriteAuditEntries() throws Exception {
Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping");
for (String handler : SENSITIVE_HANDLERS) {
String source = Files.readString(base.resolve(handler));
assertTrue(source.contains("HousekeepingAuditLog.log"),
handler + " must append a housekeeping audit log entry after successful privileged actions");
}
}
}
@@ -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");
}
}
@@ -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");
}
}
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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");
}
}
@@ -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"));
}
}
@@ -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));
}
}
@@ -0,0 +1,66 @@
package com.eu.habbo.messages.incoming.housekeeping;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HousekeepingTargetRankGuardContractTest {
private static final List<String> RANK_GUARDED_HANDLERS = List.of(
"HousekeepingBanUserEvent.java",
"HousekeepingForceDisconnectUserEvent.java",
"HousekeepingGiveCreditsEvent.java",
"HousekeepingGiveCurrencyEvent.java",
"HousekeepingGrantItemEvent.java",
"HousekeepingKickUserEvent.java",
"HousekeepingMuteUserEvent.java",
"HousekeepingResetUserPasswordEvent.java",
"HousekeepingSetHcSubscriptionEvent.java",
"HousekeepingTradeLockUserEvent.java",
"HousekeepingUnbanUserEvent.java"
);
@Test
void privilegedUserActionsRejectPeerRanksUnlessOperatorIsCoreRank() throws Exception {
String guard = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingTargetRankGuard.java"));
assertTrue(guard.contains("static boolean canTargetRank(Habbo operator, int targetRankId)"),
"rank comparison should be reusable for online and offline housekeeping targets");
assertTrue(guard.contains("static boolean canAssignRank(Habbo operator, int rankId)"),
"rank assignment should use the same peer/core ceiling as target moderation");
assertTrue(guard.contains("targetRankId < operatorRankId"),
"non-core housekeeping operators must only target lower-ranked users");
assertTrue(guard.contains("isCoreRank(operatorRankId) && targetRankId <= operatorRankId"),
"the highest/core rank should be allowed to target peer ranks");
assertTrue(guard.contains("private static boolean isCoreRank(int rankId)"),
"core-rank detection should be centralized in the target-rank guard");
}
@Test
void sensitiveHousekeepingUserActionsUseRankGuard() throws Exception {
Path base = Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping");
for (String handler : RANK_GUARDED_HANDLERS) {
String source = Files.readString(base.resolve(handler));
assertTrue(source.contains("HousekeepingTargetRankGuard.canTargetUser(this.client.getHabbo(), userId)"),
handler + " must reject equal or higher-ranked targets before applying privileged user actions");
assertTrue(source.contains("housekeeping.error.rank_too_high"),
handler + " must return a rank-ceiling error when the target cannot be managed");
}
}
@Test
void housekeepingRankChangesUseCentralRankCeilings() throws Exception {
String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetUserRankEvent.java"));
assertTrue(source.contains("HousekeepingTargetRankGuard.canAssignRank(this.client.getHabbo(), rank.getId())"),
"housekeeping rank assignment must not grant peer-or-higher ranks to non-core operators");
assertTrue(source.contains("HousekeepingTargetRankGuard.canTargetRank(this.client.getHabbo(), targetRankId)"),
"housekeeping rank assignment must not modify peer-or-higher ranked targets for non-core operators");
assertTrue(source.contains("housekeeping.error.user_not_found"),
"rank changes must reject missing offline users instead of reporting success for a zero-row update");
}
}