From da1fd01074f4263c0e2afa336ca0963ec40d0299 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 8 Jun 2026 22:12:52 +0000 Subject: [PATCH 01/21] fix: address bug-hunt findings across security, concurrency, trade & wired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security - HousekeepingSetUserRankEvent: add rank-ceiling guard (reject granting a rank above the operator's own and modifying a higher-ranked target), mirroring GiveRankCommand — closes a privilege-escalation path. Trade integrity - RoomTrade.clearAccepted now also resets confirmed; a stale confirmed=true let a user strip their side and still complete once the partner re-confirms. Concurrency - RoomCycleManager: iterate the synchronized bot/pet maps under their own monitor (lock order stays one-directional vs addBot/addPet — no deadlock). - RoomSpecialTypes: synchronize nest/petDrink/petFood/petToy/petTree writers on the same monitor their getters already use. - HabboStats: synchronize achievement-progress map accessors. - RebugKickBallAction: drop redundant direct mutation of the shared tile-cache sets (updateTile invalidates them right after) — removes a data race. Robustness - Wired legacy parsers (HabboCount, NotHabboCount, MatchStatePosition, MoveRotateFurni): guard length/format so one malformed row no longer aborts the whole room's wired load. - RoomLayout: fill malformed/short heightmap rows with INVALID tiles instead of leaving nulls, and bounds-check door coordinates. - FurnidataWatcher: defer (instead of drop) a throttled delta so furni-name changes are never lost between broadcasts. - GuildManager.getGuildMembers: fix LIMIT row-count (page size 14, not offset+14) so member pages no longer overlap from page 1 on. --- .../habbo/habbohotel/guilds/GuildManager.java | 2 +- .../habbohotel/items/FurnidataWatcher.java | 45 +++++---- .../conditions/WiredConditionHabboCount.java | 10 +- .../WiredConditionMatchStatePosition.java | 29 +++--- .../WiredConditionNotHabboCount.java | 10 +- .../effects/WiredEffectMoveRotateFurni.java | 11 ++- .../habbohotel/rooms/RoomCycleManager.java | 97 ++++++++++--------- .../eu/habbo/habbohotel/rooms/RoomLayout.java | 17 ++-- .../habbohotel/rooms/RoomSpecialTypes.java | 50 ++++++++-- .../eu/habbo/habbohotel/rooms/RoomTrade.java | 4 + .../habbo/habbohotel/rooms/RoomTradeUser.java | 4 + .../eu/habbo/habbohotel/users/HabboStats.java | 12 ++- .../HousekeepingSetUserRankEvent.java | 40 +++++++- .../runnables/RebugKickBallAction.java | 10 +- 14 files changed, 230 insertions(+), 111 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java index 85349452..5d02b165 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java @@ -423,7 +423,7 @@ public class GuildManager { statement.setInt(1, guild.getId()); statement.setString(2, "%" + query + "%"); statement.setInt(3, page * 14); - statement.setInt(4, (page * 14) + 14); + statement.setInt(4, 14); try (ResultSet set = statement.executeQuery()) { while (set.next()) { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java index 0b3aa615..cc844510 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java @@ -115,32 +115,39 @@ public class FurnidataWatcher { } } - private void onChange() { + private void onChange() throws InterruptedException { + // Re-index under the shared furnidata lock so the watcher and editor + // writes never swap the index concurrently. The lock is released before + // the throttle/broadcast below so a slow broadcast can't stall editor saves. + List delta; FurnidataLock.LOCK.lock(); try { Path source = this.provider.getSource(); if (source == null) return; - - List delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read()); - if (delta.isEmpty()) return; - - long now = System.currentTimeMillis(); - if (now - this.lastBroadcast < this.minIntervalMs) { - LOGGER.info("FurnidataWatcher: {} changes indexed but broadcast skipped (min interval) — clients update on next change or reconnect", delta.size()); - return; - } - this.lastBroadcast = now; - - FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap) - ? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of()) - : new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta); - - broadcast(composer); - LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)", - delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size()); + delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read()); } finally { FurnidataLock.LOCK.unlock(); } + if (delta.isEmpty()) return; + + // Min-interval throttle: the index has already been swapped, so we must + // not drop this delta (the next reindex would diff against the updated + // index and never re-emit it). Instead, defer the broadcast until the + // interval elapses. Running on a dedicated daemon thread, sleeping is + // safe; file events arriving meanwhile coalesce into the next cycle. + long sinceLast = System.currentTimeMillis() - this.lastBroadcast; + if (sinceLast < this.minIntervalMs) { + Thread.sleep(this.minIntervalMs - sinceLast); + } + this.lastBroadcast = System.currentTimeMillis(); + + FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap) + ? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of()) + : new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta); + + broadcast(composer); + LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)", + delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size()); } private void broadcast(FurnitureDataReloadComposer composer) { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionHabboCount.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionHabboCount.java index 6cd6e951..98010557 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionHabboCount.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionHabboCount.java @@ -65,8 +65,14 @@ public class WiredConditionHabboCount extends InteractionWiredCondition { } else { String[] data = wiredData.split(":"); - this.lowerLimit = Integer.parseInt(data[0]); - this.upperLimit = Integer.parseInt(data[1]); + if (data.length >= 2) { + try { + this.lowerLimit = Integer.parseInt(data[0].trim()); + this.upperLimit = Integer.parseInt(data[1].trim()); + } catch (NumberFormatException ignored) { + // malformed legacy data — keep the constructed defaults + } + } this.userSource = WiredSourceUtil.SOURCE_TRIGGER; } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionMatchStatePosition.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionMatchStatePosition.java index ddc0477e..36e721fc 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionMatchStatePosition.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionMatchStatePosition.java @@ -263,22 +263,29 @@ public class WiredConditionMatchStatePosition extends InteractionWiredCondition } else { String[] data = wiredData.split(":"); - int itemCount = Integer.parseInt(data[0]); + if (data.length >= 5) { + try { + int itemCount = Integer.parseInt(data[0]); - String[] items = data[1].split(";"); + String[] items = data[1].split(";"); - for (int i = 0; i < itemCount; i++) { - String[] stuff = items[i].split("-"); + for (int i = 0; i < itemCount && i < items.length; i++) { + String[] stuff = items[i].split("-"); - if (stuff.length >= 6) - this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]), Double.parseDouble(stuff[5]))); - else if (stuff.length >= 5) - this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]))); + if (stuff.length >= 6) + this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]), Double.parseDouble(stuff[5]))); + else if (stuff.length >= 5) + this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]))); + } + + this.state = data[2].equals("1"); + this.direction = data[3].equals("1"); + this.position = data[4].equals("1"); + } catch (NumberFormatException ignored) { + // malformed legacy data — keep whatever was parsed plus defaults + } } - this.state = data[2].equals("1"); - this.direction = data[3].equals("1"); - this.position = data[4].equals("1"); this.altitude = false; this.furniSource = this.settings.isEmpty() ? WiredSourceUtil.SOURCE_TRIGGER : WiredSourceUtil.SOURCE_SELECTED; this.quantifier = QUANTIFIER_ALL; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionNotHabboCount.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionNotHabboCount.java index a2bb7056..e993d19f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionNotHabboCount.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionNotHabboCount.java @@ -64,8 +64,14 @@ public class WiredConditionNotHabboCount extends InteractionWiredCondition { this.userSource = data.userSource; } else { String[] data = wiredData.split(":"); - this.lowerLimit = Integer.parseInt(data[0]); - this.upperLimit = Integer.parseInt(data[1]); + if (data.length >= 2) { + try { + this.lowerLimit = Integer.parseInt(data[0].trim()); + this.upperLimit = Integer.parseInt(data[1].trim()); + } catch (NumberFormatException ignored) { + // malformed legacy data — keep the constructed defaults + } + } this.userSource = WiredSourceUtil.SOURCE_TRIGGER; } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectMoveRotateFurni.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectMoveRotateFurni.java index 52bbe142..8ef38fe1 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectMoveRotateFurni.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectMoveRotateFurni.java @@ -190,10 +190,15 @@ public class WiredEffectMoveRotateFurni extends InteractionWiredEffect implement } for (String s : data[3].split("\r")) { - HabboItem item = room.getHabboItem(Integer.parseInt(s)); + if (s.trim().isEmpty()) continue; + try { + HabboItem item = room.getHabboItem(Integer.parseInt(s.trim())); - if (item != null) - this.items.add(item); + if (item != null) + this.items.add(item); + } catch (NumberFormatException ignored) { + // skip malformed furni id token + } } } this.furniSource = this.items.isEmpty() ? WiredSourceUtil.SOURCE_TRIGGER : WiredSourceUtil.SOURCE_SELECTED; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java index f79ca2b9..ff4f4e25 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java @@ -300,32 +300,37 @@ public class RoomCycleManager { return; } - TIntObjectIterator botIterator = currentBots.iterator(); - for (int i = currentBots.size(); i-- > 0; ) { - try { - final Bot bot; + // currentBots is a TCollections.synchronizedMap; the iterator is not + // safe against concurrent put/remove from IO threads (bot place/pickup), + // so we must hold the map's monitor for the whole traversal. + synchronized (currentBots) { + TIntObjectIterator botIterator = currentBots.iterator(); + for (int i = currentBots.size(); i-- > 0; ) { try { - botIterator.advance(); - bot = botIterator.value(); - } catch (Exception e) { + final Bot bot; + try { + botIterator.advance(); + bot = botIterator.value(); + } catch (Exception e) { + break; + } + + if (!this.room.isAllowBotsWalk() && bot.getRoomUnit().isWalking()) { + bot.getRoomUnit().stopWalking(); + updatedUnit.add(bot.getRoomUnit()); + continue; + } + + bot.cycle(this.room.isAllowBotsWalk()); + + if (this.cycleRoomUnit(bot.getRoomUnit(), RoomUnitType.BOT)) { + updatedUnit.add(bot.getRoomUnit()); + } + + } catch (NoSuchElementException e) { + LOGGER.error("Caught exception", e); break; } - - if (!this.room.isAllowBotsWalk() && bot.getRoomUnit().isWalking()) { - bot.getRoomUnit().stopWalking(); - updatedUnit.add(bot.getRoomUnit()); - continue; - } - - bot.cycle(this.room.isAllowBotsWalk()); - - if (this.cycleRoomUnit(bot.getRoomUnit(), RoomUnitType.BOT)) { - updatedUnit.add(bot.getRoomUnit()); - } - - } catch (NoSuchElementException e) { - LOGGER.error("Caught exception", e); - break; } } } @@ -339,31 +344,35 @@ public class RoomCycleManager { return; } - TIntObjectIterator petIterator = currentPets.iterator(); - for (int i = currentPets.size(); i-- > 0; ) { - try { - petIterator.advance(); - } catch (NoSuchElementException e) { - LOGGER.error("Caught exception", e); - break; - } + // currentPets is a TCollections.synchronizedMap; hold its monitor for the + // whole traversal to stay safe against concurrent pet place/pickup. + synchronized (currentPets) { + TIntObjectIterator petIterator = currentPets.iterator(); + for (int i = currentPets.size(); i-- > 0; ) { + try { + petIterator.advance(); + } catch (NoSuchElementException e) { + LOGGER.error("Caught exception", e); + break; + } - Pet pet = petIterator.value(); - if (this.cycleRoomUnit(pet.getRoomUnit(), RoomUnitType.PET)) { - updatedUnit.add(pet.getRoomUnit()); - } + Pet pet = petIterator.value(); + if (this.cycleRoomUnit(pet.getRoomUnit(), RoomUnitType.PET)) { + updatedUnit.add(pet.getRoomUnit()); + } - pet.cycle(); + pet.cycle(); - if (pet.packetUpdate) { - updatedUnit.add(pet.getRoomUnit()); - pet.packetUpdate = false; - } + if (pet.packetUpdate) { + updatedUnit.add(pet.getRoomUnit()); + pet.packetUpdate = false; + } - if (pet.getRoomUnit().isWalking() && pet.getRoomUnit().getPath().size() == 1 - && pet.getRoomUnit().hasStatus(RoomUnitStatus.GESTURE)) { - pet.getRoomUnit().removeStatus(RoomUnitStatus.GESTURE); - updatedUnit.add(pet.getRoomUnit()); + if (pet.getRoomUnit().isWalking() && pet.getRoomUnit().getPath().size() == 1 + && pet.getRoomUnit().hasStatus(RoomUnitStatus.GESTURE)) { + pet.getRoomUnit().removeStatus(RoomUnitStatus.GESTURE); + updatedUnit.add(pet.getRoomUnit()); + } } } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomLayout.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomLayout.java index 29156355..489c6345 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomLayout.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomLayout.java @@ -130,13 +130,16 @@ public class RoomLayout { this.roomTiles = new RoomTile[this.mapSizeX][this.mapSizeY]; for (short y = 0; y < this.mapSizeY; y++) { - if (modelTemp[y].isEmpty() || modelTemp[y].equalsIgnoreCase("\r")) { - continue; - } + // A row shorter/longer than the model width (or empty) cannot be parsed + // per-square. Previously such tiles were left null while tileExists() + // still reported them present, causing NPEs in the coordinate accessors. + // Fill them with INVALID tiles so every in-bounds coordinate is non-null. + boolean validRow = !modelTemp[y].isEmpty() && modelTemp[y].length() == this.mapSizeX; for (short x = 0; x < this.mapSizeX; x++) { - if (modelTemp[y].length() != this.mapSizeX) { - break; + if (!validRow) { + this.roomTiles[x][y] = new RoomTile(x, y, (short) 0, RoomTileState.INVALID, true); + continue; } String square = modelTemp[y].substring(x, x + 1).trim().toLowerCase(); @@ -159,7 +162,9 @@ public class RoomLayout { } } - this.doorTile = this.roomTiles[this.doorX][this.doorY]; + this.doorTile = (this.doorX >= 0 && this.doorX < this.mapSizeX && this.doorY >= 0 && this.doorY < this.mapSizeY) + ? this.roomTiles[this.doorX][this.doorY] + : null; if (this.doorTile != null) { this.doorTile.setAllowStack(false); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java index 4d22505b..544aca51 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java @@ -156,11 +156,17 @@ public class RoomSpecialTypes { } public void addNest(InteractionNest item) { - this.nests.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); + synchronized (this.nests) { + this.nests.put(item.getId(), item); + } + this.specialItemsById.put(item.getId(), item); } public void removeNest(InteractionNest item) { - this.nests.remove(item.getId()); this.specialItemsById.remove(item.getId()); + synchronized (this.nests) { + this.nests.remove(item.getId()); + } + this.specialItemsById.remove(item.getId()); } public THashSet getNests() { @@ -178,11 +184,17 @@ public class RoomSpecialTypes { } public void addPetDrink(InteractionPetDrink item) { - this.petDrinks.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); + synchronized (this.petDrinks) { + this.petDrinks.put(item.getId(), item); + } + this.specialItemsById.put(item.getId(), item); } public void removePetDrink(InteractionPetDrink item) { - this.petDrinks.remove(item.getId()); this.specialItemsById.remove(item.getId()); + synchronized (this.petDrinks) { + this.petDrinks.remove(item.getId()); + } + this.specialItemsById.remove(item.getId()); } public THashSet getPetDrinks() { @@ -200,11 +212,17 @@ public class RoomSpecialTypes { } public void addPetFood(InteractionPetFood item) { - this.petFoods.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); + synchronized (this.petFoods) { + this.petFoods.put(item.getId(), item); + } + this.specialItemsById.put(item.getId(), item); } public void removePetFood(InteractionPetFood petFood) { - this.petFoods.remove(petFood.getId()); this.specialItemsById.remove(petFood.getId()); + synchronized (this.petFoods) { + this.petFoods.remove(petFood.getId()); + } + this.specialItemsById.remove(petFood.getId()); } public THashSet getPetFoods() { @@ -222,11 +240,17 @@ public class RoomSpecialTypes { } public void addPetToy(InteractionPetToy item) { - this.petToys.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); + synchronized (this.petToys) { + this.petToys.put(item.getId(), item); + } + this.specialItemsById.put(item.getId(), item); } public void removePetToy(InteractionPetToy petToy) { - this.petToys.remove(petToy.getId()); this.specialItemsById.remove(petToy.getId()); + synchronized (this.petToys) { + this.petToys.remove(petToy.getId()); + } + this.specialItemsById.remove(petToy.getId()); } public THashSet getPetToys() { @@ -244,11 +268,17 @@ public class RoomSpecialTypes { } public void addPetTree(InteractionPetTree item) { - this.petTrees.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); + synchronized (this.petTrees) { + this.petTrees.put(item.getId(), item); + } + this.specialItemsById.put(item.getId(), item); } public void removePetTree(InteractionPetTree petTree) { - this.petTrees.remove(petTree.getId()); this.specialItemsById.remove(petTree.getId()); + synchronized (this.petTrees) { + this.petTrees.remove(petTree.getId()); + } + this.specialItemsById.remove(petTree.getId()); } public THashSet getPetTrees() { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java index 46138a3c..b28eb85a 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java @@ -264,6 +264,10 @@ public class RoomTrade { protected void clearAccepted() { for (RoomTradeUser user : this.users) { user.setAccepted(false); + // Any change to the offered items invalidates a prior confirmation; + // without this a stale confirmed=true lets a user strip their side + // and still complete the trade once the partner re-confirms. + user.setConfirmed(false); } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeUser.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeUser.java index 6b222e32..9ac826c0 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeUser.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeUser.java @@ -51,6 +51,10 @@ public class RoomTradeUser { this.confirmed = true; } + public void setConfirmed(boolean value) { + this.confirmed = value; + } + public void addItem(HabboItem item) { this.items.add(item); } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java index 3c7e820d..b45c372f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java @@ -448,14 +448,16 @@ public class HabboStats implements Runnable { return 0; } - if (this.achievementProgress.containsKey(achievement)) - return this.achievementProgress.get(achievement); - - return -1; + synchronized (this.achievementProgress) { + Integer progress = this.achievementProgress.get(achievement); + return progress != null ? progress : -1; + } } public void setProgress(Achievement achievement, int progress) { - this.achievementProgress.put(achievement, progress); + synchronized (this.achievementProgress) { + this.achievementProgress.put(achievement, progress); + } } public int getRentedTimeEnd() { 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 67f35d8a..dbdba31c 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 @@ -11,6 +11,7 @@ import com.eu.habbo.messages.outgoing.users.UserPermissionsComposer; import java.sql.Connection; import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.sql.SQLException; public class HousekeepingSetUserRankEvent extends MessageHandler { @@ -44,6 +45,43 @@ 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) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + + Habbo online = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); + + int targetRankId; + if (online != null) { + targetRankId = online.getHabboInfo().getRank().getId(); + } else { + targetRankId = 0; + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT rank FROM users WHERE id = ? LIMIT 1")) { + statement.setInt(1, userId); + try (ResultSet set = statement.executeQuery()) { + if (set.next()) { + targetRankId = set.getInt("rank"); + } + } + } catch (SQLException e) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.db_failed")); + return; + } + } + + if (targetRankId > operatorRankId) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + // Persist for the offline path. Online users get their in-memory // HabboInfo.rank rebound below so server-side hasPermission() // checks land on the new permission set without a relogin. @@ -57,8 +95,6 @@ public class HousekeepingSetUserRankEvent extends MessageHandler { return; } - Habbo online = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); - if (online != null) { online.getHabboInfo().setRank(rank); // Ship the refreshed permissions snapshot — same payload the diff --git a/Emulator/src/main/java/com/eu/habbo/threading/runnables/RebugKickBallAction.java b/Emulator/src/main/java/com/eu/habbo/threading/runnables/RebugKickBallAction.java index 7bc761e9..20637c35 100644 --- a/Emulator/src/main/java/com/eu/habbo/threading/runnables/RebugKickBallAction.java +++ b/Emulator/src/main/java/com/eu/habbo/threading/runnables/RebugKickBallAction.java @@ -165,12 +165,10 @@ public class RebugKickBallAction implements Runnable { this.dead = true; } - THashSet oldItems = this.room.getItemsAt(oldTile); - if (oldItems != null && !oldItems.isEmpty()) { - oldItems.remove(this.ball); - } - this.room.getItemsAt(nextTile).add(this.ball); - + // updateTile() below removes both tiles from the item cache (rebuilt + // lazily from the ball's already-updated position), so mutating the + // shared cached THashSets here is both redundant and a data race + // against the room-cycle/IO threads iterating those same sets. this.room.updateTile(oldTile); this.room.updateTile(nextTile); From c98d3a3205aa5c4d903b8e74f95d4d85d96396ee Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 10:35:23 +0000 Subject: [PATCH 02/21] fix: guard double gift-open and harden client string reads - InteractionGift/OpenRecycleBoxEvent: add an atomic open-once guard so two near-simultaneous OpenRecycleBox packets can't both schedule the async, delayed OpenGift before the wrapper is removed (redundant double-process). - ClientMessage.readString: treat the length prefix as unsigned (mask 0xFFFF) and clamp to the buffered bytes, so a bogus/oversized length no longer throws mid-read and desyncs the remaining fields of the packet. --- .../items/interactions/InteractionGift.java | 11 +++++++++++ .../java/com/eu/habbo/messages/ClientMessage.java | 9 ++++++++- .../catalog/recycler/OpenRecycleBoxEvent.java | 5 +++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionGift.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionGift.java index f00252f2..8a7ec640 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionGift.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionGift.java @@ -13,11 +13,13 @@ import org.slf4j.LoggerFactory; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.concurrent.atomic.AtomicBoolean; public class InteractionGift extends HabboItem { private static final Logger LOGGER = LoggerFactory.getLogger(InteractionGift.class); public boolean explode = false; + private final AtomicBoolean opening = new AtomicBoolean(false); private int[] itemId; private int colorId = 0; private int ribbonId = 0; @@ -46,6 +48,15 @@ public class InteractionGift extends HabboItem { } } + /** + * Claims the right to open this gift, returning true exactly once. Guards + * against two near-simultaneous OpenRecycleBox packets both scheduling an + * (async, delayed) OpenGift before the wrapper is removed from the room. + */ + public boolean tryStartOpening() { + return this.opening.compareAndSet(false, true); + } + @Override public void serializeExtradata(ServerMessage serverMessage) { //serverMessage.appendInt(this.colorId * 1000 + this.ribbonId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java b/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java index 55fdae44..3cdfb710 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java @@ -61,7 +61,14 @@ public class ClientMessage { public String readString() { try { - int length = this.readShort(); + // Length is an unsigned short in the protocol; mask to avoid a + // negative array size, and clamp to what's actually buffered so a + // bogus length can't throw mid-read and desync the remaining fields. + int length = this.readShort() & 0xFFFF; + int available = this.buffer.readableBytes(); + if (length > available) { + length = available; + } byte[] data = new byte[length]; this.buffer.readBytes(data); return new String(data); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/OpenRecycleBoxEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/OpenRecycleBoxEvent.java index 523d5d0d..bd8cd616 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/OpenRecycleBoxEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/OpenRecycleBoxEvent.java @@ -29,6 +29,11 @@ public class OpenRecycleBoxEvent extends MessageHandler { if (item.getUserId() != this.client.getHabbo().getHabboInfo().getId()) return; if (item instanceof InteractionGift) { + // The actual unwrap (OpenGift) runs async/delayed and only then + // removes the wrapper, so a second packet would otherwise pass + // the room/owner checks and double-process the gift. Claim it once. + if (!((InteractionGift) item).tryStartOpening()) return; + if (item.getBaseItem().getName().contains("present_wrap")) { ((InteractionGift) item).explode = true; room.updateItem(item); From d1570d357454ce9ae3c2bf90ccf1c6d28c9defb7 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 11:02:09 +0000 Subject: [PATCH 03/21] fix: economy-integrity, currency thread-safety, and resource-leak hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the full-codebase audit. Economy/security (Batch A): - CatalogBuyItemEvent: clamp client `count` to 1..100 — the club-offer branch accumulated cost in plain ints, so a huge count overflowed to a negative total, bypassed the affordability checks and CREDITED the buyer (free currency/subscription exploit). - HousekeepingGiveCredits/GiveCurrency: bound `amount` to +/-1e9 to stop overflow/negative-balance grants via the privileged path. - RoomTrade: synchronize accept/confirm/offer/remove and add a `completed` re-entry guard so two simultaneous confirms can't run tradeItems() twice (item/credit duplication). - HabboInfo: serialize credits + currencies read-modify-write and the saveCurrencies snapshot on a dedicated lock (never held across DB I/O) — fixes lost updates and Trove rehash-during-iteration corruption between the credit-roller thread and purchase/trade handlers. - AchievementManager/HabboStats: atomic incrementProgress() so concurrent progress sources don't lose updates. Resource/stability (Batch B): - GameMessageRateLimit: release the wrapped ByteBuf on every drop path (ClientMessage isn't ReferenceCounted, so the decoder's auto-release is a no-op) — fixes a refcount leak on pre-auth/rate-limited packets. - AuthRateLimiter: opportunistically evict window-expired STATE/PROBE_STATE entries — previously grew unbounded, one entry per unique client IP. - ForumThread/ForumThreadComment: close getGeneratedKeys() ResultSets via try-with-resources, and create the first comment after the thread's connection is released (was holding two pooled connections at once). - DatabasePool: add socketTimeout/connectTimeout/tcpKeepAlive so a stalled MariaDB can't pin a pooled connection (and its thread) indefinitely. Concurrency visibility (Batch C, partial): - Room: mark allowBotsWalk/allowPets/allowPetsEat volatile (read every cycle, written from settings handlers on another thread). --- .../com/eu/habbo/database/DatabasePool.java | 8 ++++ .../achievements/AchievementManager.java | 4 +- .../habbohotel/guilds/forums/ForumThread.java | 26 ++++++----- .../guilds/forums/ForumThreadComment.java | 11 ++--- .../com/eu/habbo/habbohotel/rooms/Room.java | 8 ++-- .../eu/habbo/habbohotel/rooms/RoomTrade.java | 19 +++++--- .../eu/habbo/habbohotel/users/HabboInfo.java | 44 ++++++++++++++----- .../eu/habbo/habbohotel/users/HabboStats.java | 10 +++++ .../incoming/catalog/CatalogBuyItemEvent.java | 9 ++++ .../HousekeepingGiveCreditsEvent.java | 3 +- .../HousekeepingGiveCurrencyEvent.java | 3 +- .../gameserver/auth/AuthRateLimiter.java | 28 ++++++++++++ .../decoders/GameMessageRateLimit.java | 7 +++ 13 files changed, 142 insertions(+), 38 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/database/DatabasePool.java b/Emulator/src/main/java/com/eu/habbo/database/DatabasePool.java index acd50764..813f8f15 100644 --- a/Emulator/src/main/java/com/eu/habbo/database/DatabasePool.java +++ b/Emulator/src/main/java/com/eu/habbo/database/DatabasePool.java @@ -79,6 +79,14 @@ class DatabasePool { databaseConfiguration.addDataSourceProperty("useLocalSessionState", "true"); databaseConfiguration.addDataSourceProperty("cacheResultSetMetadata", "true"); databaseConfiguration.addDataSourceProperty("elideSetAutoCommits", "true"); + + // Fail fast instead of pinning a pooled connection (and its worker + // thread) indefinitely on a stalled/slow MariaDB. HikariCP's + // connectionTimeout only bounds the pool *borrow*; these bound the + // actual socket/connect round-trip. Overridable via db.params. + databaseConfiguration.addDataSourceProperty("socketTimeout", "30000"); + databaseConfiguration.addDataSourceProperty("connectTimeout", "10000"); + databaseConfiguration.addDataSourceProperty("tcpKeepAlive", "true"); databaseConfiguration.addDataSourceProperty("maintainTimeStats", "false"); databaseConfiguration.setPoolName("HabboHikariPool"); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/achievements/AchievementManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/achievements/AchievementManager.java index 7e5ecdb8..fcc5ba99 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/achievements/AchievementManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/achievements/AchievementManager.java @@ -100,9 +100,9 @@ public class AchievementManager { if (oldLevel != null && (oldLevel.level == achievement.levels.size() && currentProgress >= oldLevel.progress)) //Maximum achievement gotten. return; - habbo.getHabboStats().setProgress(achievement, currentProgress + amount); + int newProgress = habbo.getHabboStats().incrementProgress(achievement, amount); - AchievementLevel newLevel = achievement.getLevelForProgress(currentProgress + amount); + AchievementLevel newLevel = achievement.getLevelForProgress(newProgress); if (AchievementManager.TALENTTRACK_ENABLED) { for (TalentTrackType type : TalentTrackType.values()) { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThread.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThread.java index 0bc0c265..54517e4e 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThread.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThread.java @@ -101,21 +101,27 @@ public class ForumThread implements Runnable, ISerialize { if (statement.executeUpdate() < 1) return null; - ResultSet set = statement.getGeneratedKeys(); - if (set.next()) { - int threadId = set.getInt(1); - createdThread = new ForumThread(threadId, guild.getId(), opener.getHabboInfo().getId(), subject, 0, timestamp, timestamp, ForumThreadState.OPEN, false, false, 0, null); - cacheThread(createdThread); - - ForumThreadComment comment = ForumThreadComment.create(createdThread, opener, message); - createdThread.addComment(comment); - - Emulator.getPluginManager().fireEvent(new GuildForumThreadCreated(createdThread)); + try (ResultSet set = statement.getGeneratedKeys()) { + if (set.next()) { + int threadId = set.getInt(1); + createdThread = new ForumThread(threadId, guild.getId(), opener.getHabboInfo().getId(), subject, 0, timestamp, timestamp, ForumThreadState.OPEN, false, false, 0, null); + cacheThread(createdThread); + } } } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); } + // ForumThreadComment.create() opens its OWN connection; do it after the + // thread's connection has been released to avoid holding two pooled + // connections simultaneously per forum-thread creation. + if (createdThread != null) { + ForumThreadComment comment = ForumThreadComment.create(createdThread, opener, message); + createdThread.addComment(comment); + + Emulator.getPluginManager().fireEvent(new GuildForumThreadCreated(createdThread)); + } + return createdThread; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThreadComment.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThreadComment.java index c404ddb5..99b6cf61 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThreadComment.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThreadComment.java @@ -98,12 +98,13 @@ public class ForumThreadComment implements Runnable, ISerialize { if (statement.executeUpdate() < 1) return null; - ResultSet set = statement.getGeneratedKeys(); - if (set.next()) { - int commentId = set.getInt(1); - createdComment = new ForumThreadComment(commentId, thread.getThreadId(), poster.getHabboInfo().getId(), message, timestamp, ForumThreadState.OPEN, 0); + try (ResultSet set = statement.getGeneratedKeys()) { + if (set.next()) { + int commentId = set.getInt(1); + createdComment = new ForumThreadComment(commentId, thread.getThreadId(), poster.getHabboInfo().getId(), message, timestamp, ForumThreadState.OPEN, 0); - Emulator.getPluginManager().fireEvent(new GuildForumThreadCommentCreated(createdComment)); + Emulator.getPluginManager().fireEvent(new GuildForumThreadCommentCreated(createdComment)); + } } } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java index fa31b151..b8b25ceb 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java @@ -158,10 +158,12 @@ public class Room implements Comparable, ISerialize, Runnable { private String tags; private boolean publicRoom; private boolean staffPromotedRoom; - private boolean allowPets; - private boolean allowPetsEat; + // Read every room cycle (processBots/processPets) but written from settings/ + // admin packet handlers on another thread — volatile for cross-thread visibility. + private volatile boolean allowPets; + private volatile boolean allowPetsEat; private boolean allowWalkthrough; - private boolean allowBotsWalk; + private volatile boolean allowBotsWalk; private boolean allowEffects; private boolean hideWall; private int chatMode; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java index b28eb85a..38d2b66f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java @@ -26,6 +26,7 @@ public class RoomTrade { private final List users; private final Room room; + private boolean completed = false; public RoomTrade(Habbo userOne, Habbo userTwo, Room room) { this.users = new ArrayList<>(); @@ -54,7 +55,7 @@ public class RoomTrade { this.sendMessageToUsers(new TradeStartComposer(this)); } - public void offerItem(Habbo habbo, HabboItem item) { + public synchronized void offerItem(Habbo habbo, HabboItem item) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); if (user.getItems().contains(item)) @@ -67,7 +68,7 @@ public class RoomTrade { this.updateWindow(); } - public void offerMultipleItems(Habbo habbo, THashSet items) { + public synchronized void offerMultipleItems(Habbo habbo, THashSet items) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); for (HabboItem item : items) { @@ -81,7 +82,7 @@ public class RoomTrade { this.updateWindow(); } - public void removeItem(Habbo habbo, HabboItem item) { + public synchronized void removeItem(Habbo habbo, HabboItem item) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); if (!user.getItems().contains(item)) @@ -94,7 +95,7 @@ public class RoomTrade { this.updateWindow(); } - public void accept(Habbo habbo, boolean value) { + public synchronized void accept(Habbo habbo, boolean value) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); user.setAccepted(value); @@ -110,7 +111,13 @@ public class RoomTrade { } } - public void confirm(Habbo habbo) { + public synchronized void confirm(Habbo habbo) { + // Re-entry guard: both participants confirm on their own EventLoop + // threads. Without this (and the method-level lock) two concurrent + // confirms could each observe "all confirmed" and run tradeItems() + // twice → item/credit duplication. + if (this.completed) return; + RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); user.confirm(); @@ -122,6 +129,8 @@ public class RoomTrade { accepted = false; } if (accepted) { + this.completed = true; + if (this.tradeItems()) { this.closeWindow(); this.sendMessageToUsers(new TradeCompleteComposer()); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java index 373642be..aa441a05 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java @@ -55,6 +55,11 @@ public class HabboInfo implements Runnable { private RideablePet riding; private Class currentGame; private TIntIntHashMap currencies; + // Serializes credits + currencies read-modify-write and the saveCurrencies + // snapshot so the credit-roller thread and purchase/trade handler threads + // can't lose updates or rehash the Trove map mid-iteration. Never held + // across run()'s DB I/O. + private final Object currencyLock = new Object(); private GamePlayer gamePlayer; private int photoRoomId; private int photoTimestamp; @@ -123,11 +128,16 @@ public class HabboInfo implements Runnable { } private void saveCurrencies() { - List entries = new ArrayList<>(this.currencies.size()); - this.currencies.forEachEntry((type, amount) -> { - entries.add(new int[]{type, amount}); - return true; - }); + // Snapshot under the lock so a concurrent adjustOrPutValue/put can't + // rehash the Trove map while we iterate; do the DB batch off-lock. + List entries; + synchronized (this.currencyLock) { + entries = new ArrayList<>(this.currencies.size()); + this.currencies.forEachEntry((type, amount) -> { + entries.add(new int[]{type, amount}); + return true; + }); + } try { SqlQueries.batchUpdate( @@ -238,7 +248,9 @@ public class HabboInfo implements Runnable { } public int getCurrencyAmount(int type) { - return this.currencies.get(type); + synchronized (this.currencyLock) { + return this.currencies.get(type); + } } public TIntIntHashMap getCurrencies() { @@ -246,12 +258,16 @@ public class HabboInfo implements Runnable { } public void addCurrencyAmount(int type, int amount) { - this.currencies.adjustOrPutValue(type, amount, amount); + synchronized (this.currencyLock) { + this.currencies.adjustOrPutValue(type, amount, amount); + } this.run(); } public void setCurrencyAmount(int type, int amount) { - this.currencies.put(type, amount); + synchronized (this.currencyLock) { + this.currencies.put(type, amount); + } this.run(); } @@ -384,16 +400,22 @@ public class HabboInfo implements Runnable { } public int getCredits() { - return this.credits; + synchronized (this.currencyLock) { + return this.credits; + } } public void setCredits(int credits) { - this.credits = credits; + synchronized (this.currencyLock) { + this.credits = credits; + } this.run(); } public void addCredits(int credits) { - this.credits += credits; + synchronized (this.currencyLock) { + this.credits += credits; + } this.run(); } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java index b45c372f..cbd84b82 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java @@ -460,6 +460,16 @@ public class HabboStats implements Runnable { } } + /** Atomic read-add-write so concurrent progress sources don't lose updates. Returns the new total. */ + public int incrementProgress(Achievement achievement, int amount) { + synchronized (this.achievementProgress) { + Integer current = this.achievementProgress.get(achievement); + int next = (current != null ? current : 0) + amount; + this.achievementProgress.put(achievement, next); + return next; + } + } + public int getRentedTimeEnd() { return this.rentedTimeEnd; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java index a80067b7..fdf052bd 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java @@ -53,6 +53,15 @@ public class CatalogBuyItemEvent extends MessageHandler { String extraData = this.packet.readString(); int count = this.packet.readInt(); + // Clamp the client-supplied quantity. Without this the club-offer + // branch accumulates cost in plain ints and a huge count overflows + // to a negative total, bypassing the affordability checks and + // CREDITING the buyer (free currency/subscription exploit). + if (count < 1 || count > 100) { + this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR).compose()); + return; + } + try { if (this.client.getHabbo().getInventory().getItemsComponent().itemCount() > HabboInventory.MAXIMUM_ITEMS) { this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR).compose()); 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 e0d16e2d..50a04b24 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,6 +12,7 @@ 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() { @@ -27,7 +28,7 @@ public class HousekeepingGiveCreditsEvent extends MessageHandler { int userId = this.packet.readInt(); int amount = this.packet.readInt(); - if (userId <= 0 || amount == 0) { + if (userId <= 0 || amount == 0 || amount < -MAX_GRANT || amount > MAX_GRANT) { 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 ba347b93..afe2df66 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,6 +18,7 @@ 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() { @@ -36,7 +37,7 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler { String actionKey = "user.give_currency_" + currencyType; - if (userId <= 0 || amount == 0) { + if (userId <= 0 || amount == 0 || amount < -MAX_GRANT || amount > MAX_GRANT) { this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.invalid_input")); return; } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthRateLimiter.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthRateLimiter.java index 67cabf17..1d0186e3 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthRateLimiter.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthRateLimiter.java @@ -11,8 +11,34 @@ public final class AuthRateLimiter { private static final Map> STATE = new ConcurrentHashMap<>(); private static final Map> PROBE_STATE = new ConcurrentHashMap<>(); + // Both maps are keyed by client IP and reachable by unauthenticated traffic. + // recordSuccess removes STATE on login, but failed-only and probe-only IPs + // never get removed otherwise — unbounded growth over the JVM lifetime. + // Opportunistically evict window-expired entries once the maps get large. + private static final int SWEEP_THRESHOLD = 10_000; + private static final long SWEEP_MIN_INTERVAL_MS = 60_000L; + private static volatile long lastSweepMillis = 0L; + private AuthRateLimiter() {} + private static void maybeSweep(long now) { + if (STATE.size() < SWEEP_THRESHOLD && PROBE_STATE.size() < SWEEP_THRESHOLD) return; + if (now - lastSweepMillis < SWEEP_MIN_INTERVAL_MS) return; + lastSweepMillis = now; + + long stateWindowMs = configInt("login.ratelimit.window_sec", 60) * 1000L; + STATE.entrySet().removeIf(e -> { + State s = e.getValue().get(); + return s == null || (s.lockedUntilMillis <= now && (now - s.windowStartMillis) > stateWindowMs); + }); + + long probeWindowMs = configInt("login.probe.window_sec", 60) * 1000L; + PROBE_STATE.entrySet().removeIf(e -> { + ProbeState p = e.getValue().get(); + return p == null || (now - p.windowStartMillis) > probeWindowMs; + }); + } + public static boolean isLocked(String ip) { if (!isEnabled() || ip == null || ip.isEmpty()) return false; @@ -38,6 +64,7 @@ public final class AuthRateLimiter { if (!isEnabled() || ip == null || ip.isEmpty()) return; long now = System.currentTimeMillis(); + maybeSweep(now); long windowMs = configInt("login.ratelimit.window_sec", 60) * 1000L; int maxAttempts = configInt("login.ratelimit.max_attempts", 5); long lockoutMs = configInt("login.ratelimit.lockout_sec", 120) * 1000L; @@ -64,6 +91,7 @@ public final class AuthRateLimiter { if (isLocked(ip)) return false; long now = System.currentTimeMillis(); + maybeSweep(now); long windowMs = configInt("login.probe.window_sec", 60) * 1000L; int maxAttempts = configInt("login.probe.max_attempts", 20); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageRateLimit.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageRateLimit.java index 1b83a3ee..85967273 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageRateLimit.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageRateLimit.java @@ -23,7 +23,12 @@ public class GameMessageRateLimit extends MessageToMessageDecoder protected void decode(ChannelHandlerContext ctx, ClientMessage message, List out) throws Exception { GameClient client = ctx.channel().attr(GameServerAttributes.CLIENT).get(); + // ClientMessage is not ReferenceCounted, so MessageToMessageDecoder's + // auto-release is a no-op for it; on every drop path we must release the + // wrapped ByteBuf ourselves or it leaks (it is only released downstream + // in ChannelReadHandler on the success path). if (client == null) { + message.release(); return; } @@ -42,6 +47,7 @@ public class GameMessageRateLimit extends MessageToMessageDecoder } if (count > MAX_COUNTER) { + message.release(); return; } @@ -53,6 +59,7 @@ public class GameMessageRateLimit extends MessageToMessageDecoder LOGGER.warn("Global packet rate limit exceeded for {} ({} packets/sec) — dropping excess packets", username, globalCount); } + message.release(); return; } From 01c17c051198dece426f16680c89e95363e84821 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 11:11:56 +0000 Subject: [PATCH 04/21] fix: wired double-fire guard, RoomUnit path race, roomItems iteration, Netty CVE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuation of the concurrency hardening from the audit: - InteractionWired/WiredHandler (E4): add an atomic per-box processing guard so one trigger box is handled by a single thread at a time, making the cooldown check-and-set effectively atomic; mark `cooldown` volatile. Prevents a packet thread and the room cycle thread from double-firing the same wired stack (double teleport/reward). - RoomUnit (C1): the walk path is now a volatile ConcurrentLinkedDeque instead of a plain LinkedList, so the room cycle popping steps can't corrupt it while a walk packet rebuilds it via findPath/setPath. - RoomItemManager (C2): iterate roomItems under its own monitor in getFloorItems/ getWallItems/getPostItNotes/getUserUniqueFurniCount/getItemsAt, matching the existing put/remove sync sites — stops place/pickup from corrupting the traversal into a silently-incomplete item set. - pom.xml (S4): bump netty-all 4.1.115 -> 4.1.118.Final (CVE-2025-24970 SslHandler pre-auth DoS, CVE-2025-25193). --- Emulator/pom.xml | 2 +- .../items/interactions/InteractionWired.java | 16 +- .../habbohotel/rooms/RoomItemManager.java | 138 ++++++++++-------- .../eu/habbo/habbohotel/rooms/RoomUnit.java | 10 +- .../habbo/habbohotel/wired/WiredHandler.java | 12 ++ 5 files changed, 112 insertions(+), 66 deletions(-) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 022cec42..a1ab3749 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -83,7 +83,7 @@ io.netty netty-all - 4.1.115.Final + 4.1.118.Final diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionWired.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionWired.java index 6054066e..ffbf6996 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionWired.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionWired.java @@ -18,6 +18,7 @@ import java.sql.SQLException; import java.util.Iterator; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; /** * Base abstract class for all wired furniture items (triggers, effects, conditions, extras). @@ -61,7 +62,11 @@ public abstract class InteractionWired extends InteractionDefault { */ private static final long CACHE_EXPIRY_MS = 5 * 60 * 1000; - private long cooldown; + private volatile long cooldown; + // Ensures one box is processed by a single thread at a time, so the + // cooldown check-and-set in WiredHandler can't double-fire when a packet + // thread and the room cycle thread trigger the same box concurrently. + private final AtomicBoolean processing = new AtomicBoolean(false); private final ConcurrentHashMap userExecutionCache = new ConcurrentHashMap<>(); InteractionWired(ResultSet set, Item baseItem) throws SQLException { @@ -149,6 +154,15 @@ public abstract class InteractionWired extends InteractionDefault { this.cooldown = newMillis; } + /** Claims exclusive processing of this box; returns false if another thread is already in it. */ + public boolean tryBeginProcessing() { + return this.processing.compareAndSet(false, true); + } + + public void endProcessing() { + this.processing.set(false); + } + @Override public boolean allowWiredResetState() { return false; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomItemManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomItemManager.java index d823d8ea..b307d989 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomItemManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomItemManager.java @@ -167,17 +167,22 @@ public class RoomItemManager { */ public THashSet getFloorItems() { THashSet items = new THashSet<>(); - TIntObjectIterator iterator = this.roomItems.iterator(); + // roomItems is a TCollections.synchronizedMap; its iterator is not safe + // against concurrent put/remove (item place/pickup), so hold the map + // monitor for the whole traversal, matching the mutation sites. + synchronized (this.roomItems) { + TIntObjectIterator iterator = this.roomItems.iterator(); - for (int i = this.roomItems.size(); i-- > 0; ) { - try { - iterator.advance(); - } catch (Exception e) { - break; - } + for (int i = this.roomItems.size(); i-- > 0; ) { + try { + iterator.advance(); + } catch (Exception e) { + break; + } - if (iterator.value().getBaseItem().getType() == FurnitureType.FLOOR) { - items.add(iterator.value()); + if (iterator.value().getBaseItem().getType() == FurnitureType.FLOOR) { + items.add(iterator.value()); + } } } @@ -189,17 +194,19 @@ public class RoomItemManager { */ public THashSet getWallItems() { THashSet items = new THashSet<>(); - TIntObjectIterator iterator = this.roomItems.iterator(); + synchronized (this.roomItems) { + TIntObjectIterator iterator = this.roomItems.iterator(); - for (int i = this.roomItems.size(); i-- > 0; ) { - try { - iterator.advance(); - } catch (Exception e) { - break; - } + for (int i = this.roomItems.size(); i-- > 0; ) { + try { + iterator.advance(); + } catch (Exception e) { + break; + } - if (iterator.value().getBaseItem().getType() == FurnitureType.WALL) { - items.add(iterator.value()); + if (iterator.value().getBaseItem().getType() == FurnitureType.WALL) { + items.add(iterator.value()); + } } } @@ -211,18 +218,20 @@ public class RoomItemManager { */ public THashSet getPostItNotes() { THashSet items = new THashSet<>(); - TIntObjectIterator iterator = this.roomItems.iterator(); + synchronized (this.roomItems) { + TIntObjectIterator iterator = this.roomItems.iterator(); - for (int i = this.roomItems.size(); i-- > 0; ) { - try { - iterator.advance(); - } catch (Exception e) { - break; - } + for (int i = this.roomItems.size(); i-- > 0; ) { + try { + iterator.advance(); + } catch (Exception e) { + break; + } - if (iterator.value().getBaseItem().getInteractionType().getType() - == InteractionPostIt.class) { - items.add(iterator.value()); + if (iterator.value().getBaseItem().getInteractionType().getType() + == InteractionPostIt.class) { + items.add(iterator.value()); + } } } @@ -276,44 +285,49 @@ public class RoomItemManager { } } - TIntObjectIterator iterator = this.roomItems.iterator(); + // Cache miss: iterate roomItems under its monitor so a concurrent + // place/pickup can't rehash the map mid-traversal (which the per-advance + // try/catch would otherwise silently swallow into an incomplete result). + synchronized (this.roomItems) { + TIntObjectIterator iterator = this.roomItems.iterator(); - for (int i = this.roomItems.size(); i-- > 0; ) { - HabboItem item; - try { - iterator.advance(); - item = iterator.value(); - } catch (Exception e) { - break; - } + for (int i = this.roomItems.size(); i-- > 0; ) { + HabboItem item; + try { + iterator.advance(); + item = iterator.value(); + } catch (Exception e) { + break; + } - if (item == null) { - continue; - } + if (item == null) { + continue; + } - if (item.getBaseItem().getType() != FurnitureType.FLOOR) { - continue; - } + if (item.getBaseItem().getType() != FurnitureType.FLOOR) { + continue; + } - int width, length; + int width, length; - if (item.getRotation() != 2 && item.getRotation() != 6) { - width = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1; - length = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1; - } else { - width = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1; - length = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1; - } + if (item.getRotation() != 2 && item.getRotation() != 6) { + width = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1; + length = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1; + } else { + width = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1; + length = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1; + } - if (!(tile.x >= item.getX() && tile.x <= item.getX() + width - 1 && tile.y >= item.getY() - && tile.y <= item.getY() + length - 1)) { - continue; - } + if (!(tile.x >= item.getX() && tile.x <= item.getX() + width - 1 && tile.y >= item.getY() + && tile.y <= item.getY() + length - 1)) { + continue; + } - items.add(item); + items.add(item); - if (returnOnFirst) { - return items; + if (returnOnFirst) { + return items; + } } } @@ -956,9 +970,11 @@ public class RoomItemManager { public int getUserUniqueFurniCount(int userId) { THashSet items = new THashSet<>(); - for (HabboItem item : this.roomItems.valueCollection()) { - if (!items.contains(item.getBaseItem()) && item.getUserId() == userId) { - items.add(item.getBaseItem()); + synchronized (this.roomItems) { + for (HabboItem item : this.roomItems.valueCollection()) { + if (!items.contains(item.getBaseItem()) && item.getUserId() == userId) { + items.add(item.getBaseItem()); + } } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomUnit.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomUnit.java index 7b9326e0..af4987f9 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomUnit.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomUnit.java @@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ScheduledFuture; import java.util.stream.Collectors; @@ -71,7 +72,10 @@ public class RoomUnit { private RoomUserRotation headRotation = RoomUserRotation.NORTH; private DanceType danceType; private RoomUnitType roomUnitType; - private Deque path = new LinkedList<>(); + // Concurrent + volatile: the room cycle thread polls/clears this path while a + // walk packet thread rebuilds it via findPath/setPath. A plain LinkedList would + // corrupt under the concurrent structural modification. + private volatile Deque path = new ConcurrentLinkedDeque<>(); private int handItem; private long handItemTimestamp; private long lastRollerTime; @@ -587,7 +591,7 @@ public class RoomUnit { Deque newPath = this.room.getLayout().getPathfinder() .findPath(this.currentLocation, this.goalLocation, this.goalLocation, this); if (newPath != null && !newPath.isEmpty()) { - this.path = newPath; + this.path = new ConcurrentLinkedDeque<>(newPath); } } @@ -765,7 +769,7 @@ public class RoomUnit { } public void setPath(Deque path) { - this.path = path; + this.path = (path == null) ? new ConcurrentLinkedDeque<>() : new ConcurrentLinkedDeque<>(path); } public RoomRightLevels getRightsLevel() { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/WiredHandler.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/WiredHandler.java index 9ee3631a..119e1745 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/WiredHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/WiredHandler.java @@ -178,6 +178,15 @@ public class WiredHandler { private static boolean handle(InteractionWiredTrigger trigger, final RoomUnit roomUnit, final Room room, final Object[] stuff, final LegacyExecutionPlan executionPlan) { long millis = System.currentTimeMillis(); int roomUnitId = roomUnit != null ? roomUnit.getId() : -1; + + // Only one thread may process a given trigger box at a time, so the + // cooldown check (below) and setCooldown (further down) act as one + // atomic claim — preventing a concurrent packet/cycle double-fire. + if (!trigger.tryBeginProcessing()) { + return false; + } + + try { if (Emulator.isReady && ((Emulator.getConfig().getBoolean("wired.custom.enabled", false) && (trigger.canExecute(millis) || roomUnitId > -1) && trigger.userCanExecute(roomUnitId, millis)) || (!Emulator.getConfig().getBoolean("wired.custom.enabled", false) && trigger.canExecute(millis))) && trigger.execute(roomUnit, room, stuff)) { THashSet conditions = room.getRoomSpecialTypes().getConditions(trigger.getX(), trigger.getY()); THashSet effects = room.getRoomSpecialTypes().getEffects(trigger.getX(), trigger.getY()); @@ -272,6 +281,9 @@ public class WiredHandler { } return false; + } finally { + trigger.endProcessing(); + } } private static boolean evaluateConditions(THashSet conditions, RoomUnit roomUnit, Room room, Object[] stuff, int evaluationMode, int evaluationValue) { From 373d0399c189ad626215a7099967ac7e8b126eb3 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 11:20:12 +0000 Subject: [PATCH 05/21] fix: trusted-proxy gate for forwarded IP, wired-var cache + ghost-session cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security (S3): - AuthHttpUtil/WebSocketHttpHandler: only honour the configured ws.ip.header forwarded-IP header when the DIRECT peer is a trusted reverse proxy, instead of trusting it unconditionally. Loopback is always trusted; extra proxies can be allow-listed (exact IP or string prefix, comma-separated) via the new `ws.ip.header.trusted` config key — default-deny so the header can't be spoofed from the open internet to evade per-IP rate limiting and IP bans. Also take only the first comma token when setting the game-session WS_IP. Leak cleanup (C4): - WiredVariableReferenceSupport.invalidateRoom(): drop a room's shared wired-variable assignment caches; called from Room.dispose so the static USER/ROOM_ASSIGNMENT_CACHE maps don't retain entries for the JVM lifetime. - SessionResumeManager.parkHabbo: if the scheduler refuses the grace-expiry task (future == null), disconnect immediately instead of parking an un-reapable GhostSession that would pin the Habbo + room refs forever. Note: ws.ip.header.trusted defaults to loopback-only; deployments whose proxy is on another host must add its IP/prefix to that key or client IPs will collapse to the proxy address. --- .../gameclients/SessionResumeManager.java | 9 +++++ .../extra/WiredVariableReferenceSupport.java | 12 +++++++ .../com/eu/habbo/habbohotel/rooms/Room.java | 4 +++ .../gameserver/auth/AuthHttpUtil.java | 33 +++++++++++++++++-- .../handlers/WebSocketHttpHandler.java | 10 ++++-- 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java index e8099bbc..5052168f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java @@ -71,6 +71,15 @@ public class SessionResumeManager { } }, graceSeconds * 1000); + if (future == null) { + // The scheduler refused the grace-expiry task (pool saturated or + // shutting down). Parking now would leave a GhostSession that nothing + // can ever reap (the Habbo + room refs pinned for the JVM lifetime), + // so disconnect immediately instead. + performFullDisconnect(habbo); + return false; + } + ghostSessions.put(userId, new GhostSession(habbo, ssoTicket, future, previousEffectId, previousEffectEnd)); applyPausedEffect(habbo); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java index d8de5ab4..344524dc 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java @@ -227,6 +227,18 @@ public final class WiredVariableReferenceSupport { USER_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); } + /** + * Drops all cached shared-variable assignments belonging to a room. Both + * caches are keyed "roomId:itemId[:userId]", so the trailing colon makes the + * prefix match the exact room id. Called on room dispose so the static caches + * don't retain entries for the JVM lifetime. + */ + public static void invalidateRoom(int roomId) { + String prefix = roomId + ":"; + USER_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); + ROOM_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); + } + public static SharedRoomAssignment getSharedRoomAssignment(WiredExtraVariableReference reference) { if (reference == null || !reference.isRoomReference()) { return null; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java index b8b25ceb..1a69f83f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java @@ -1028,6 +1028,10 @@ public class Room implements Comparable, ISerialize, Runnable { com.eu.habbo.habbohotel.wired.core.WiredManager.getEngine().clearRoomDiagnostics(this.id); } + // Drop this room's shared wired-variable assignment caches (otherwise + // they accrue per (room, item, user) for the JVM lifetime). + com.eu.habbo.habbohotel.items.interactions.wired.extra.WiredVariableReferenceSupport.invalidateRoom(this.id); + this.itemManager.clear(); this.unitManager.clearQueue(); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java index 0d949ec8..acd03b82 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java @@ -28,7 +28,7 @@ import java.sql.SQLException; import java.util.Base64; import java.util.regex.Pattern; -final class AuthHttpUtil { +public final class AuthHttpUtil { private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpUtil.class); @@ -132,7 +132,10 @@ final class AuthHttpUtil { String ipHeader = Emulator.getConfig() != null ? Emulator.getConfig().getValue("ws.ip.header", "") : ""; - if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) { + // Only trust a client-supplied forwarded-IP header when the DIRECT peer + // is a trusted reverse proxy; otherwise an attacker hitting the port + // directly could spoof it to evade per-IP rate limiting and IP bans. + if (!ipHeader.isEmpty() && req.headers().contains(ipHeader) && isTrustedProxy(ctx)) { String hv = req.headers().get(ipHeader); if (hv != null && !hv.isEmpty()) { int comma = hv.indexOf(','); @@ -148,6 +151,32 @@ final class AuthHttpUtil { return ""; } + /** + * Whether the channel's direct peer may set a forwarded-IP header. Loopback + * is always trusted; additional proxies can be allow-listed (exact IP or + * string prefix, comma-separated) via the {@code ws.ip.header.trusted} + * config key. Default-deny so the header can't be spoofed from the open net. + */ + public static boolean isTrustedProxy(ChannelHandlerContext ctx) { + String peerIp = (ctx.channel().remoteAddress() instanceof InetSocketAddress a) + ? a.getAddress().getHostAddress() : null; + if (peerIp == null || peerIp.isEmpty()) return false; + if (peerIp.equals("127.0.0.1") || peerIp.equals("::1") || peerIp.equals("0:0:0:0:0:0:0:1")) { + return true; + } + String trusted = Emulator.getConfig() != null + ? Emulator.getConfig().getValue("ws.ip.header.trusted", "") + : ""; + if (trusted.isEmpty()) return false; + for (String entry : trusted.split(",")) { + String prefix = entry.trim(); + if (!prefix.isEmpty() && (peerIp.equals(prefix) || peerIp.startsWith(prefix))) { + return true; + } + } + return false; + } + static boolean checkPassword(String plain, String stored) { String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored; try { diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java index 572e9488..d356c0e5 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java @@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.handlers; import com.eu.habbo.Emulator; import com.eu.habbo.networking.gameserver.GameServerAttributes; +import com.eu.habbo.networking.gameserver.auth.AuthHttpUtil; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; @@ -65,9 +66,14 @@ public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter { private static void captureForwardedIp(ChannelHandlerContext ctx, HttpMessage req) { String ipHeader = Emulator.getConfig().getValue("ws.ip.header", ""); - if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) { + // Only honour the forwarded-IP header from a trusted reverse proxy, + // otherwise the game-session IP (used for bans/rate-limits) is spoofable. + if (!ipHeader.isEmpty() && req.headers().contains(ipHeader) && AuthHttpUtil.isTrustedProxy(ctx)) { String ip = req.headers().get(ipHeader); - ctx.channel().attr(GameServerAttributes.WS_IP).set(ip); + if (ip != null && !ip.isEmpty()) { + int comma = ip.indexOf(','); + ctx.channel().attr(GameServerAttributes.WS_IP).set((comma > 0 ? ip.substring(0, comma) : ip).trim()); + } } } From 1c4449fb8801d902b2c0ce61d491afc3100ecca7 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 11:27:14 +0000 Subject: [PATCH 06/21] perf: run auth HTTP endpoints off the Netty event loop (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/auth/* handlers ran inline on the Netty worker event loop, so their blocking work — BCrypt (cost 12 ~tens of ms), JDBC, the Turnstile HTTPS round-trip and SMTP — stalled every other client multiplexed on that thread; a burst of logins/registers could freeze game traffic. Dispatch each auth request to a dedicated bounded pool (4..16 daemon threads, bounded queue, 503 on saturation) instead. It is deliberately SEPARATE from the shared game ThreadPooling so auth load can't starve room cycles either. Netty writes are thread-safe, so the endpoints' sendJson calls work unchanged from the worker; the FullHttpRequest is released when the task finishes. Caveat: this allows concurrent handling of pipelined requests on a single keep-alive connection (out-of-order responses) — not a concern for the Nitro client which is strictly request/response, but worth load-testing before prod. --- .../gameserver/auth/AuthHttpHandler.java | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java index a743a30b..bf1e9369 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java @@ -9,8 +9,15 @@ import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.QueryStringDecoder; import io.netty.util.ReferenceCountUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.nio.charset.StandardCharsets; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.MAX_BODY_BYTES; import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.errorPayload; @@ -21,6 +28,25 @@ import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendJson; public class AuthHttpHandler extends ChannelInboundHandlerAdapter { + private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpHandler.class); + + // Dedicated, bounded pool for the auth endpoints. Their work blocks on + // BCrypt, JDBC, the Turnstile HTTPS round-trip and SMTP — running that on the + // Netty event loop stalls every client on the same worker. A SEPARATE pool + // (not the shared game ThreadPooling) also keeps it from starving room cycles. + private static final ThreadPoolExecutor AUTH_EXECUTOR = new ThreadPoolExecutor( + 4, 16, 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(512), + new java.util.concurrent.ThreadFactory() { + private final AtomicInteger counter = new AtomicInteger(1); + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "auth-http-worker-" + counter.getAndIncrement()); + t.setDaemon(true); + return t; + } + }); + static final String LOGIN_PATH = "/api/auth/login"; static final String REGISTER_PATH = "/api/auth/register"; static final String FORGOT_PATH = "/api/auth/forgot-password"; @@ -52,10 +78,30 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { return; } + // Offload the (potentially blocking) auth work off the event loop. Netty + // writes are thread-safe, so the endpoints' sendJson/writeAndFlush calls + // are fine from the worker; the request is released once the work ends. try { - handle(ctx, req, path); - } finally { - ReferenceCountUtil.release(req); + AUTH_EXECUTOR.execute(() -> { + try { + handle(ctx, req, path); + } catch (Throwable t) { + LOGGER.error("Auth handler failed for {}", path, t); + try { + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Internal error.")); + } catch (Throwable ignored) { + // response may already be partially written — nothing else to do + } + } finally { + ReferenceCountUtil.release(req); + } + }); + } catch (RejectedExecutionException rejected) { + try { + sendJson(ctx, req, HttpResponseStatus.SERVICE_UNAVAILABLE, errorPayload("Server busy, try again shortly.")); + } finally { + ReferenceCountUtil.release(req); + } } } From 45d01876c1efa0153bae0827877f2b20b116d115 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 11:28:26 +0000 Subject: [PATCH 07/21] fix: bound the move-blocking Future.get in RoomUserWalkEvent roomUnit.getMoveBlockingTask().get() blocked the Netty event loop with no timeout; a stuck/delayed move-blocking task would park the worker thread (and every client on it) indefinitely. Wait at most 2s, then proceed with the walk. --- .../messages/incoming/rooms/users/RoomUserWalkEvent.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserWalkEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserWalkEvent.java index 51a38650..63e905af 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserWalkEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserWalkEvent.java @@ -163,7 +163,13 @@ public class RoomUserWalkEvent extends MessageHandler { } if (roomUnit.getMoveBlockingTask() != null) { - roomUnit.getMoveBlockingTask().get(); + try { + // Bound the wait so a stuck/delayed move-blocking task can't park + // the Netty event loop (and thus every client on it) indefinitely. + roomUnit.getMoveBlockingTask().get(2, java.util.concurrent.TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException | java.util.concurrent.ExecutionException | InterruptedException e) { + // proceed with the walk regardless + } } boolean needsLocationResync = From 4eb1484dafe1c531494442c262b5e9464ef57c5c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 11:36:34 +0000 Subject: [PATCH 08/21] perf: run game packet handlers off the Netty I/O loop + bound A* pathfinding (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause from the CPU audit: every incoming packet handler ran on the Netty I/O event loop (MULTI_THREADED_PACKET_HANDLING is false by default), so any blocking handler — login DB + loadHabbo, friends/polls/catalog/guild-forum JDBC (~48 handlers), synchronous A* per walk — stalled socket I/O for every other client sharing that I/O thread. - WebSocketChannelInitializer: register GameMessageHandler on a dedicated DefaultEventExecutorGroup (max(16, 2x cores), daemon). Netty pins each channel to one executor in the group, so a client's packets stay strictly ordered (no new intra-client races) while blocking work moves off the I/O loop. The cross-client concurrency degree matches the already-multi-threaded I/O group, and this is strictly safer than the existing (order-losing) shared-pool MULTI_THREADED_PACKET_HANDLING mode the codebase already supported. - GameMessageHandler: always run the handler inline (now on the group thread); drop the shared-pool branch (which would break per-channel ordering and also removes the rejectable-pool ByteBuf-drop path). - PathfinderImpl: default the A* execution-time guard ON (25ms) so a pathological search returns an empty path instead of running unbounded on its thread. Note: this changes the server's packet-threading model — verified to compile, unit-test, and assemble the shaded jar, but should be load-tested before prod. Group size is currently derived from CPU count; can be made a config key if tuning is needed. --- .../rooms/pathfinding/impl/PathfinderImpl.java | 5 ++++- .../gameserver/WebSocketChannelInitializer.java | 16 +++++++++++++++- .../gameserver/decoders/GameMessageHandler.java | 15 +++++++-------- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/pathfinding/impl/PathfinderImpl.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/pathfinding/impl/PathfinderImpl.java index b6bd7df5..c3105f54 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/pathfinding/impl/PathfinderImpl.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/pathfinding/impl/PathfinderImpl.java @@ -24,8 +24,11 @@ public class PathfinderImpl implements Pathfinder { private static final int CACHED_TIMEOUT_MS = Emulator.getConfig() .getInt(CONFIG_EXECUTION_TIME, 25); + // Default ON: bound A* to CACHED_TIMEOUT_MS (25ms) so a pathological search + // can't run unbounded and stall the thread. On timeout findPath returns an + // empty path (the unit simply doesn't move there) — graceful degradation. private static final boolean CACHED_TIMEOUT_ENABLED = Emulator.getConfig() - .getBoolean(CONFIG_TIMEOUT_ENABLED, false); + .getBoolean(CONFIG_TIMEOUT_ENABLED, true); private static final long CACHED_TIMEOUT_NANOS = CACHED_TIMEOUT_MS * 1_000_000L; private final Room room; diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java index 6418992d..44a1b6be 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java @@ -26,12 +26,26 @@ import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; import io.netty.handler.logging.LoggingHandler; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslHandler; +import io.netty.util.concurrent.DefaultEventExecutorGroup; +import io.netty.util.concurrent.DefaultThreadFactory; +import io.netty.util.concurrent.EventExecutorGroup; import javax.net.ssl.SSLEngine; public class WebSocketChannelInitializer extends ChannelInitializer { private static final int MAX_FRAME_SIZE = 500000; + // Runs the game packet handler OFF the Netty I/O event loop, so a blocking + // handler (login/friends/catalog/guild JDBC, A* pathfinding, etc.) can no + // longer stall socket I/O for every other client sharing that I/O thread. + // A DefaultEventExecutorGroup pins each channel to one executor, so a single + // client's packets stay strictly ordered (no new intra-client races); the + // cross-client concurrency degree is the same the multi-threaded I/O group + // already had. Daemon threads so they don't block JVM shutdown. + private static final EventExecutorGroup PACKET_HANDLER_GROUP = new DefaultEventExecutorGroup( + Math.max(16, Runtime.getRuntime().availableProcessors() * 2), + new DefaultThreadFactory("GamePacketHandler", true)); + private final SslContext sslContext; private final boolean sslEnabled; private final WebSocketServerProtocolConfig wsConfig; @@ -82,7 +96,7 @@ public class WebSocketChannelInitializer extends ChannelInitializer Date: Tue, 9 Jun 2026 15:42:22 +0000 Subject: [PATCH 09/21] =?UTF-8?q?fix:=20deep-analysis=20pass=20=E2=80=94?= =?UTF-8?q?=20self-review=20regressions=20+=20pre-existing=20logic=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regressions found by an adversarial review of this branch's own diff: - RoomCycleManager: stop holding the currentBots/currentPets monitor across the whole bot/pet tick — snapshot under the lock then cycle off-lock. The previous fix blocked place/pickup and room dispose for the full tick and inverted lock order vs roomUnitLock->currentBots (latent deadlock for any future cycle code touching roomUnitLock). - HabboInfo: complete the currencyLock invariant — getCurrencies() now returns a snapshot under the lock (UserInfoCommand iterated the live Trove map off-lock, the exact rehash corruption the lock guards); canBuy() uses the lock-guarded getCredits()/getCurrencyAmount(); run() reads credits under the lock for save. - RoomSpecialTypes: synchronize the by-id pet getters (getNest/getPetDrink/ getPetFood/getPetToy/getPetTree) to match their now-synchronized mutators. - AuthHttpUtil.isTrustedProxy: exact-match trusted IPs; only treat an entry as a range when it ends with "."/":" so "10.0.0.1" can't also trust "10.0.0.12". Pre-existing logic bugs found by the deep subsystem analysis: - RoomUsersComposer: the bulk (room-entry) branch wrote the guild id twice; the second field must be the 1/-1 group-membership flag (matches the single branch) — every user showed a wrong group indicator on room entry. - BotManager.pickUpBot: room owners (and ACC_PLACEFURNI) couldn't pick up bots placed in their own room — added the room-owner clause that placeBot has. - PetPickupEvent: compared user id to pet.getId() instead of pet.getUserId(), so a pet owner who isn't the room owner couldn't pick up their own pet. - RoomRightsManager.refreshRightsForHabbo: in guild rooms, explicit room_rights were stripped (overwritten by guild level NONE); now takes the stronger of explicit rights and guild level. - RoomRequestBannedUsersEvent: `!hasRights || !ACC_ANYROOMOWNER` required BOTH, denying legitimate owners the banned-users list — corrected to `&&`. - InteractionPetBreedingNest.breed: a crafted packet on a not-full nest deleted the nest furni then NPE'd (furni loss); guard petOne/petTwo/room before the destructive delete; ConfirmPetBreedingEvent null-checks the room. - WiredEffectTeleport/UserFurniBase: appended item id instead of sprite id in the incompatible-triggers list (cosmetic wired-dialog mismatch) — matched the ~10 other effects' getBaseItem().getSpriteId() convention. --- .../eu/habbo/habbohotel/bots/BotManager.java | 6 +- .../pets/InteractionPetBreedingNest.java | 8 ++ .../wired/effects/WiredEffectTeleport.java | 2 +- .../effects/WiredEffectUserFurniBase.java | 2 +- .../habbohotel/rooms/RoomCycleManager.java | 76 +++++++++---------- .../habbohotel/rooms/RoomRightsManager.java | 12 ++- .../habbohotel/rooms/RoomSpecialTypes.java | 20 +++-- .../eu/habbo/habbohotel/users/HabboInfo.java | 17 ++++- .../rooms/RoomRequestBannedUsersEvent.java | 2 +- .../rooms/pets/ConfirmPetBreedingEvent.java | 6 +- .../incoming/rooms/pets/PetPickupEvent.java | 2 +- .../rooms/users/RoomUsersComposer.java | 2 +- .../gameserver/auth/AuthHttpUtil.java | 9 ++- 13 files changed, 106 insertions(+), 58 deletions(-) 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 ff2b1d72..7fd3ee93 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 @@ -188,7 +188,11 @@ public class BotManager { if (pickedUpEvent.isCancelled()) return; - if (habbo == null || (bot.getOwnerId() == habbo.getHabboInfo().getId() || habbo.hasPermission(Permission.ACC_ANYROOMOWNER))) { + Room currentRoom = habbo != null ? habbo.getHabboInfo().getCurrentRoom() : null; + if (habbo == null + || bot.getOwnerId() == habbo.getHabboInfo().getId() + || habbo.hasPermission(Permission.ACC_ANYROOMOWNER) + || (currentRoom != null && (currentRoom.getOwnerId() == habbo.getHabboInfo().getId() || habbo.hasPermission(Permission.ACC_PLACEFURNI)))) { if (habbo != null && !habbo.hasPermission(Permission.ACC_UNLIMITED_BOTS) && habbo.getInventory().getBotsComponent().getBots().size() >= BotManager.MAXIMUM_BOT_INVENTORY_SIZE) { habbo.alert(Emulator.getTexts().getValue("error.bots.max.inventory").replace("%amount%", BotManager.MAXIMUM_BOT_INVENTORY_SIZE + "")); return; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/pets/InteractionPetBreedingNest.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/pets/InteractionPetBreedingNest.java index 78880aab..075efac1 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/pets/InteractionPetBreedingNest.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/pets/InteractionPetBreedingNest.java @@ -190,6 +190,14 @@ public class InteractionPetBreedingNest extends HabboItem { } public void breed(Habbo habbo, String name, int petOneId, int petTwoId) { + // Guard before the destructive delete below: a crafted packet can call + // this on a nest that isn't full, which would delete the nest furni and + // then NPE on petOne/petTwo in the async runnable (losing the furni). + if (habbo == null || this.petOne == null || this.petTwo == null + || habbo.getHabboInfo().getCurrentRoom() == null) { + return; + } + Emulator.getThreading().run(new QueryDeleteHabboItem(this.getId())); this.setExtradata("2"); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectTeleport.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectTeleport.java index 92c60e46..6a5e4b59 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectTeleport.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectTeleport.java @@ -151,7 +151,7 @@ public class WiredEffectTeleport extends InteractionWiredEffect { @Override public boolean execute(InteractionWiredTrigger object) { if (!object.isTriggeredByRoomUnit()) { - invalidTriggers.add(object.getId()); + invalidTriggers.add(object.getBaseItem().getSpriteId()); } return true; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectUserFurniBase.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectUserFurniBase.java index 359e463b..7973090d 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectUserFurniBase.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectUserFurniBase.java @@ -252,7 +252,7 @@ public abstract class WiredEffectUserFurniBase extends InteractionWiredEffect { @Override public boolean execute(InteractionWiredTrigger object) { if (!object.isTriggeredByRoomUnit()) { - invalidTriggers.add(object.getId()); + invalidTriggers.add(object.getBaseItem().getSpriteId()); } return true; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java index ff4f4e25..da3ccc3a 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java @@ -300,37 +300,35 @@ public class RoomCycleManager { return; } - // currentBots is a TCollections.synchronizedMap; the iterator is not - // safe against concurrent put/remove from IO threads (bot place/pickup), - // so we must hold the map's monitor for the whole traversal. + // Snapshot under the map monitor (currentBots is a synchronizedMap whose + // iterator isn't concurrency-safe), then cycle OFF-lock. Holding the + // monitor across the whole tick would block bot place/pickup and room + // dispose for the tick duration AND invert the lock order vs + // roomUnitLock -> currentBots taken by RoomUnitManager.addBot/clear. + final ArrayList bots; synchronized (currentBots) { - TIntObjectIterator botIterator = currentBots.iterator(); - for (int i = currentBots.size(); i-- > 0; ) { - try { - final Bot bot; - try { - botIterator.advance(); - bot = botIterator.value(); - } catch (Exception e) { - break; - } + bots = new ArrayList<>(currentBots.valueCollection()); + } - if (!this.room.isAllowBotsWalk() && bot.getRoomUnit().isWalking()) { - bot.getRoomUnit().stopWalking(); - updatedUnit.add(bot.getRoomUnit()); - continue; - } - - bot.cycle(this.room.isAllowBotsWalk()); - - if (this.cycleRoomUnit(bot.getRoomUnit(), RoomUnitType.BOT)) { - updatedUnit.add(bot.getRoomUnit()); - } - - } catch (NoSuchElementException e) { - LOGGER.error("Caught exception", e); - break; + for (Bot bot : bots) { + try { + if (bot == null || bot.getRoomUnit() == null) { + continue; } + + if (!this.room.isAllowBotsWalk() && bot.getRoomUnit().isWalking()) { + bot.getRoomUnit().stopWalking(); + updatedUnit.add(bot.getRoomUnit()); + continue; + } + + bot.cycle(this.room.isAllowBotsWalk()); + + if (this.cycleRoomUnit(bot.getRoomUnit(), RoomUnitType.BOT)) { + updatedUnit.add(bot.getRoomUnit()); + } + } catch (Exception e) { + LOGGER.error("Caught exception", e); } } } @@ -344,19 +342,19 @@ public class RoomCycleManager { return; } - // currentPets is a TCollections.synchronizedMap; hold its monitor for the - // whole traversal to stay safe against concurrent pet place/pickup. + // Snapshot under the monitor, then cycle off-lock (see processBots): avoids + // holding currentPets for the whole tick and the roomUnitLock inversion. + final ArrayList pets; synchronized (currentPets) { - TIntObjectIterator petIterator = currentPets.iterator(); - for (int i = currentPets.size(); i-- > 0; ) { - try { - petIterator.advance(); - } catch (NoSuchElementException e) { - LOGGER.error("Caught exception", e); - break; + pets = new ArrayList<>(currentPets.valueCollection()); + } + + for (Pet pet : pets) { + try { + if (pet == null || pet.getRoomUnit() == null) { + continue; } - Pet pet = petIterator.value(); if (this.cycleRoomUnit(pet.getRoomUnit(), RoomUnitType.PET)) { updatedUnit.add(pet.getRoomUnit()); } @@ -373,6 +371,8 @@ public class RoomCycleManager { pet.getRoomUnit().removeStatus(RoomUnitStatus.GESTURE); updatedUnit.add(pet.getRoomUnit()); } + } catch (Exception e) { + LOGGER.error("Caught exception", e); } } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomRightsManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomRightsManager.java index c9eb266d..9166305b 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomRightsManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomRightsManager.java @@ -272,10 +272,16 @@ public class RoomRightsManager { } else if (this.isOwner(habbo)) { habbo.getClient().sendResponse(new RoomOwnerComposer()); flatCtrl = RoomRightLevels.MODERATOR; - } else if (this.hasRights(habbo) && !this.room.hasGuild()) { - flatCtrl = RoomRightLevels.RIGHTS; } else if (this.room.hasGuild()) { - flatCtrl = this.getGuildRightLevel(habbo); + // Explicit room rights must still be honoured in guild rooms (the old + // `&& !hasGuild()` guard stripped them for non-guild members) — take + // whichever of the two is stronger. + RoomRightLevels guildLevel = this.getGuildRightLevel(habbo); + flatCtrl = (this.hasRights(habbo) && RoomRightLevels.RIGHTS.isEqualOrGreaterThan(guildLevel)) + ? RoomRightLevels.RIGHTS + : guildLevel; + } else if (this.hasRights(habbo)) { + flatCtrl = RoomRightLevels.RIGHTS; } habbo.getClient().sendResponse(new RoomRightsComposer(flatCtrl)); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java index 544aca51..9e775568 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java @@ -152,7 +152,9 @@ public class RoomSpecialTypes { public InteractionNest getNest(int itemId) { - return this.nests.get(itemId); + synchronized (this.nests) { + return this.nests.get(itemId); + } } public void addNest(InteractionNest item) { @@ -180,7 +182,9 @@ public class RoomSpecialTypes { public InteractionPetDrink getPetDrink(int itemId) { - return this.petDrinks.get(itemId); + synchronized (this.petDrinks) { + return this.petDrinks.get(itemId); + } } public void addPetDrink(InteractionPetDrink item) { @@ -208,7 +212,9 @@ public class RoomSpecialTypes { public InteractionPetFood getPetFood(int itemId) { - return this.petFoods.get(itemId); + synchronized (this.petFoods) { + return this.petFoods.get(itemId); + } } public void addPetFood(InteractionPetFood item) { @@ -236,7 +242,9 @@ public class RoomSpecialTypes { public InteractionPetToy getPetToy(int itemId) { - return this.petToys.get(itemId); + synchronized (this.petToys) { + return this.petToys.get(itemId); + } } public void addPetToy(InteractionPetToy item) { @@ -264,7 +272,9 @@ public class RoomSpecialTypes { public InteractionPetTree getPetTree(int itemId) { - return this.petTrees.get(itemId); + synchronized (this.petTrees) { + return this.petTrees.get(itemId); + } } public void addPetTree(InteractionPetTree item) { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java index aa441a05..06bff0a0 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java @@ -254,7 +254,11 @@ public class HabboInfo implements Runnable { } public TIntIntHashMap getCurrencies() { - return this.currencies; + // Return a snapshot under the lock: callers iterate this map, which would + // otherwise corrupt during a concurrent adjustOrPutValue rehash. + synchronized (this.currencyLock) { + return new TIntIntHashMap(this.currencies); + } } public void addCurrencyAmount(int type, int amount) { @@ -396,7 +400,7 @@ public class HabboInfo implements Runnable { } public boolean canBuy(CatalogItem item) { - return this.credits >= item.getCredits() && this.getCurrencies().get(item.getPointsType()) >= item.getPoints(); + return this.getCredits() >= item.getCredits() && this.getCurrencyAmount(item.getPointsType()) >= item.getPoints(); } public int getCredits() { @@ -622,6 +626,13 @@ public class HabboInfo implements Runnable { public void run() { this.saveCurrencies(); + // Read credits under the lock so the persisted value is consistent with + // concurrent addCredits/setCredits (matches the currencyLock invariant). + final int creditsForSave; + synchronized (this.currencyLock) { + creditsForSave = this.credits; + } + try { SqlQueries.update( "UPDATE users SET motto = ?, online = ?, look = ?, gender = ?, credits = ?, last_login = ?, last_online = ?, home_room = ?, ip_current = ?, `rank` = ?, machine_id = ?, username = ?, background_id = ?, background_stand_id = ?, background_overlay_id = ?, background_card_id = ?, background_border_id = ? WHERE id = ?", @@ -629,7 +640,7 @@ public class HabboInfo implements Runnable { this.online ? "1" : "0", this.look, this.gender.name(), - this.credits, + creditsForSave, Emulator.getIntUnixTimestamp(), this.lastOnline, this.homeRoom, diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RoomRequestBannedUsersEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RoomRequestBannedUsersEvent.java index 321bdb42..e103bd0e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RoomRequestBannedUsersEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RoomRequestBannedUsersEvent.java @@ -14,7 +14,7 @@ public class RoomRequestBannedUsersEvent extends MessageHandler { Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId); if (room == null) return; - if (!room.hasRights(this.client.getHabbo()) || !this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) return; + if (!room.hasRights(this.client.getHabbo()) && !this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) return; this.client.sendResponse(new RoomBannedUsersComposer(room)); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/ConfirmPetBreedingEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/ConfirmPetBreedingEvent.java index 13a1e200..17692282 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/ConfirmPetBreedingEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/ConfirmPetBreedingEvent.java @@ -1,6 +1,7 @@ package com.eu.habbo.messages.incoming.rooms.pets; import com.eu.habbo.habbohotel.items.interactions.pets.InteractionPetBreedingNest; +import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.habbohotel.users.HabboItem; import com.eu.habbo.messages.incoming.MessageHandler; @@ -13,7 +14,10 @@ public class ConfirmPetBreedingEvent extends MessageHandler { int petOneId = this.packet.readInt(); int petTwoId = this.packet.readInt(); - HabboItem item = this.client.getHabbo().getHabboInfo().getCurrentRoom().getHabboItem(itemId); + Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); + if (room == null) return; + + HabboItem item = room.getHabboItem(itemId); if (item instanceof InteractionPetBreedingNest) { ((InteractionPetBreedingNest) item).breed(this.client.getHabbo(), name, petOneId, petTwoId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/PetPickupEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/PetPickupEvent.java index 3cce0a34..7da0c5f4 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/PetPickupEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/PetPickupEvent.java @@ -23,7 +23,7 @@ public class PetPickupEvent extends MessageHandler { Pet pet = room.getPet(petId); if (pet != null) { - if (this.client.getHabbo().getHabboInfo().getId() == pet.getId() || room.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) { + if (this.client.getHabbo().getHabboInfo().getId() == pet.getUserId() || room.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) { if (!this.client.getHabbo().hasPermission(Permission.ACC_UNLIMITED_PETS) && this.client.getHabbo().getInventory().getPetsComponent().getPets().size() >= PetManager.MAXIMUM_PET_INVENTORY_SIZE) { this.client.getHabbo().alert(Emulator.getTexts().getValue("error.pets.max.inventory").replace("%amount%", PetManager.MAXIMUM_PET_INVENTORY_SIZE + "")); return; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java index 51e00c4c..64bf2d1f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java @@ -99,7 +99,7 @@ public class RoomUsersComposer extends MessageComposer { this.response.appendInt(1); this.response.appendString(habbo.getHabboInfo().getGender().name().toUpperCase()); this.response.appendInt(habbo.getHabboStats().guild != 0 ? habbo.getHabboStats().guild : -1); - this.response.appendInt(habbo.getHabboStats().guild != 0 ? habbo.getHabboStats().guild : -1); + this.response.appendInt(habbo.getHabboStats().guild != 0 ? 1 : -1); String name = ""; if (habbo.getHabboStats().guild != 0) { Guild g = Emulator.getGameEnvironment().getGuildManager().getGuild(habbo.getHabboStats().guild); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java index acd03b82..644e9e1b 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java @@ -169,8 +169,13 @@ public final class AuthHttpUtil { : ""; if (trusted.isEmpty()) return false; for (String entry : trusted.split(",")) { - String prefix = entry.trim(); - if (!prefix.isEmpty() && (peerIp.equals(prefix) || peerIp.startsWith(prefix))) { + String t = entry.trim(); + if (t.isEmpty()) continue; + // Exact IP match, or a dotted/colon prefix range (e.g. "10.0.0." or + // "2001:db8:") — never a bare-IP prefix, so "10.0.0.1" can't also + // trust "10.0.0.12". + boolean isRange = t.endsWith(".") || t.endsWith(":"); + if (peerIp.equals(t) || (isRange && peerIp.startsWith(t))) { return true; } } From f7556138aa2abc0a4f474b37b6e273ec8ea19e51 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 15:50:19 +0000 Subject: [PATCH 10/21] feat: LIKE-wildcard escaping (security) + recycle/craft reward rollback (stability) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security / speed: - New util SqlLikeEscaper: escapes %, _ and \ in user search input. Applied to the user-facing LIKE searches (messenger user search, marketplace search, furni-editor search, housekeeping room search, guild member search) so a query like "%" can no longer match everything or trigger a needless full scan, and usernames containing "_" are matched literally. Stability (item-loss fixes): - RecycleEvent: compute the recycler reward BEFORE consuming the 8 inputs. The inputs were deleted from the DB first, so a null reward (misconfig) destroyed them permanently with nothing back. Now the inputs are only removed once the reward is confirmed. - CraftingCraftItemEvent: restore the pulled ingredients to the inventory if the recipe can't be completed (not enough ingredients mid-pull, or reward creation returns null) — previously they silently vanished from the inventory. --- .../catalog/marketplace/MarketPlace.java | 5 ++-- .../habbo/habbohotel/guilds/GuildManager.java | 2 +- .../habbo/habbohotel/messenger/Messenger.java | 2 +- .../catalog/recycler/RecycleEvent.java | 17 +++++++------ .../crafting/CraftingCraftItemEvent.java | 17 +++++++++++++ .../furnieditor/FurniEditorSearchEvent.java | 10 ++++---- .../HousekeepingSearchRoomsEvent.java | 2 +- .../com/eu/habbo/util/SqlLikeEscaper.java | 24 +++++++++++++++++++ 8 files changed, 63 insertions(+), 16 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/util/SqlLikeEscaper.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java index edbcd4a3..34fccb75 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java @@ -171,8 +171,9 @@ public class MarketPlace { statement.setInt(paramIndex++, maxPrice); } if (!search.isEmpty()) { - statement.setString(paramIndex++, "%" + search + "%"); - statement.setString(paramIndex++, "%" + search + "%"); + String likeSearch = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(search) + "%"; + statement.setString(paramIndex++, likeSearch); + statement.setString(paramIndex++, likeSearch); } try (ResultSet set = statement.executeQuery()) { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java index 5d02b165..b835a5f5 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java @@ -421,7 +421,7 @@ public class GuildManager { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT users.username, users.look, guilds_members.* FROM guilds_members INNER JOIN users ON guilds_members.user_id = users.id WHERE guilds_members.guild_id = ? " + (rankQuery(levelId)) + " AND users.username LIKE ? ORDER BY level_id, member_since ASC LIMIT ?, ?")) { statement.setInt(1, guild.getId()); - statement.setString(2, "%" + query + "%"); + statement.setString(2, "%" + com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%"); statement.setInt(3, page * 14); statement.setInt(4, 14); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java index f60c1b49..5dacc0cc 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java @@ -53,7 +53,7 @@ public class Messenger { public static THashSet searchUsers(String username) { THashSet users = new THashSet<>(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT * FROM users WHERE username LIKE ? ORDER BY username ASC LIMIT " + Emulator.getConfig().getInt("hotel.messenger.search.maxresults"))) { - statement.setString(1, username + "%"); + statement.setString(1, com.eu.habbo.util.SqlLikeEscaper.escape(username) + "%"); try (ResultSet set = statement.executeQuery()) { while (set.next()) { users.add(new MessengerBuddy(set, false)); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/RecycleEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/RecycleEvent.java index 884b6f6c..f1b6d8dd 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/RecycleEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/RecycleEvent.java @@ -40,23 +40,26 @@ public class RecycleEvent extends MessageHandler { } } - if (items.size() == count) { - for (HabboItem item : items) { - this.client.getHabbo().getInventory().getItemsComponent().removeHabboItem(item); - this.client.sendResponse(new RemoveHabboItemComposer(item.getGiftAdjustedId())); - Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId())); - } - } else { + if (items.size() != count) { this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR)); return; } + // Compute the reward BEFORE consuming the inputs. Previously the + // inputs were deleted first, so a null reward (misconfiguration) + // permanently destroyed the 8 furni with nothing in return. HabboItem reward = Emulator.getGameEnvironment().getItemManager().handleRecycle(this.client.getHabbo(), Emulator.getGameEnvironment().getCatalogManager().getRandomRecyclerPrize().getId() + ""); if (reward == null) { this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR)); return; } + for (HabboItem item : items) { + this.client.getHabbo().getInventory().getItemsComponent().removeHabboItem(item); + this.client.sendResponse(new RemoveHabboItemComposer(item.getGiftAdjustedId())); + Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId())); + } + this.client.sendResponse(new AddHabboItemComposer(reward)); this.client.getHabbo().getInventory().getItemsComponent().addItem(reward); this.client.sendResponse(new RecyclerCompleteComposer(RecyclerCompleteComposer.RECYCLING_COMPLETE)); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/crafting/CraftingCraftItemEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/crafting/CraftingCraftItemEvent.java index fe0c4993..6bd4f2e4 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/crafting/CraftingCraftItemEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/crafting/CraftingCraftItemEvent.java @@ -37,6 +37,8 @@ public class CraftingCraftItemEvent extends MessageHandler { HabboItem habboItem = this.client.getHabbo().getInventory().getItemsComponent().getAndRemoveHabboItem(set.getKey()); if (habboItem == null) { + // Not enough ingredients — give back whatever we already pulled. + this.restoreItems(toRemove); return; } @@ -70,8 +72,23 @@ public class CraftingCraftItemEvent extends MessageHandler { return; } + // Reward creation failed after we already pulled the ingredients — + // restore them so the craft isn't a silent item sink. + this.restoreItems(toRemove); } this.client.sendResponse(new CraftingResultComposer(null)); } + + private void restoreItems(TIntObjectHashMap items) { + if (items.isEmpty()) { + return; + } + items.forEachValue(item -> { + this.client.getHabbo().getInventory().getItemsComponent().addItem(item); + this.client.sendResponse(new AddHabboItemComposer(item)); + return true; + }); + this.client.sendResponse(new InventoryRefreshComposer()); + } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java index 274a5dbd..bcbd5626 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java @@ -49,15 +49,17 @@ public class FurniEditorSearchEvent extends MessageHandler { try { int numericQuery = Integer.parseInt(query); isNumeric = true; + String likeQuery = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%"; whereClause.append(" AND (id = ? OR sprite_id = ? OR item_name LIKE ? OR public_name LIKE ?)"); params.add(numericQuery); params.add(numericQuery); - params.add("%" + query + "%"); - params.add("%" + query + "%"); + params.add(likeQuery); + params.add(likeQuery); } catch (NumberFormatException e) { + String likeQuery = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%"; whereClause.append(" AND (item_name LIKE ? OR public_name LIKE ?)"); - params.add("%" + query + "%"); - params.add("%" + query + "%"); + params.add(likeQuery); + params.add(likeQuery); } } 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 985250c9..bccf6588 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 @@ -56,7 +56,7 @@ public class HousekeepingSearchRoomsEvent extends MessageHandler { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, exactMatch ? query : query + "%"); + statement.setString(1, exactMatch ? query : com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%"); statement.setInt(2, limit); try (ResultSet set = statement.executeQuery()) { diff --git a/Emulator/src/main/java/com/eu/habbo/util/SqlLikeEscaper.java b/Emulator/src/main/java/com/eu/habbo/util/SqlLikeEscaper.java new file mode 100644 index 00000000..e8784547 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/util/SqlLikeEscaper.java @@ -0,0 +1,24 @@ +package com.eu.habbo.util; + +/** + * Escapes the LIKE wildcards {@code %} and {@code _} (and the escape char itself) + * in user-supplied search input, so they are matched literally instead of acting + * as wildcards. Prevents wildcard-driven over-broad matches and the expensive + * full-scans an attacker could trigger with a query like {@code "%"}. Uses + * MariaDB's default escape character {@code \}. + */ +public final class SqlLikeEscaper { + + private SqlLikeEscaper() { + } + + public static String escape(String input) { + if (input == null) { + return ""; + } + return input + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + } +} From dcc23ba744641f88ba62df4ee82ded9f0fead6b0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 15:56:49 +0000 Subject: [PATCH 11/21] feat: housekeeping audit log + shared Gson instances Security: - HousekeepingAuditLog: append-only audit trail of privileged actions. There was no record of which operator granted ranks/currency to whom. SetUserRank, GiveCredits and GiveCurrency now log operator id+name, action, target, detail and IP. Writes are async; the housekeeping_log table is created on first use (CREATE TABLE IF NOT EXISTS) so no manual migration is needed. Speed (minor): - RCONServerHandler / PluginManager: reuse a shared Gson instead of allocating a new parser per request/plugin-config load (Gson is thread-safe). The wired Gson builders were already cached singletons. --- .../modtool/HousekeepingAuditLog.java | 97 +++++++++++++++++++ .../HousekeepingGiveCreditsEvent.java | 10 ++ .../HousekeepingGiveCurrencyEvent.java | 10 ++ .../HousekeepingSetUserRankEvent.java | 6 ++ .../rconserver/RCONServerHandler.java | 6 +- .../com/eu/habbo/plugin/PluginManager.java | 7 +- 6 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLog.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 new file mode 100644 index 00000000..0507115e --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLog.java @@ -0,0 +1,97 @@ +package com.eu.habbo.habbohotel.modtool; + +import com.eu.habbo.Emulator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +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. + */ +public final class HousekeepingAuditLog { + + private static final Logger LOGGER = LoggerFactory.getLogger(HousekeepingAuditLog.class); + + private static volatile boolean tableReady = false; + + private HousekeepingAuditLog() { + } + + /** + * Records a privileged action asynchronously. + * + * @param operatorId the acting staff member's user id + * @param operatorName the acting staff member's username + * @param action a short action key, e.g. {@code "user.set_rank"} + * @param targetUserId the affected user's id (0 if not applicable) + * @param detail free-form detail, e.g. {@code "rankId=6"} (capped to 512 chars) + * @param ip the operator's IP, for correlation + */ + public static void log(int operatorId, String operatorName, String action, int targetUserId, String detail, String ip) { + Emulator.getThreading().run(() -> writeEntry(operatorId, operatorName, action, targetUserId, detail, ip)); + } + + private static void writeEntry(int operatorId, String operatorName, String action, int targetUserId, String detail, String ip) { + ensureTable(); + + 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 : ""); + statement.setInt(4, targetUserId); + statement.setString(5, truncate(detail)); + statement.setString(6, ip != null ? ip : ""); + statement.setInt(7, Emulator.getIntUnixTimestamp()); + 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 void ensureTable() { + if (tableReady) { + return; + } + synchronized (HousekeepingAuditLog.class) { + if (tableReady) { + return; + } + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + 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 '', " + + "timestamp INT NOT NULL, " + + "PRIMARY KEY (id), " + + "KEY idx_operator (operator_id), " + + "KEY idx_target (target_user_id), " + + "KEY idx_timestamp (timestamp)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + tableReady = true; + } catch (SQLException e) { + LOGGER.error("Failed to create housekeeping_log table", e); + } + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCreditsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCreditsEvent.java index 50a04b24..c47bad24 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 @@ -39,6 +39,7 @@ public class HousekeepingGiveCreditsEvent extends MessageHandler { // giveCredits already pushes UserCreditsComposer and persists via the // standard HabboInfo write path; nothing extra needed for the online branch. online.giveCredits(amount); + this.audit(userId, amount); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); return; } @@ -58,6 +59,15 @@ public class HousekeepingGiveCreditsEvent extends MessageHandler { return; } + this.audit(userId, amount); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); } + + private void audit(int userId, int amount) { + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "amount=" + amount, + this.client.getHabbo().getHabboInfo().getIpLogin()); + } } 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 afe2df66..5e5053a1 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 @@ -53,6 +53,7 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler { online.givePoints(currencyType, amount); } + this.audit(actionKey, userId, currencyType, amount); this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, true, userId, "")); return; } @@ -70,6 +71,15 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler { return; } + this.audit(actionKey, userId, currencyType, amount); this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, true, userId, "")); } + + private void audit(String actionKey, int userId, int currencyType, int amount) { + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + actionKey, userId, "type=" + currencyType + " amount=" + amount, + this.client.getHabbo().getHabboInfo().getIpLogin()); + } } 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 dbdba31c..db100eb8 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 @@ -102,6 +102,12 @@ public class HousekeepingSetUserRankEvent extends MessageHandler { online.getClient().sendResponse(new UserPermissionsComposer(online)); } + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "rankId=" + rankId, + this.client.getHabbo().getHabboInfo().getIpLogin()); + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java index 0563fe0e..6d55c443 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java @@ -16,6 +16,10 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter { private static final Logger LOGGER = LoggerFactory.getLogger(RCONServerHandler.class); + // Gson is thread-safe and immutable once built — share one instance instead + // of allocating a parser per RCON request. + private static final Gson GSON = new Gson(); + @Override public void channelRegistered(ChannelHandlerContext ctx) throws Exception { String adress = ctx.channel().remoteAddress().toString().split(":")[0].replace("/", ""); @@ -38,7 +42,7 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter { byte[] d = new byte[data.readableBytes()]; data.getBytes(0, d); String message = new String(d); - Gson gson = new Gson(); + Gson gson = GSON; String response = "ERROR"; String key = ""; try { diff --git a/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java b/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java index a8fe1e44..59ff591a 100644 --- a/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java +++ b/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java @@ -67,6 +67,10 @@ public class PluginManager { private static final Logger LOGGER = LoggerFactory.getLogger(PluginManager.class); + // Gson is thread-safe and immutable once built — reuse one instance instead + // of building a parser per plugin-config load. + private static final Gson PLUGIN_GSON = new GsonBuilder().create(); + private final THashSet plugins = new THashSet<>(); private final THashSet methods = new THashSet<>(); @@ -275,8 +279,7 @@ public class PluginManager { if (stream.read(content) > 0) { String body = new String(content); - Gson gson = new GsonBuilder().create(); - HabboPluginConfiguration pluginConfigurtion = gson.fromJson(body, HabboPluginConfiguration.class); + HabboPluginConfiguration pluginConfigurtion = PLUGIN_GSON.fromJson(body, HabboPluginConfiguration.class); try { Class clazz = urlClassLoader.loadClass(pluginConfigurtion.main); From af82352f24d38cc267de4bfd6ad6d46840f5e395 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 16:05:16 +0000 Subject: [PATCH 12/21] feat: configurable pool sizes (#2) + pool-safe buffers and opt-in pooled allocator (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2 — tunable thread pools (sensible defaults kept): - io.packet.handler.threads overrides the packet-handler EventExecutorGroup size (default max(16, 2x cores)). - auth.http.pool.size overrides the auth HTTP pool max threads (default 16). #5 — Netty buffer pooling: - Make the crypto handlers pool-safe: GameByteEncryption/GameByteDecryption no longer call ByteBuf.array() on a readBytes-derived buffer (whose arrayOffset is non-zero under a pooled allocator, which would have read/encrypted the wrong region). They now copy the readable region into a plain byte[] (offset-safe) and wrap the result — also drops one intermediate buffer allocation. This is correct for the current unpooled allocator too. (ServerMessage uses its own Unpooled buffer, and ClientMessage reads via buffer methods, so both are already offset-safe.) - Add a shared channel allocator selected by io.netty.allocator.pooled (default false = unpooled-heap, unchanged). Set true for a pooled HEAP allocator (preferDirect=false, so array-backed paths keep working) to cut per-packet alloc/GC churn. Opt-in until validated under load with the Netty leak detector, since unreleased pooled buffers accumulate rather than being GC-reclaimed. New optional config keys (insert into emulator_settings to set/silence the "key not found" notice): io.packet.handler.threads, auth.http.pool.size, io.netty.allocator.pooled. --- .../java/com/eu/habbo/networking/Server.java | 29 ++++++++++++++++++- .../networking/gameserver/GameServer.java | 2 +- .../WebSocketChannelInitializer.java | 13 ++++++++- .../gameserver/auth/AuthHttpHandler.java | 14 ++++++++- .../decoders/GameByteDecryption.java | 14 +++++---- .../encoders/GameByteEncryption.java | 14 +++++---- 6 files changed, 72 insertions(+), 14 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/networking/Server.java b/Emulator/src/main/java/com/eu/habbo/networking/Server.java index 7ae6f43f..4062141c 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/Server.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/Server.java @@ -1,6 +1,9 @@ package com.eu.habbo.networking; +import com.eu.habbo.Emulator; import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.PooledByteBufAllocator; import io.netty.buffer.UnpooledByteBufAllocator; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelOption; @@ -18,6 +21,30 @@ public abstract class Server { private static final Logger LOGGER = LoggerFactory.getLogger(Server.class); + private static volatile ByteBufAllocator sharedAllocator; + + /** + * Shared channel allocator. Defaults to unpooled-heap (the long-standing + * behaviour); set {@code io.netty.allocator.pooled=true} to switch to a + * pooled HEAP allocator (preferDirect=false, so the array-backed crypto + * paths keep working) which removes the per-packet alloc/GC churn. Opt-in + * until validated under load with the Netty leak detector, since pooled + * buffers that aren't released accumulate instead of being GC-reclaimed. + */ + protected static ByteBufAllocator allocator() { + if (sharedAllocator == null) { + synchronized (Server.class) { + if (sharedAllocator == null) { + boolean pooled = Emulator.getConfig() != null + && "true".equalsIgnoreCase(Emulator.getConfig().getValue("io.netty.allocator.pooled", "false")); + sharedAllocator = pooled ? new PooledByteBufAllocator(false) : new UnpooledByteBufAllocator(false); + LOGGER.info("Netty ByteBuf allocator: {}", pooled ? "pooled-heap" : "unpooled-heap"); + } + } + } + return sharedAllocator; + } + protected final ServerBootstrap serverBootstrap; protected final EventLoopGroup bossGroup; protected final EventLoopGroup workerGroup; @@ -45,7 +72,7 @@ public abstract class Server { this.serverBootstrap.childOption(ChannelOption.SO_REUSEADDR, true); this.serverBootstrap.childOption(ChannelOption.SO_RCVBUF, 4096); this.serverBootstrap.childOption(ChannelOption.RCVBUF_ALLOCATOR, new FixedRecvByteBufAllocator(4096)); - this.serverBootstrap.childOption(ChannelOption.ALLOCATOR, new UnpooledByteBufAllocator(false)); + this.serverBootstrap.childOption(ChannelOption.ALLOCATOR, allocator()); } public void connect() { diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java index b3f81c91..fe0939e5 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java @@ -84,7 +84,7 @@ public class GameServer extends Server { this.webSocketBootstrap.childOption(ChannelOption.SO_REUSEADDR, true); this.webSocketBootstrap.childOption(ChannelOption.SO_RCVBUF, 4096); this.webSocketBootstrap.childOption(ChannelOption.RCVBUF_ALLOCATOR, new FixedRecvByteBufAllocator(4096)); - this.webSocketBootstrap.childOption(ChannelOption.ALLOCATOR, new UnpooledByteBufAllocator(false)); + this.webSocketBootstrap.childOption(ChannelOption.ALLOCATOR, allocator()); this.webSocketBootstrap.childHandler(wsInitializer); ChannelFuture wsFuture = this.webSocketBootstrap.bind(wsHost, wsPort); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java index 44a1b6be..a5ca2c87 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java @@ -43,9 +43,20 @@ public class WebSocketChannelInitializer extends ChannelInitializer 0 ? configured : fallback; + } + private final SslContext sslContext; private final boolean sslEnabled; private final WebSocketServerProtocolConfig wsConfig; diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java index bf1e9369..0944b6b6 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java @@ -34,8 +34,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { // BCrypt, JDBC, the Turnstile HTTPS round-trip and SMTP — running that on the // Netty event loop stalls every client on the same worker. A SEPARATE pool // (not the shared game ThreadPooling) also keeps it from starving room cycles. + private static final int AUTH_POOL_MAX = authPoolMax(); private static final ThreadPoolExecutor AUTH_EXECUTOR = new ThreadPoolExecutor( - 4, 16, 60L, TimeUnit.SECONDS, + Math.min(4, AUTH_POOL_MAX), AUTH_POOL_MAX, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(512), new java.util.concurrent.ThreadFactory() { private final AtomicInteger counter = new AtomicInteger(1); @@ -47,6 +48,17 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } }); + // Max threads for the auth pool. Defaults to 16; set the optional + // `auth.http.pool.size` config key to override. + private static int authPoolMax() { + int fallback = 16; + if (com.eu.habbo.Emulator.getConfig() == null) { + return fallback; + } + int configured = com.eu.habbo.Emulator.getConfig().getInt("auth.http.pool.size", fallback); + return configured > 0 ? configured : fallback; + } + static final String LOGIN_PATH = "/api/auth/login"; static final String REGISTER_PATH = "/api/auth/register"; static final String FORGOT_PATH = "/api/auth/forgot-password"; diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecryption.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecryption.java index 126648ed..6845206e 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecryption.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecryption.java @@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.decoders; import com.eu.habbo.networking.gameserver.GameServerAttributes; import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.ByteToMessageDecoder; @@ -15,14 +16,17 @@ public class GameByteDecryption extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { - // Read all available bytes. - ByteBuf data = in.readBytes(in.readableBytes()); + // Copy the readable region into a plain array (offset-safe, so this is + // correct for pooled buffers too — buf.array() would have read the wrong + // region for a pooled/sliced buffer). + byte[] bytes = new byte[in.readableBytes()]; + in.readBytes(bytes); - // Decrypt. - ctx.channel().attr(GameServerAttributes.CRYPTO_CLIENT).get().parse(data.array()); + // Decrypt in place. + ctx.channel().attr(GameServerAttributes.CRYPTO_CLIENT).get().parse(bytes); // Continue in the pipeline. - out.add(data); + out.add(Unpooled.wrappedBuffer(bytes)); } } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/encoders/GameByteEncryption.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/encoders/GameByteEncryption.java index d2930c52..e7a9a09b 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/encoders/GameByteEncryption.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/encoders/GameByteEncryption.java @@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.encoders; import com.eu.habbo.networking.gameserver.GameServerAttributes; import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelOutboundHandlerAdapter; import io.netty.channel.ChannelPromise; @@ -14,16 +15,19 @@ public class GameByteEncryption extends ChannelOutboundHandlerAdapter { // convert to Bytebuf ByteBuf in = (ByteBuf) msg; - // read available bytes - ByteBuf data = (in).readBytes(in.readableBytes()); + // Copy the readable region into a plain array (respects readerIndex / + // arrayOffset, so this is correct for pooled buffers too — buf.array() + // would have returned the wrong region for a pooled/sliced buffer). + byte[] bytes = new byte[in.readableBytes()]; + in.readBytes(bytes); //release old object ReferenceCountUtil.release(in); - // Encrypt. - ctx.channel().attr(GameServerAttributes.CRYPTO_SERVER).get().parse(data.array()); + // Encrypt in place. + ctx.channel().attr(GameServerAttributes.CRYPTO_SERVER).get().parse(bytes); // Continue in the pipeline. - ctx.write(data, promise); + ctx.write(Unpooled.wrappedBuffer(bytes), promise); } } From a9f1903465be372151654f02993196ee02952f4d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 16:09:36 +0000 Subject: [PATCH 13/21] chore(deps): update dependencies to latest stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped to the latest stable within each safe major line (no source changes needed — all APIs compatible; verified with clean compile + test + shaded jar): - io.netty:netty-all 4.1.118 -> 4.1.135.Final - com.google.code.gson:gson 2.11.0 -> 2.14.0 - org.mariadb.jdbc:* 3.5.1 -> 3.5.8 - com.zaxxer:HikariCP 6.2.1 -> 6.3.3 - org.apache.commons:commons-lang3 3.17.0 -> 3.20.0 - org.jsoup:jsoup 1.18.3 -> 1.22.2 - org.slf4j:slf4j-api 2.0.16 -> 2.0.18 - ch.qos.logback:logback-classic 1.5.15 -> 1.5.34 - org.fusesource.jansi:jansi 2.4.1 -> 2.4.3 - joda-time:joda-time 2.13.0 -> 2.14.2 - org.eclipse.angus:jakarta.mail 2.0.3 -> 2.0.5 - org.junit.jupiter:junit-jupiter 5.10.2 -> 5.14.4 - maven-surefire-plugin 3.2.5 -> 3.5.2 Deliberately NOT changed: - Stayed on the netty 4.1.x line (4.2/5.0 are new majors with API changes), slf4j 2.0.x (logback 1.5.x requires it), junit 5.x (6.x needs a new baseline), and HikariCP 6.x (kept the current major for DB-pool stability; 7.x available). - trove4j 3.0.3, commons-math3 3.6.1, jbcrypt 0.4 — already at their final (unmaintained) releases. - compiler source/target=19 / release=21 — intentional per project convention. --- Emulator/pom.xml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index a1ab3749..2964c8b2 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -66,7 +66,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.2.5 + 3.5.2 @@ -83,21 +83,21 @@ io.netty netty-all - 4.1.118.Final + 4.1.135.Final com.google.code.gson gson - 2.11.0 + 2.14.0 org.mariadb.jdbc mariadb-java-client - 3.5.1 + 3.5.8 runtime @@ -113,7 +113,7 @@ com.zaxxer HikariCP - 6.2.1 + 6.3.3 compile @@ -121,7 +121,7 @@ org.apache.commons commons-lang3 - 3.17.0 + 3.20.0 compile @@ -137,7 +137,7 @@ org.jsoup jsoup - 1.18.3 + 1.22.2 compile @@ -145,14 +145,14 @@ org.slf4j slf4j-api - 2.0.16 + 2.0.18 ch.qos.logback logback-classic - 1.5.15 + 1.5.34 compile @@ -160,14 +160,14 @@ org.fusesource.jansi jansi - 2.4.1 + 2.4.3 joda-time joda-time - 2.13.0 + 2.14.2 org.junit.jupiter junit-jupiter - 5.10.2 + 5.14.4 test From fad6be158ad7f2d7adce3c3ae04132aaa50469c9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 16:15:23 +0000 Subject: [PATCH 14/21] chore(deps): upgrade Netty (4.2), HikariCP (7) and JUnit (6) to latest major MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major-version upgrades of the three the previous bump deliberately held back. Verified: clean compile, all 15 tests run green (surefire 3.5.2 drives the JUnit 6 platform fine — no extra launcher dep needed), and the shaded jar assembles. - io.netty:netty-all 4.1.135.Final -> 4.2.15.Final - com.zaxxer:HikariCP 6.3.3 -> 7.0.2 - org.junit.jupiter:junit-jupiter 5.14.4 -> 6.1.0 Notes: - Stayed on Netty 4.2 (GA), not 5.0 which is still Alpha. No source changes needed; the channel ALLOCATOR is set explicitly so 4.2's new default adaptive allocator doesn't apply. NioEventLoopGroup is deprecated in 4.2 but still functions as before (left as-is to avoid an event-loop behavioural change). netty-all 4.2 pulls more transitive modules, so the fat jar grows (~20->32 MB). - HikariCP 7.x baselines Java 17; our HikariConfig usage is unchanged. --- Emulator/pom.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 2964c8b2..30c0c42c 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -83,7 +83,7 @@ io.netty netty-all - 4.1.135.Final + 4.2.15.Final @@ -113,7 +113,7 @@ com.zaxxer HikariCP - 6.3.3 + 7.0.2 compile @@ -190,7 +190,7 @@ org.junit.jupiter junit-jupiter - 5.14.4 + 6.1.0 test From 62104596ac483f6662f7ac4da4438e89a6cbd002 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 16:19:58 +0000 Subject: [PATCH 15/21] refactor(netty): migrate off the deprecated NioEventLoopGroup (Netty 4.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netty 4.2 deprecates NioEventLoopGroup in favour of the generic MultiThreadIoEventLoopGroup driven by an IoHandlerFactory. Server.java now builds its boss/worker groups with MultiThreadIoEventLoopGroup(.., NioIoHandler.newFactory()) — functionally equivalent, and the codebase now compiles with zero deprecation warnings. Verified: compile, 15/15 tests, shaded jar. --- .../src/main/java/com/eu/habbo/networking/Server.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/networking/Server.java b/Emulator/src/main/java/com/eu/habbo/networking/Server.java index 4062141c..61258f2d 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/Server.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/Server.java @@ -9,7 +9,8 @@ import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelOption; import io.netty.channel.EventLoopGroup; import io.netty.channel.FixedRecvByteBufAllocator; -import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.nio.NioIoHandler; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.util.concurrent.DefaultThreadFactory; import org.slf4j.Logger; @@ -59,8 +60,10 @@ public abstract class Server { String threadName = name.replace("Server", "").replace(" ", ""); - this.bossGroup = new NioEventLoopGroup(bossGroupThreads, new DefaultThreadFactory(threadName + "Boss")); - this.workerGroup = new NioEventLoopGroup(workerGroupThreads, new DefaultThreadFactory(threadName + "Worker")); + // Netty 4.2: NioEventLoopGroup is deprecated in favour of the generic + // MultiThreadIoEventLoopGroup driven by an IoHandlerFactory (NIO here). + this.bossGroup = new MultiThreadIoEventLoopGroup(bossGroupThreads, new DefaultThreadFactory(threadName + "Boss"), NioIoHandler.newFactory()); + this.workerGroup = new MultiThreadIoEventLoopGroup(workerGroupThreads, new DefaultThreadFactory(threadName + "Worker"), NioIoHandler.newFactory()); this.serverBootstrap = new ServerBootstrap(); } From b6ee400b8344e82f269595cadd08ea2c44a724a4 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 16:27:37 +0000 Subject: [PATCH 16/21] refactor: drop Joda-Time (-> java.time) and make protocol charsets explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modernization following the dependency upgrades: - Joda-Time was used in exactly one place (ModToolSanctionInfoComposer, to subtract probation days from a Date). Migrated to java.time (Instant/ZoneId.systemDefault, calendar-accurate like the old Joda call) and removed the joda-time dependency entirely — confirmed gone from the shaded jar. - Make string<->bytes conversions explicitly UTF-8 instead of relying on the platform default. Most importantly the wire codec (ClientMessage.readString / ServerMessage.appendString) — both sides now pinned to UTF-8 so international characters are robust regardless of -Dfile.encoding. Also RCONServerHandler, PluginManager and the WS origin-forbidden response. Verified: clean compile, 15/15 tests, shaded jar. --- Emulator/pom.xml | 7 ------- .../src/main/java/com/eu/habbo/messages/ClientMessage.java | 4 +++- .../src/main/java/com/eu/habbo/messages/ServerMessage.java | 3 ++- .../outgoing/modtool/ModToolSanctionInfoComposer.java | 7 +++++-- .../gameserver/handlers/WebSocketHttpHandler.java | 2 +- .../eu/habbo/networking/rconserver/RCONServerHandler.java | 4 ++-- .../src/main/java/com/eu/habbo/plugin/PluginManager.java | 2 +- 7 files changed, 14 insertions(+), 15 deletions(-) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 30c0c42c..ada1fd06 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -163,13 +163,6 @@ 2.4.3 - - - joda-time - joda-time - 2.14.2 - - diff --git a/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java b/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java index 3cdfb710..5817a758 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java @@ -4,6 +4,8 @@ import com.eu.habbo.util.PacketUtils; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; +import java.nio.charset.StandardCharsets; + public class ClientMessage { private final int header; private final ByteBuf buffer; @@ -71,7 +73,7 @@ public class ClientMessage { } byte[] data = new byte[length]; this.buffer.readBytes(data); - return new String(data); + return new String(data, StandardCharsets.UTF_8); } catch (Exception e) { return ""; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/ServerMessage.java b/Emulator/src/main/java/com/eu/habbo/messages/ServerMessage.java index 7b03768e..73660a55 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/ServerMessage.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/ServerMessage.java @@ -7,6 +7,7 @@ import io.netty.buffer.ByteBufOutputStream; import io.netty.buffer.Unpooled; import java.io.IOException; +import java.nio.charset.StandardCharsets; public class ServerMessage { @@ -61,7 +62,7 @@ public class ServerMessage { } try { - byte[] data = obj.getBytes(); + byte[] data = obj.getBytes(StandardCharsets.UTF_8); this.stream.writeShort(data.length); this.stream.write(data); } catch (IOException e) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolSanctionInfoComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolSanctionInfoComposer.java index b281fe53..875e1597 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolSanctionInfoComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolSanctionInfoComposer.java @@ -9,8 +9,8 @@ import com.eu.habbo.messages.ServerMessage; import com.eu.habbo.messages.outgoing.MessageComposer; import com.eu.habbo.messages.outgoing.Outgoing; import gnu.trove.map.hash.THashMap; -import org.joda.time.DateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Date; @@ -47,7 +47,10 @@ public class ModToolSanctionInfoComposer extends MessageComposer { if (item.probationTimestamp > 0) { probationEndTime = new Date((long) item.probationTimestamp * 1000); - probationStartTime = new DateTime(probationEndTime).minusDays(modToolSanctions.getProbationDays(modToolSanctionLevelItem)).toDate(); + probationStartTime = Date.from(probationEndTime.toInstant() + .atZone(ZoneId.systemDefault()) + .minusDays(modToolSanctions.getProbationDays(modToolSanctionLevelItem)) + .toInstant()); Date tradeLockedUntil = null; diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java index d356c0e5..0ed1eb60 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java @@ -54,7 +54,7 @@ public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter { FullHttpResponse response = new DefaultFullHttpResponse( HttpVersion.HTTP_1_1, HttpResponseStatus.FORBIDDEN, - Unpooled.wrappedBuffer("Origin forbidden".getBytes()) + Unpooled.wrappedBuffer("Origin forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8)) ); response.headers().set("Vary", "Origin"); ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java index 6d55c443..26226413 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java @@ -41,7 +41,7 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter { byte[] d = new byte[data.readableBytes()]; data.getBytes(0, d); - String message = new String(d); + String message = new String(d, java.nio.charset.StandardCharsets.UTF_8); Gson gson = GSON; String response = "ERROR"; String key = ""; @@ -56,7 +56,7 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter { e.printStackTrace(); } - ChannelFuture f = ctx.channel().write(Unpooled.copiedBuffer(response.getBytes()), ctx.channel().voidPromise()); + ChannelFuture f = ctx.channel().write(Unpooled.copiedBuffer(response.getBytes(java.nio.charset.StandardCharsets.UTF_8)), ctx.channel().voidPromise()); ctx.channel().flush(); ctx.flush(); f.channel().close(); diff --git a/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java b/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java index 59ff591a..767d9976 100644 --- a/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java +++ b/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java @@ -277,7 +277,7 @@ public class PluginManager { byte[] content = new byte[stream.available()]; if (stream.read(content) > 0) { - String body = new String(content); + String body = new String(content, java.nio.charset.StandardCharsets.UTF_8); HabboPluginConfiguration pluginConfigurtion = PLUGIN_GSON.fromJson(body, HabboPluginConfiguration.class); From 61ea33ac28ab22f4332e361084a1552f11c520dd Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 20:06:31 +0200 Subject: [PATCH 17/21] docs(config): document new networking/threading keys from the hardening batch Add commented examples for the config keys introduced by this PR so operators can discover and tune them (defaults apply if unset): - ws.ip.header.trusted (trusted reverse-proxy gate for the forwarded-IP header) - io.packet.handler.threads (game packet-handler pool, off the Netty I/O loop) - auth.http.pool.size (dedicated /api/auth/* worker pool) - io.netty.allocator.pooled (opt-in pooled ByteBuf allocator) --- Latest_Compiled_Version/config.ini.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Latest_Compiled_Version/config.ini.example b/Latest_Compiled_Version/config.ini.example index 547083ea..835e7f92 100644 --- a/Latest_Compiled_Version/config.ini.example +++ b/Latest_Compiled_Version/config.ini.example @@ -68,3 +68,9 @@ login.news.limit=5 ### ws.port=2096 ### ws.whitelist=localhost #Comma-separated whitelist of allowed origins. Supports wildcards: *.example.com, * (allow all) ### ws.ip.header=X-Forwarded-For #Header name for real client IP when behind a proxy (e.g., X-Forwarded-For, CF-Connecting-IP). Leave empty if not using a proxy. +### ws.ip.header.trusted= #Comma-separated trusted reverse-proxy IPs/prefixes (entries ending in '.' or ':' are prefix ranges, e.g. 10.0.0.) allowed to set ws.ip.header. Loopback (127.0.0.1/::1) is ALWAYS trusted; default-deny otherwise so the forwarded header can't be spoofed from the open net. + +#Performance / concurrency (optional — sensible defaults apply if unset; adjust in the Database). +### io.packet.handler.threads=24 #Game packet-handler pool size; runs game handlers OFF the Netty I/O loop. Default max(16, 2 x CPU cores). +### auth.http.pool.size=16 #Dedicated worker pool for the /api/auth/* HTTP endpoints (BCrypt/JDBC/Turnstile/SMTP run off the event loop). Default 16. +### io.netty.allocator.pooled=false #Set true to opt into Netty's pooled ByteBuf allocator. Default false (unpooled-heap). From d984461cc065f05b8be2e2a3b5553176fe22544d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 20:50:12 +0200 Subject: [PATCH 18/21] fix(login): don't reject login when the machine fingerprint arrives after the SSO ticket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Nitro renderer sends the UniqueID (machine fingerprint) packet right AFTER the SSOTicket, so Habbo.connect() ran before the machineId was set and returned false on the empty machineId — silently disposing the client (WS closed with Netty's default "Bye"), so login never completed and no SecureLoginOK was sent. - Habbo.connect(): only set machineID + run the MAC-ban check when the fingerprint is already present; never reject the login solely for a missing machineId (also drop a duplicated MAC/IP-ban block). - MachineIDEvent: enforce the MAC ban when the fingerprint arrives after login, preserving the ban check that connect() now defers. --- .../com/eu/habbo/habbohotel/users/Habbo.java | 30 +++++++------------ .../incoming/handshake/MachineIDEvent.java | 7 +++++ 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/Habbo.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/Habbo.java index 61f8075e..9983c49c 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/Habbo.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/Habbo.java @@ -146,31 +146,23 @@ public class Habbo implements Runnable { this.habboInfo.setIpLogin(ip); } - if (this.client.getMachineId() == null || this.client.getMachineId().length() == 0) { - return false; - } + // The Nitro client sends the UniqueID (machine fingerprint) packet right + // AFTER the SSO ticket, so client.getMachineId() may still be null here. + // Do NOT reject the login for a missing machineId — MachineIDEvent sets it + // and enforces the MAC ban as soon as the UniqueID packet arrives. Only + // MAC-ban check here when the fingerprint is already available. + String machineId = this.client.getMachineId(); + if (machineId != null && !machineId.isEmpty()) { + this.habboInfo.setMachineID(machineId); - this.habboInfo.setMachineID(this.client.getMachineId()); - - if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) { - return false; + if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) { + return false; + } } if (Emulator.getGameEnvironment().getModToolManager().hasIPBan(this.habboInfo.getIpLogin())) { return false; } - - this.habboInfo.setMachineID(this.client.getMachineId()); - - if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) { - return false; - } - - if (Emulator.getGameEnvironment().getModToolManager().hasIPBan(this.habboInfo.getIpLogin())) { - return false; - } - - this.habboInfo.setMachineID(this.client.getMachineId()); this.isOnline(true); this.messenger.connectionChanged(this, true, false); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java index 64696bf7..b39fda3f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java @@ -32,6 +32,13 @@ public class MachineIDEvent extends MessageHandler { if (!storedMachineId.isEmpty() && this.client.getHabbo() != null && this.client.getHabbo().getHabboInfo() != null) { this.client.getHabbo().getHabboInfo().setMachineID(storedMachineId); Emulator.getThreading().run(this.client.getHabbo()); + + // The fingerprint can arrive AFTER login (UniqueID is sent right after the + // SSO ticket), so Habbo.connect() may have skipped the MAC-ban check for + // lack of a machineId. Enforce it now that the fingerprint is known. + if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) { + Emulator.getGameServer().getGameClientManager().disposeClient(this.client); + } } LOGGER.debug("Setting client MachineId to {}", storedMachineId); From 5c0f2d2855d4f593f8a52fc8f2d0661d9b0ac62c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 22:02:07 +0200 Subject: [PATCH 19/21] fix(session): separate forced disconnects from resume parking Add a forced dispose path for bans, RCON disconnects, logout/account endpoints, plugin-cancelled login, duplicate login replacement, and late MAC-ban enforcement. Soft channel closes still park a session for reconnect, while security-driven closes now bypass session resume. Also null-guard client/channel disposal and cover the contract with focused tests. --- .../habbohotel/gameclients/GameClient.java | 8 ++++-- .../gameclients/GameClientManager.java | 26 ++++++++++++++++--- .../habbo/habbohotel/users/HabboManager.java | 2 +- .../incoming/handshake/MachineIDEvent.java | 4 +-- .../incoming/handshake/SecureLoginEvent.java | 2 +- .../habbo/messages/rcon/DisconnectUser.java | 4 +-- .../auth/AccountChangeEndpoints.java | 2 +- .../gameserver/auth/SessionEndpoints.java | 2 +- .../GameClientManagerContractTest.java | 22 ++++++++++++++++ 9 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/gameclients/GameClientManagerContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClient.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClient.java index 7ffe5228..87d5fb3d 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClient.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClient.java @@ -149,6 +149,10 @@ public class GameClient { } public void dispose() { + this.dispose(true); + } + + public void dispose(boolean allowSessionResume) { try { this.channel.close(); @@ -161,7 +165,7 @@ public class GameClient { // appena ripristinata (era la causa del "Bye"/kick al 2° reconnect). if (this.habbo.getClient() == this && this.habbo.isOnline()) { // Try to park the habbo in the grace period instead of immediate disconnect - boolean parked = SessionResumeManager.getInstance().parkHabbo(this.habbo, this.ssoTicket); + boolean parked = allowSessionResume && SessionResumeManager.getInstance().parkHabbo(this.habbo, this.ssoTicket); if (!parked) { // No grace period configured — immediate disconnect as before @@ -177,4 +181,4 @@ public class GameClient { LOGGER.error("Caught exception", e); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClientManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClientManager.java index cd0602cb..d07e33b7 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClientManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClientManager.java @@ -43,14 +43,34 @@ public class GameClientManager { public void disposeClient(GameClient client) { - this.disposeClient(client.getChannel()); + if (client == null) { + return; + } + + this.disposeClient(client.getChannel(), true); + } + + public void forceDisposeClient(GameClient client) { + if (client == null) { + return; + } + + this.disposeClient(client.getChannel(), false); } private void disposeClient(Channel channel) { + this.disposeClient(channel, true); + } + + private void disposeClient(Channel channel, boolean allowSessionResume) { + if (channel == null) { + return; + } + GameClient client = channel.attr(GameServerAttributes.CLIENT).get(); if (client != null) { - client.dispose(); + client.dispose(allowSessionResume); } channel.deregister(); channel.attr(GameServerAttributes.CLIENT).set(null); @@ -190,4 +210,4 @@ public class GameClientManager { CFKeepAlive(); }, 30000); } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java index 48261f66..ef602679 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java @@ -111,7 +111,7 @@ public class HabboManager { habbo = this.cloneCheck(userId); if (habbo != null) { habbo.alert(Emulator.getTexts().getValue("loggedin.elsewhere")); - Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient()); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(habbo.getClient()); habbo = null; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java index b39fda3f..b9c7fd71 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java @@ -37,10 +37,10 @@ public class MachineIDEvent extends MessageHandler { // SSO ticket), so Habbo.connect() may have skipped the MAC-ban check for // lack of a machineId. Enforce it now that the fingerprint is known. if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) { - Emulator.getGameServer().getGameClientManager().disposeClient(this.client); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(this.client); } } LOGGER.debug("Setting client MachineId to {}", storedMachineId); } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java index cdebe27d..f9704bec 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java @@ -306,7 +306,7 @@ public class SecureLoginEvent extends MessageHandler { Emulator.getPluginManager().fireEvent(userLoginEvent); if(userLoginEvent.isCancelled()) { - Emulator.getGameServer().getGameClientManager().disposeClient(this.client); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(this.client); return; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/DisconnectUser.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/DisconnectUser.java index 8b768ac0..8f8a51ed 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/DisconnectUser.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/DisconnectUser.java @@ -29,7 +29,7 @@ public class DisconnectUser extends RCONMessage GameClient.class.getDeclaredMethod("dispose", boolean.class)); + assertDoesNotThrow(() -> GameClientManager.class.getDeclaredMethod("forceDisposeClient", GameClient.class)); + } + + @Test + void disposeMethodsIgnoreNullClient() { + GameClientManager manager = new GameClientManager(); + + assertDoesNotThrow(() -> manager.disposeClient(null)); + assertDoesNotThrow(() -> manager.forceDisposeClient(null)); + } +} From 8161e3d7e5848474a1db063c45715285f07afe76 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 22:02:33 +0200 Subject: [PATCH 20/21] fix(moderation): harden ban and modtool edge cases Use executeUpdate with generated keys for offline ban inserts, return an empty result when an offline target cannot be loaded, and make ban commands handle empty results instead of indexing blindly. Modtool chatlog requests now guard missing users instead of dereferencing null. --- .../eu/habbo/habbohotel/commands/BanCommand.java | 10 +++++++++- .../eu/habbo/habbohotel/commands/IPBanCommand.java | 10 ++++++---- .../habbohotel/commands/MachineBanCommand.java | 7 +++++-- .../habbo/habbohotel/modtool/ModToolManager.java | 14 ++++++++++---- .../modtool/ModToolRequestUserChatlogEvent.java | 7 ++++++- 5 files changed, 36 insertions(+), 12 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/BanCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/BanCommand.java index c95a5b9a..90b2ea57 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/BanCommand.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/BanCommand.java @@ -9,6 +9,8 @@ import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.habbohotel.users.HabboInfo; import com.eu.habbo.habbohotel.users.HabboManager; +import java.util.List; + public class BanCommand extends Command { public BanCommand() { super("cmd_ban", Emulator.getTexts().getValue("commands.keys.cmd_ban").split(";")); @@ -72,7 +74,13 @@ public class BanCommand extends Command { } } - ModToolBan ban = Emulator.getGameEnvironment().getModToolManager().ban(target.getId(), gameClient.getHabbo(), reason.toString(), banTime, ModToolBanType.ACCOUNT, -1).get(0); + List bans = Emulator.getGameEnvironment().getModToolManager().ban(target.getId(), gameClient.getHabbo(), reason.toString(), banTime, ModToolBanType.ACCOUNT, -1); + if (bans == null || bans.isEmpty()) { + gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_ban.user_offline"), RoomChatMessageBubbles.ALERT); + return true; + } + + ModToolBan ban = bans.get(0); gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_ban.ban_issued").replace("%user%", target.getUsername()).replace("%time%", ban.expireDate - Emulator.getIntUnixTimestamp() + "").replace("%reason%", ban.reason), RoomChatMessageBubbles.ALERT); return true; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/IPBanCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/IPBanCommand.java index e37bb1e0..8622cec9 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/IPBanCommand.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/IPBanCommand.java @@ -8,6 +8,8 @@ import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.habbohotel.users.HabboInfo; import com.eu.habbo.habbohotel.users.HabboManager; +import java.util.List; + public class IPBanCommand extends Command { public final static int TEN_YEARS = 315569260; @@ -50,12 +52,12 @@ public class IPBanCommand extends Command { return true; } - Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1); - count++; + List bans = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1); + count += bans != null ? bans.size() : 0; for (Habbo h : Emulator.getGameServer().getGameClientManager().getHabbosWithIP(habbo.getIpLogin())) { if (h != null) { - count++; - Emulator.getGameEnvironment().getModToolManager().ban(h.getHabboInfo().getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1); + bans = Emulator.getGameEnvironment().getModToolManager().ban(h.getHabboInfo().getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1); + count += bans != null ? bans.size() : 0; } } } else { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/MachineBanCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/MachineBanCommand.java index 56f5b0fb..29918bb9 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/MachineBanCommand.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/MachineBanCommand.java @@ -8,6 +8,8 @@ import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.habbohotel.users.HabboInfo; import com.eu.habbo.habbohotel.users.HabboManager; +import java.util.List; + public class MachineBanCommand extends Command { public MachineBanCommand() { super("cmd_machine_ban", Emulator.getTexts().getValue("commands.keys.cmd_machine_ban").split(";")); @@ -46,7 +48,8 @@ public class MachineBanCommand extends Command { return true; } - count = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), IPBanCommand.TEN_YEARS, ModToolBanType.MACHINE, -1).size(); + List bans = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), IPBanCommand.TEN_YEARS, ModToolBanType.MACHINE, -1); + count = bans != null ? bans.size() : 0; } else { @@ -58,4 +61,4 @@ public class MachineBanCommand extends Command { return true; } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java index 03546504..0f59478d 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java @@ -378,7 +378,9 @@ public class ModToolManager { statement.setString(6, reason); statement.setString(7, type.getType()); - try (ResultSet set = statement.executeQuery()) { + statement.executeUpdate(); + + try (ResultSet set = statement.getGeneratedKeys()) { if (set.next()) { try (PreparedStatement selectBanStatement = connection.prepareStatement("SELECT * FROM bans WHERE id = ? LIMIT 1")) { selectBanStatement.setInt(1, set.getInt(1)); @@ -434,6 +436,10 @@ public class ModToolManager { Habbo target = Emulator.getGameEnvironment().getHabboManager().getHabbo(targetUserId); HabboInfo offlineInfo = target != null ? target.getHabboInfo() : HabboManager.getOfflineHabboInfo(targetUserId); + if (offlineInfo == null) { + return bans; + } + if (moderator.getHabboInfo().getRank().getId() < offlineInfo.getRank().getId()) { return bans; } @@ -454,7 +460,7 @@ public class ModToolManager { bans.add(ban); if (target != null) { - Emulator.getGameServer().getGameClientManager().disposeClient(target.getClient()); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(target.getClient()); } if ((type == ModToolBanType.IP || type == ModToolBanType.SUPER) && target != null && !ban.ip.equals("offline")) { @@ -465,7 +471,7 @@ public class ModToolManager { Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban)); Emulator.getThreading().run(ban); bans.add(ban); - Emulator.getGameServer().getGameClientManager().disposeClient(h.getClient()); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(h.getClient()); } } @@ -477,7 +483,7 @@ public class ModToolManager { Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban)); Emulator.getThreading().run(ban); bans.add(ban); - Emulator.getGameServer().getGameClientManager().disposeClient(h.getClient()); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(h.getClient()); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java index ad55465e..a175cdd0 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java @@ -3,6 +3,7 @@ package com.eu.habbo.messages.incoming.modtool; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.modtool.ScripterManager; import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.habbohotel.users.HabboInfo; import com.eu.habbo.habbohotel.users.HabboManager; import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.outgoing.modtool.ModToolUserChatlogComposer; @@ -12,7 +13,11 @@ public class ModToolRequestUserChatlogEvent extends MessageHandler { public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { int userId = this.packet.readInt(); - String username = HabboManager.getOfflineHabboInfo(userId).getUsername(); + HabboInfo habboInfo = HabboManager.getOfflineHabboInfo(userId); + if (habboInfo == null) { + return; + } + String username = habboInfo.getUsername(); this.client.sendResponse(new ModToolUserChatlogComposer(Emulator.getGameEnvironment().getModToolManager().getUserRoomVisitsAndChatlogs(userId), userId, username)); } else { From 19cde45d3ec22c238a1f637d3f1357ae32dec467 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 22:02:53 +0200 Subject: [PATCH 21/21] fix(marketplace): avoid inventory desync on failed offer insert Expose whether a marketplace offer was persisted before mutating inventory state, refuse sells whose database insert failed, and synchronize the sold timestamp into the online seller's in-memory offer when present. This keeps failed or racing marketplace operations from desynchronizing credits/items. --- .../catalog/marketplace/MarketPlace.java | 14 ++++++++++++-- .../catalog/marketplace/MarketPlaceOffer.java | 4 ++++ .../marketplace/MarketPlaceOfferContractTest.java | 13 +++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOfferContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java index 34fccb75..e7952892 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java @@ -279,8 +279,9 @@ public class MarketPlace { return; } + int soldTimestamp = Emulator.getIntUnixTimestamp(); try (PreparedStatement updateOffer = connection.prepareStatement("UPDATE marketplace_items SET state = 2, sold_timestamp = ? WHERE id = ? AND state = 1")) { - updateOffer.setInt(1, Emulator.getIntUnixTimestamp()); + updateOffer.setInt(1, soldTimestamp); updateOffer.setInt(2, offerId); int updated = updateOffer.executeUpdate(); if (updated == 0) { @@ -307,7 +308,11 @@ public class MarketPlace { client.sendResponse(new MarketplaceBuyErrorComposer(MarketplaceBuyErrorComposer.REFRESH, 0, offerId, price)); if (habbo != null) { - habbo.getInventory().getOffer(offerId).setState(MarketPlaceState.SOLD); + MarketPlaceOffer offer = habbo.getInventory().getOffer(offerId); + if (offer != null) { + offer.setState(MarketPlaceState.SOLD); + offer.setSoldTimestamp(soldTimestamp); + } } } } @@ -369,6 +374,11 @@ public class MarketPlace { event.item.setFromGift(false); MarketPlaceOffer offer = new MarketPlaceOffer(event.item, event.price, client.getHabbo()); + if (!offer.isPersisted()) { + LOGGER.warn("Marketplace offer insert failed for user {} item {}", client.getHabbo().getHabboInfo().getId(), event.item.getId()); + return false; + } + client.getHabbo().getInventory().addMarketplaceOffer(offer); client.getHabbo().getInventory().getItemsComponent().removeHabboItem(event.item); item.setUserId(-1); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOffer.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOffer.java index 37bd2ab1..ccf0dda1 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOffer.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOffer.java @@ -98,6 +98,10 @@ public class MarketPlaceOffer implements Runnable { return this.offerId; } + public boolean isPersisted() { + return this.offerId > 0; + } + public void setOfferId(int offerId) { this.offerId = offerId; } diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOfferContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOfferContractTest.java new file mode 100644 index 00000000..518f6ee7 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOfferContractTest.java @@ -0,0 +1,13 @@ +package com.eu.habbo.habbohotel.catalog.marketplace; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class MarketPlaceOfferContractTest { + + @Test + void exposesPersistenceState() { + assertDoesNotThrow(() -> MarketPlaceOffer.class.getDeclaredMethod("isPersisted")); + } +}