From 8672c2d0ea43ad0e816e5bd117424293dcf64b73 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 16:08:20 +0200 Subject: [PATCH] fix(catalog): validate admin offer payloads --- .../CatalogAdminCreateOfferEvent.java | 48 ++++--- .../CatalogAdminOfferPayload.java | 125 ++++++++++++++++++ .../CatalogAdminSaveOfferEvent.java | 57 +++++--- ...CatalogAdminOfferMutationContractTest.java | 43 ++++++ .../CatalogAdminOfferPayloadTest.java | 41 ++++++ 5 files changed, 278 insertions(+), 36 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayload.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferMutationContractTest.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayloadTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java index 380a3e07..59da4f9b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java @@ -35,33 +35,45 @@ public class CatalogAdminCreateOfferEvent extends MessageHandler { int orderNumber = this.packet.readInt(); CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString()); + CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate(pageId, itemIds, catalogName, costCredits, + costPoints, pointsType, amount, clubOnly, extradata, haveOffer, offerIdGroup, limitedStack, + orderNumber, pageType); + if (payload == null) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer payload")); + return; + } + + if (Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(payload.pageId, payload.pageType) == null) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + payload.pageId)); + return; + } + int newId = -1; try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( - (pageType == CatalogPageType.BUILDER) + (payload.pageType == CatalogPageType.BUILDER) ? "INSERT INTO catalog_items_bc (page_id, item_ids, catalog_name, order_number, extradata) VALUES (?, ?, ?, ?, ?)" : "INSERT INTO catalog_items (page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, club_only, extradata, have_offer, offer_id, limited_stack, order_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", Statement.RETURN_GENERATED_KEYS)) { - String cleanItemIds = (itemIds == null || itemIds.trim().isEmpty()) ? "0" : itemIds.trim(); - statement.setInt(1, pageId); - statement.setString(2, cleanItemIds); - statement.setString(3, catalogName); + statement.setInt(1, payload.pageId); + statement.setString(2, payload.itemIds); + statement.setString(3, payload.catalogName); - if (pageType == CatalogPageType.BUILDER) { - statement.setInt(4, orderNumber); - statement.setString(5, extradata); + if (payload.pageType == CatalogPageType.BUILDER) { + statement.setInt(4, payload.orderNumber); + statement.setString(5, payload.extradata); } else { - statement.setInt(4, costCredits); - statement.setInt(5, costPoints); - statement.setInt(6, pointsType); - statement.setInt(7, amount); - statement.setString(8, clubOnly == 1 ? "1" : "0"); - statement.setString(9, extradata); - statement.setString(10, haveOffer ? "1" : "0"); - statement.setInt(11, offerIdGroup); - statement.setInt(12, limitedStack); - statement.setInt(13, orderNumber); + statement.setInt(4, payload.costCredits); + statement.setInt(5, payload.costPoints); + statement.setInt(6, payload.pointsType); + statement.setInt(7, payload.amount); + statement.setString(8, payload.clubOnly == 1 ? "1" : "0"); + statement.setString(9, payload.extradata); + statement.setString(10, payload.haveOffer ? "1" : "0"); + statement.setInt(11, payload.offerIdGroup); + statement.setInt(12, payload.limitedStack); + statement.setInt(13, payload.orderNumber); } statement.execute(); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayload.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayload.java new file mode 100644 index 00000000..43073036 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayload.java @@ -0,0 +1,125 @@ +package com.eu.habbo.messages.incoming.catalog.catalogadmin; + +import com.eu.habbo.habbohotel.catalog.CatalogPageType; + +final class CatalogAdminOfferPayload { + private static final int MAX_ITEM_IDS_LENGTH = 512; + private static final int MAX_ITEM_IDS = 100; + private static final int MAX_CATALOG_NAME_LENGTH = 128; + private static final int MAX_EXTRADATA_LENGTH = 1024; + private static final int MAX_CURRENCY_VALUE = 1_000_000_000; + private static final int MAX_AMOUNT = 10_000; + private static final int MAX_POINTS_TYPE = 10_000; + private static final int MAX_ORDER_NUMBER = 1_000_000; + private static final int MAX_LIMITED_STACK = 1_000_000; + + final int pageId; + final String itemIds; + final String catalogName; + final int costCredits; + final int costPoints; + final int pointsType; + final int amount; + final int clubOnly; + final String extradata; + final boolean haveOffer; + final int offerIdGroup; + final int limitedStack; + final int orderNumber; + final CatalogPageType pageType; + + private CatalogAdminOfferPayload(int pageId, String itemIds, String catalogName, int costCredits, int costPoints, + int pointsType, int amount, int clubOnly, String extradata, boolean haveOffer, + int offerIdGroup, int limitedStack, int orderNumber, CatalogPageType pageType) { + this.pageId = pageId; + this.itemIds = itemIds; + this.catalogName = catalogName; + this.costCredits = costCredits; + this.costPoints = costPoints; + this.pointsType = pointsType; + this.amount = amount; + this.clubOnly = clubOnly; + this.extradata = extradata; + this.haveOffer = haveOffer; + this.offerIdGroup = offerIdGroup; + this.limitedStack = limitedStack; + this.orderNumber = orderNumber; + this.pageType = pageType; + } + + static CatalogAdminOfferPayload validate(int pageId, String itemIds, String catalogName, int costCredits, + int costPoints, int pointsType, int amount, int clubOnly, + String extradata, boolean haveOffer, int offerIdGroup, + int limitedStack, int orderNumber, CatalogPageType pageType) { + String cleanItemIds = normalizeItemIds(itemIds); + String cleanCatalogName = clamp(catalogName, MAX_CATALOG_NAME_LENGTH); + String cleanExtradata = clamp(extradata, MAX_EXTRADATA_LENGTH); + + if (pageId <= 0 + || cleanItemIds == null + || cleanCatalogName.isBlank() + || !isInRange(orderNumber, 0, MAX_ORDER_NUMBER)) { + return null; + } + + if (pageType != CatalogPageType.BUILDER) { + if (!isInRange(costCredits, 0, MAX_CURRENCY_VALUE) + || !isInRange(costPoints, 0, MAX_CURRENCY_VALUE) + || !isInRange(pointsType, 0, MAX_POINTS_TYPE) + || !isInRange(amount, 1, MAX_AMOUNT) + || !isInRange(clubOnly, 0, 1) + || offerIdGroup < 0 + || !isInRange(limitedStack, 0, MAX_LIMITED_STACK)) { + return null; + } + } + + return new CatalogAdminOfferPayload(pageId, cleanItemIds, cleanCatalogName, costCredits, costPoints, + pointsType, amount, clubOnly, cleanExtradata, haveOffer, offerIdGroup, limitedStack, orderNumber, + pageType); + } + + private static String normalizeItemIds(String value) { + if (value == null || value.trim().isEmpty()) { + return "0"; + } + + String clean = value.trim(); + if (clean.length() > MAX_ITEM_IDS_LENGTH) { + return null; + } + + String[] parts = clean.split(","); + if (parts.length == 0 || parts.length > MAX_ITEM_IDS) { + return null; + } + + for (String part : parts) { + if (part.isBlank()) { + return null; + } + + try { + if (Integer.parseInt(part.trim()) < 0) { + return null; + } + } catch (NumberFormatException e) { + return null; + } + } + + return clean.replaceAll("\\s+", ""); + } + + private static boolean isInRange(int value, int min, int max) { + return value >= min && value <= max; + } + + private static String clamp(String value, int maxLength) { + if (value == null) { + return ""; + } + + return value.length() <= maxLength ? value : value.substring(0, maxLength); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java index 1a5aff1a..816d31c5 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java @@ -34,10 +34,28 @@ public class CatalogAdminSaveOfferEvent extends MessageHandler { int orderNumber = this.packet.readInt(); CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString()); + if (offerId <= 0) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer id")); + return; + } + + CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate(pageId, itemIds, catalogName, costCredits, + costPoints, pointsType, amount, clubOnly, extradata, haveOffer, offerIdGroup, limitedStack, + orderNumber, pageType); + if (payload == null) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer payload")); + return; + } + + if (Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(payload.pageId, payload.pageType) == null) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + payload.pageId)); + return; + } + boolean updateItemIds = itemIds != null && !itemIds.trim().isEmpty(); String sql; - if (pageType == CatalogPageType.BUILDER) { + if (payload.pageType == CatalogPageType.BUILDER) { sql = updateItemIds ? "UPDATE catalog_items_bc SET page_id = ?, item_ids = ?, catalog_name = ?, order_number = ?, extradata = ? WHERE id = ?" : "UPDATE catalog_items_bc SET page_id = ?, catalog_name = ?, order_number = ?, extradata = ? WHERE id = ?"; @@ -50,30 +68,33 @@ public class CatalogAdminSaveOfferEvent extends MessageHandler { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { int idx = 1; - statement.setInt(idx++, pageId); + statement.setInt(idx++, payload.pageId); if (updateItemIds) { - statement.setString(idx++, itemIds.trim()); + statement.setString(idx++, payload.itemIds); } - statement.setString(idx++, catalogName); + statement.setString(idx++, payload.catalogName); - if (pageType == CatalogPageType.BUILDER) { - statement.setInt(idx++, orderNumber); - statement.setString(idx++, extradata); + if (payload.pageType == CatalogPageType.BUILDER) { + statement.setInt(idx++, payload.orderNumber); + statement.setString(idx++, payload.extradata); statement.setInt(idx, offerId); } else { - statement.setInt(idx++, costCredits); - statement.setInt(idx++, costPoints); - statement.setInt(idx++, pointsType); - statement.setInt(idx++, amount); - statement.setString(idx++, clubOnly == 1 ? "1" : "0"); - statement.setString(idx++, extradata); - statement.setString(idx++, haveOffer ? "1" : "0"); - statement.setInt(idx++, offerIdGroup); - statement.setInt(idx++, limitedStack); - statement.setInt(idx++, orderNumber); + statement.setInt(idx++, payload.costCredits); + statement.setInt(idx++, payload.costPoints); + statement.setInt(idx++, payload.pointsType); + statement.setInt(idx++, payload.amount); + statement.setString(idx++, payload.clubOnly == 1 ? "1" : "0"); + statement.setString(idx++, payload.extradata); + statement.setString(idx++, payload.haveOffer ? "1" : "0"); + statement.setInt(idx++, payload.offerIdGroup); + statement.setInt(idx++, payload.limitedStack); + statement.setInt(idx++, payload.orderNumber); statement.setInt(idx, offerId); } - statement.execute(); + if (statement.executeUpdate() == 0) { + this.client.sendResponse(new CatalogAdminResultComposer(false, "Offer not found: " + offerId)); + return; + } } this.client.sendResponse(new CatalogAdminResultComposer(true, "Offer saved")); diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferMutationContractTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferMutationContractTest.java new file mode 100644 index 00000000..901922ca --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferMutationContractTest.java @@ -0,0 +1,43 @@ +package com.eu.habbo.messages.incoming.catalog.catalogadmin; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class CatalogAdminOfferMutationContractTest { + private static final Path CREATE_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java"); + private static final Path SAVE_SOURCE = Path.of( + "src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java"); + + @Test + void createAndSaveValidatePayloadAndTargetPageBeforeWriting() throws IOException { + String create = Files.readString(CREATE_SOURCE); + String save = Files.readString(SAVE_SOURCE); + + assertTrue(create.contains("CatalogAdminOfferPayload.validate(")); + assertTrue(save.contains("CatalogAdminOfferPayload.validate(")); + assertTrue(create.contains("getCatalogPage(payload.pageId, payload.pageType) == null")); + assertTrue(save.contains("getCatalogPage(payload.pageId, payload.pageType) == null")); + + int createValidation = create.indexOf("CatalogAdminOfferPayload.validate("); + int createInsert = create.indexOf("INSERT INTO catalog_items"); + int saveValidation = save.indexOf("CatalogAdminOfferPayload.validate("); + int saveUpdate = save.indexOf("UPDATE catalog_items"); + + assertTrue(createValidation < createInsert, "create offer should validate before insert SQL is prepared"); + assertTrue(saveValidation < saveUpdate, "save offer should validate before update SQL is prepared"); + } + + @Test + void saveOfferReportsMissingRowsInsteadOfAlwaysSucceeding() throws IOException { + String save = Files.readString(SAVE_SOURCE); + + assertTrue(save.contains("statement.executeUpdate() == 0")); + assertTrue(save.contains("Offer not found: ")); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayloadTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayloadTest.java new file mode 100644 index 00000000..8b2d71b4 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminOfferPayloadTest.java @@ -0,0 +1,41 @@ +package com.eu.habbo.messages.incoming.catalog.catalogadmin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.eu.habbo.habbohotel.catalog.CatalogPageType; +import org.junit.jupiter.api.Test; + +class CatalogAdminOfferPayloadTest { + @Test + void acceptsAndNormalizesValidOfferPayload() { + CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate( + 42, "1, 2,3", "Rare Chair", 100, 5, 0, 1, 0, + "extra", true, 0, 0, 10, CatalogPageType.NORMAL); + + assertNotNull(payload); + assertEquals("1,2,3", payload.itemIds); + assertEquals("Rare Chair", payload.catalogName); + } + + @Test + void rejectsInvalidItemIdsAndNegativeEconomyValues() { + assertNull(CatalogAdminOfferPayload.validate(42, "1,abc", "Name", 0, 0, 0, 1, 0, + "", false, 0, 0, 0, CatalogPageType.NORMAL)); + assertNull(CatalogAdminOfferPayload.validate(42, "1", "Name", -1, 0, 0, 1, 0, + "", false, 0, 0, 0, CatalogPageType.NORMAL)); + assertNull(CatalogAdminOfferPayload.validate(42, "1", "Name", 0, 0, 0, 0, 0, + "", false, 0, 0, 0, CatalogPageType.NORMAL)); + } + + @Test + void builderOffersStillRequireSafeCommonFields() { + assertNotNull(CatalogAdminOfferPayload.validate(42, "", "BC Offer", -1, -1, -1, -1, -1, + "", false, -1, -1, 0, CatalogPageType.BUILDER)); + assertNull(CatalogAdminOfferPayload.validate(0, "1", "BC Offer", 0, 0, 0, 1, 0, + "", false, 0, 0, 0, CatalogPageType.BUILDER)); + assertNull(CatalogAdminOfferPayload.validate(42, "1", "", 0, 0, 0, 1, 0, + "", false, 0, 0, 0, CatalogPageType.BUILDER)); + } +}