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