fix(catalog): validate admin offer payloads

This commit is contained in:
simoleo89
2026-06-14 16:08:20 +02:00
parent c9214bac07
commit 8672c2d0ea
5 changed files with 278 additions and 36 deletions
@@ -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();
@@ -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);
}
}
@@ -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"));
@@ -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: "));
}
}
@@ -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));
}
}