From f7556138aa2abc0a4f474b37b6e273ec8ea19e51 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 15:50:19 +0000 Subject: [PATCH] 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("_", "\\_"); + } +}