From 60ccc8c80b23a2c068cb3f177402ded54e46347c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:28:09 +0200 Subject: [PATCH 1/4] fix(items): require seed ownership for monsterplants Reject monsterplant seed redemption when the caller does not own the placed seed. Without this guard, a user in the same room could trigger ToggleFloorItemEvent against another user's seed and have the server delete that item while creating the monsterplant pet for the attacker. Add a contract test covering the ownership guard before createMonsterplant is reached. --- .../rooms/items/ToggleFloorItemEvent.java | 4 +++ ...MonsterPlantSeedOwnershipContractTest.java | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/MonsterPlantSeedOwnershipContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/ToggleFloorItemEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/ToggleFloorItemEvent.java index 45098095..8bf4942b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/ToggleFloorItemEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/ToggleFloorItemEvent.java @@ -103,6 +103,10 @@ public class ToggleFloorItemEvent extends MessageHandler { // Do not move to onClick(). Wired could trigger it. if (item instanceof InteractionMonsterPlantSeed) { + if (item.getUserId() != this.client.getHabbo().getHabboInfo().getId()) { + return; + } + Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId())); boolean isRare = item.getBaseItem().getName().contains("rare"); diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/MonsterPlantSeedOwnershipContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/MonsterPlantSeedOwnershipContractTest.java new file mode 100644 index 00000000..e8516c19 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/MonsterPlantSeedOwnershipContractTest.java @@ -0,0 +1,29 @@ +package com.eu.habbo.messages.incoming.rooms.items; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MonsterPlantSeedOwnershipContractTest { + @Test + void monsterPlantSeedsCanOnlyBeRedeemedByTheirOwner() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/rooms/items/ToggleFloorItemEvent.java")); + int seedBranch = source.indexOf("item instanceof InteractionMonsterPlantSeed"); + + assertTrue(seedBranch >= 0, "ToggleFloorItemEvent must keep monsterplant seed handling explicit"); + + String seedHandling = source.substring(seedBranch, Math.min(source.length(), seedBranch + 1400)); + + String ownershipGuard = "if (item.getUserId() != this.client.getHabbo().getHabboInfo().getId())"; + + assertTrue(seedHandling.contains(ownershipGuard), + "Monsterplant seed redemption must reject callers who do not own the seed"); + assertTrue(seedHandling.contains("createMonsterplant"), + "Monsterplant seed handling must create the pet inside the guarded branch"); + assertTrue(seedHandling.indexOf(ownershipGuard) < seedHandling.indexOf("createMonsterplant"), + "Ownership rejection must happen before creating the pet"); + } +} From 82c6f3f9ff858cb164341f685f17fb1b910fc067 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 17:34:55 +0200 Subject: [PATCH 2/4] fix(items): charge rentable space purchases Deduct the computed rent cost when a user rents an InteractionRentableSpace. The previous flow only checked that the user had enough credits, then marked the space as rented without charging them, allowing free weekly rentals. Honor ACC_INFINITE_CREDITS for staff accounts and add a contract test that keeps the charge before the rented state is assigned. --- .../InteractionRentableSpace.java | 9 +++++- .../RentableSpaceChargeContractTest.java | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/items/interactions/RentableSpaceChargeContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionRentableSpace.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionRentableSpace.java index 8bd18bd3..e900e4f6 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionRentableSpace.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionRentableSpace.java @@ -3,6 +3,7 @@ package com.eu.habbo.habbohotel.items.interactions; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.gameclients.GameClient; import com.eu.habbo.habbohotel.items.Item; +import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.habbohotel.rooms.RoomLayout; import com.eu.habbo.habbohotel.rooms.RoomUnit; @@ -133,12 +134,18 @@ public class InteractionRentableSpace extends HabboItem { if (habbo.getHabboStats().isRentingSpace()) return; - if (habbo.getHabboInfo().getCredits() < this.rentCost()) + int cost = this.rentCost(); + boolean hasInfiniteCredits = habbo.hasPermission(Permission.ACC_INFINITE_CREDITS); + if (!hasInfiniteCredits && habbo.getHabboInfo().getCredits() < cost) return; if (habbo.getHabboStats().getClubExpireTimestamp() < Emulator.getIntUnixTimestamp()) return; + if (!hasInfiniteCredits) { + habbo.giveCredits(-cost); + } + this.setRenterId(habbo.getHabboInfo().getId()); this.setRenterName(habbo.getHabboInfo().getUsername()); this.setEndTimestamp(Emulator.getIntUnixTimestamp() + (7 * 86400)); diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/interactions/RentableSpaceChargeContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/interactions/RentableSpaceChargeContractTest.java new file mode 100644 index 00000000..24cb1818 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/interactions/RentableSpaceChargeContractTest.java @@ -0,0 +1,31 @@ +package com.eu.habbo.habbohotel.items.interactions; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RentableSpaceChargeContractTest { + @Test + void rentingSpaceChargesCreditsBeforeMarkingSpaceRented() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionRentableSpace.java")); + int rentMethod = source.indexOf("public void rent(Habbo habbo)"); + + assertTrue(rentMethod >= 0, "InteractionRentableSpace must keep explicit rent handling"); + + String rentHandling = source.substring(rentMethod, Math.min(source.length(), rentMethod + 1400)); + + assertTrue(rentHandling.contains("int cost = this.rentCost();"), + "Rent cost must be computed once before charging"); + assertTrue(rentHandling.contains("boolean hasInfiniteCredits = habbo.hasPermission(Permission.ACC_INFINITE_CREDITS);"), + "Renting must honor infinite-credit staff permission before charging"); + assertTrue(rentHandling.contains("!hasInfiniteCredits && habbo.getHabboInfo().getCredits() < cost"), + "Renting must reject non-staff users without enough credits for the computed cost"); + assertTrue(rentHandling.contains("habbo.giveCredits(-cost);"), + "Renting must deduct the computed credit cost"); + assertTrue(rentHandling.indexOf("habbo.giveCredits(-cost);") < rentHandling.indexOf("this.setRenterId"), + "Credits must be charged before the rentable space is marked as rented"); + } +} From 39c6e2409756e117c6663d31e3f2e8f9bf2ed483 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 20:39:51 +0200 Subject: [PATCH 3/4] fix(items): persist clothing grants before redeem Redeeming clothing furniture now inserts the wardrobe grant before removing/deleting the voucher furniture. If the DB insert fails, the item remains in the room and the in-memory wardrobe is not updated. Tests: mvn -Dtest=RedeemClothingContractTest test; mvn -DskipTests package --- .../rooms/items/RedeemClothingEvent.java | 23 ++++++++++----- .../items/RedeemClothingContractTest.java | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingEvent.java index 496d521d..2ab95a0d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingEvent.java @@ -36,6 +36,10 @@ public class RedeemClothingEvent extends MessageHandler { if (clothing != null) { if (!this.client.getHabbo().getInventory().getWardrobeComponent().getClothing().contains(clothing.id)) { + if (!this.grantClothing(clothing.id)) { + return; + } + item.setRoomId(0); RoomTile tile = this.client.getHabbo().getHabboInfo().getCurrentRoom().getLayout().getTile(item.getX(), item.getY()); this.client.getHabbo().getHabboInfo().getCurrentRoom().removeHabboItem(item); @@ -44,14 +48,6 @@ public class RedeemClothingEvent extends MessageHandler { this.client.getHabbo().getHabboInfo().getCurrentRoom().sendComposer(new RemoveFloorItemComposer(item, true).compose()); Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId())); - try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO users_clothing (user_id, clothing_id) VALUES (?, ?)")) { - statement.setInt(1, this.client.getHabbo().getHabboInfo().getId()); - statement.setInt(2, clothing.id); - statement.execute(); - } catch (SQLException e) { - LOGGER.error("Caught SQL exception", e); - } - this.client.getHabbo().getInventory().getWardrobeComponent().getClothing().add(clothing.id); this.client.getHabbo().getInventory().getWardrobeComponent().getClothingSets().addAll(clothing.setId); this.client.sendResponse(new UserClothesComposer(this.client.getHabbo())); @@ -67,4 +63,15 @@ public class RedeemClothingEvent extends MessageHandler { } } } + + private boolean grantClothing(int clothingId) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO users_clothing (user_id, clothing_id) VALUES (?, ?)")) { + statement.setInt(1, this.client.getHabbo().getHabboInfo().getId()); + statement.setInt(2, clothingId); + return statement.executeUpdate() > 0; + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + return false; + } + } } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingContractTest.java new file mode 100644 index 00000000..fcbf35c7 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingContractTest.java @@ -0,0 +1,29 @@ +package com.eu.habbo.messages.incoming.rooms.items; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class RedeemClothingContractTest { + private static String source() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/messages/incoming/rooms/items/RedeemClothingEvent.java")); + } + + @Test + void clothingIsGrantedBeforeVoucherFurnitureIsConsumed() throws Exception { + String source = source(); + + int grantCall = source.indexOf("grantClothing("); + int roomRemoval = source.indexOf("removeHabboItem(item)"); + int deleteItem = source.indexOf("new QueryDeleteHabboItem(item.getId())"); + + assertTrue(source.contains("private boolean grantClothing(int clothingId)"), + "clothing DB insert should report whether the grant succeeded"); + assertTrue(grantCall > -1, "redeem path should call grantClothing before consuming the item"); + assertTrue(grantCall < roomRemoval, "room item must not be removed before the clothing grant succeeds"); + assertTrue(grantCall < deleteItem, "voucher furniture must not be deleted before the clothing grant succeeds"); + } +} From 7ba0029ba813b36c7088ccf801397abbf8059969 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 20:48:48 +0200 Subject: [PATCH 4/4] fix(bots): preserve owner on pickup Room owners can remove bots from their room, but picking up another user's bot must return it to the original owner instead of transferring ownership to the picker. Tests: mvn -Dtest=BotPickupOwnershipContractTest test; mvn -DskipTests package --- .../eu/habbo/habbohotel/bots/BotManager.java | 20 ++++++++++--- .../bots/BotPickupOwnershipContractTest.java | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/bots/BotPickupOwnershipContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java index 7fd3ee93..7d5ea500 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java @@ -179,9 +179,13 @@ public class BotManager { } public void pickUpBot(Bot bot, Habbo habbo) { - HabboInfo receiverInfo = habbo == null ? Emulator.getGameEnvironment().getHabboManager().getHabboInfo(bot.getOwnerId()) : habbo.getHabboInfo(); - if (bot != null) { + HabboInfo receiverInfo = resolvePickupReceiver(bot, habbo); + Room botRoom = bot.getRoom(); + if (receiverInfo == null || botRoom == null) { + return; + } + BotPickUpEvent pickedUpEvent = new BotPickUpEvent(bot, habbo); Emulator.getPluginManager().fireEvent(pickedUpEvent); @@ -198,8 +202,8 @@ public class BotManager { return; } - bot.onPickUp(habbo, receiverInfo.getCurrentRoom()); - receiverInfo.getCurrentRoom().removeBot(bot); + bot.onPickUp(habbo, botRoom); + botRoom.removeBot(bot); bot.stopFollowingHabbo(); bot.setOwnerId(receiverInfo.getId()); bot.setOwnerName(receiverInfo.getUsername()); @@ -215,6 +219,14 @@ public class BotManager { } } + private HabboInfo resolvePickupReceiver(Bot bot, Habbo picker) { + if (picker != null && bot.getOwnerId() == picker.getHabboInfo().getId()) { + return picker.getHabboInfo(); + } + + return Emulator.getGameEnvironment().getHabboManager().getHabboInfo(bot.getOwnerId()); + } + public Bot loadBot(ResultSet set) { try { String type = set.getString("type"); diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/bots/BotPickupOwnershipContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/bots/BotPickupOwnershipContractTest.java new file mode 100644 index 00000000..43295c39 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/bots/BotPickupOwnershipContractTest.java @@ -0,0 +1,30 @@ +package com.eu.habbo.habbohotel.bots; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class BotPickupOwnershipContractTest { + private static String source() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java")); + } + + @Test + void roomOwnerPickupReturnsBotToOriginalOwner() throws Exception { + String source = source(); + + assertTrue(source.contains("HabboInfo receiverInfo = resolvePickupReceiver(bot, habbo);"), + "bot pickup should resolve the receiver without blindly using the picker"); + assertTrue(source.contains("private HabboInfo resolvePickupReceiver(Bot bot, Habbo picker)"), + "bot pickup receiver logic should be centralized"); + assertTrue(source.contains("return Emulator.getGameEnvironment().getHabboManager().getHabboInfo(bot.getOwnerId());"), + "when a room owner picks up someone else's bot, it should return to the original bot owner"); + assertTrue(source.contains("Room botRoom = bot.getRoom();"), + "pickup should remove the bot from the bot's current room, not the receiver's current room"); + assertTrue(source.contains("botRoom.removeBot(bot);"), + "bot removal should work even when the original owner is offline"); + } +}