feat: LIKE-wildcard escaping (security) + recycle/craft reward rollback (stability)

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.
This commit is contained in:
simoleo89
2026-06-09 15:50:19 +00:00
parent a0910d822c
commit f7556138aa
8 changed files with 63 additions and 16 deletions
@@ -171,8 +171,9 @@ public class MarketPlace {
statement.setInt(paramIndex++, maxPrice); statement.setInt(paramIndex++, maxPrice);
} }
if (!search.isEmpty()) { if (!search.isEmpty()) {
statement.setString(paramIndex++, "%" + search + "%"); String likeSearch = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(search) + "%";
statement.setString(paramIndex++, "%" + search + "%"); statement.setString(paramIndex++, likeSearch);
statement.setString(paramIndex++, likeSearch);
} }
try (ResultSet set = statement.executeQuery()) { try (ResultSet set = statement.executeQuery()) {
@@ -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 ?, ?")) { 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.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(3, page * 14);
statement.setInt(4, 14); statement.setInt(4, 14);
@@ -53,7 +53,7 @@ public class Messenger {
public static THashSet<MessengerBuddy> searchUsers(String username) { public static THashSet<MessengerBuddy> searchUsers(String username) {
THashSet<MessengerBuddy> users = new THashSet<>(); THashSet<MessengerBuddy> 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"))) { 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()) { try (ResultSet set = statement.executeQuery()) {
while (set.next()) { while (set.next()) {
users.add(new MessengerBuddy(set, false)); users.add(new MessengerBuddy(set, false));
@@ -40,23 +40,26 @@ public class RecycleEvent extends MessageHandler {
} }
} }
if (items.size() == count) { 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 {
this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR)); this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR));
return; 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() + ""); HabboItem reward = Emulator.getGameEnvironment().getItemManager().handleRecycle(this.client.getHabbo(), Emulator.getGameEnvironment().getCatalogManager().getRandomRecyclerPrize().getId() + "");
if (reward == null) { if (reward == null) {
this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR)); this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR));
return; 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.sendResponse(new AddHabboItemComposer(reward));
this.client.getHabbo().getInventory().getItemsComponent().addItem(reward); this.client.getHabbo().getInventory().getItemsComponent().addItem(reward);
this.client.sendResponse(new RecyclerCompleteComposer(RecyclerCompleteComposer.RECYCLING_COMPLETE)); this.client.sendResponse(new RecyclerCompleteComposer(RecyclerCompleteComposer.RECYCLING_COMPLETE));
@@ -37,6 +37,8 @@ public class CraftingCraftItemEvent extends MessageHandler {
HabboItem habboItem = this.client.getHabbo().getInventory().getItemsComponent().getAndRemoveHabboItem(set.getKey()); HabboItem habboItem = this.client.getHabbo().getInventory().getItemsComponent().getAndRemoveHabboItem(set.getKey());
if (habboItem == null) { if (habboItem == null) {
// Not enough ingredients — give back whatever we already pulled.
this.restoreItems(toRemove);
return; return;
} }
@@ -70,8 +72,23 @@ public class CraftingCraftItemEvent extends MessageHandler {
return; 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)); this.client.sendResponse(new CraftingResultComposer(null));
} }
private void restoreItems(TIntObjectHashMap<HabboItem> 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());
}
} }
@@ -49,15 +49,17 @@ public class FurniEditorSearchEvent extends MessageHandler {
try { try {
int numericQuery = Integer.parseInt(query); int numericQuery = Integer.parseInt(query);
isNumeric = true; 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 ?)"); whereClause.append(" AND (id = ? OR sprite_id = ? OR item_name LIKE ? OR public_name LIKE ?)");
params.add(numericQuery); params.add(numericQuery);
params.add(numericQuery); params.add(numericQuery);
params.add("%" + query + "%"); params.add(likeQuery);
params.add("%" + query + "%"); params.add(likeQuery);
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
String likeQuery = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%";
whereClause.append(" AND (item_name LIKE ? OR public_name LIKE ?)"); whereClause.append(" AND (item_name LIKE ? OR public_name LIKE ?)");
params.add("%" + query + "%"); params.add(likeQuery);
params.add("%" + query + "%"); params.add(likeQuery);
} }
} }
@@ -56,7 +56,7 @@ public class HousekeepingSearchRoomsEvent extends MessageHandler {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) { 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); statement.setInt(2, limit);
try (ResultSet set = statement.executeQuery()) { try (ResultSet set = statement.executeQuery()) {
@@ -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("_", "\\_");
}
}