Security
Protects shared containers against item duplication — rate limiting, integrity validation, concurrent locking, and admin alerts.
Introduction
The Security module provides four independent utility classes to protect
server-side GUIs against item duplication exploits. These classes are generic: they work
with any container (ItemStack[]), any player (EntityPlayerMP),
and integrate without configuration.
The four classes cover the four main attack vectors:
| Class | Protection |
|---|---|
GuiRateLimiter |
Limits the number of clicks per second to prevent macros and spam |
ItemIntegrityValidator |
Verifies that the total item count cannot increase after an operation |
ContainerLock |
Serialized concurrent access per key — prevents race conditions between threads |
DupeAlertManager |
Alerts online operators and logs any detected duplication attempt |
All classes in this module are located in the fr.eri.eriapi.security package.
GuiRateLimiter
Limits the number of actions per second per player. Uses a sliding one-second window:
if a player triggers more than maxPerSecond actions within the same second,
check() returns false until the window resets.
Thread-safe: backed by a ConcurrentHashMap internally.
Constructor
| Parameter | Type | Description |
|---|---|---|
maxPerSecond |
int |
Maximum number of actions allowed per player per second |
Methods
| Method | Return | Description |
|---|---|---|
check(UUID playerId) |
boolean |
true if the action is allowed, false if rate-limited |
clear(UUID playerId) |
void |
Removes the tracker for the player — call on disconnect |
clearAll() |
void |
Removes all trackers — call on world unload |
getMaxPerSecond() |
int |
Returns the configured threshold |
Usage example
// Create a limiter of 20 clicks per second (class field)
private final GuiRateLimiter chestRateLimiter = new GuiRateLimiter(20);
// On each player action:
UUID uid = player.getGameProfile().getId();
if (!chestRateLimiter.check(uid)) {
// Too many clicks — reject silently
LOGGER.warn("[AntiDupe] Rate-limited {}", player.getName());
return;
}
// ... process the action
// On player disconnect:
chestRateLimiter.clear(uid);
Create a different GuiRateLimiter for each action type if the thresholds
should differ. For example: 20/s for chest clicks, 10/s for contract collections.
ItemIntegrityValidator
Validates that the total item count cannot increase during a container ↔ player inventory operation. If the total after exceeds the total before, a duplication has been detected and the operation must be rolled back.
All methods are static — no instance required.
Snapshot methods
| Method | Description |
|---|---|
snapshot(ItemStack[] items) |
Deep copy of an ItemStack array. Empty stacks are stored as ItemStack.EMPTY. |
snapshotPlayerInventory(EntityPlayerMP player) |
Deep copy of the player's 36 main inventory slots. |
Count methods
| Method | Description |
|---|---|
countItems(ItemStack[] items) |
Sum of getCount() for all non-empty stacks in the array. |
countPlayerInventory(EntityPlayerMP player) |
Counts items currently in the player's 36 main slots (live state). |
Validation methods
| Signature | Description |
|---|---|
validate(ItemStack[] containerBefore, ItemStack[] containerAfter, |
Returns true if the total has not increased. Numeric overload for both inventory counts. |
validate(ItemStack[] containerBefore, ItemStack[] containerAfter, |
Convenience overload: passes the BEFORE snapshot and the live player for the AFTER count. |
Rollback method
| Method | Description |
|---|---|
rollback(ItemStack[] container, ItemStack[] containerSnapshot, |
Restores the container and player inventory from their snapshots.
Also calls player.inventoryContainer.detectAndSendChanges().
Important: if the container is stored externally (NBT, database), persist it after rollback. |
Snapshot / validate / rollback workflow
// 1. Snapshot BEFORE the operation
ItemStack[] containerSnap = ItemIntegrityValidator.snapshot(container);
ItemStack cursorSnap = cursor.isEmpty() ? ItemStack.EMPTY : cursor.copy();
ItemStack[] invSnap = ItemIntegrityValidator.snapshotPlayerInventory(player);
// 2. Perform the operation (modify container, cursor, player inventory)
doOperation(container, cursor, player);
// 3. Validate — reject if items were created
if (!ItemIntegrityValidator.validate(containerSnap, container,
cursorSnap, cursor, invSnap, player)) {
// Duplication detected — restore state
ItemIntegrityValidator.rollback(container, containerSnap, player, invSnap);
// Re-persist the container if necessary
persistContainer(containerSnap);
return;
}
// 4. Persist — no duplication
persistContainer(container);
validate() returns true if the total after is less than or equal
to the total before — meaning item loss is accepted (they may have been dropped).
Only an increase in items is detected as a dupe.
ContainerLock
Provides a distinct Object monitor per logical key (e.g. a faction ID, a
container UUID). Using these locks inside synchronized blocks prevents two
threads from processing the same shared resource simultaneously.
The advantage over a single global lock is granularity: two different factions can be processed in parallel, but two players from the same faction cannot modify the same chest at the same time.
Thread-safe: backed by a ConcurrentHashMap internally.
Methods
| Method | Description |
|---|---|
getLock(String key) |
Returns the lock for the given key, creating it if it does not yet exist. |
removeLock(String key) |
Removes the lock for the key — call when the resource is destroyed (e.g. faction disbanded). |
clearAll() |
Removes all locks. |
size() |
Returns the current number of tracked keys. |
Usage example
// Class field
private final ContainerLock containerLock = new ContainerLock();
// Protect a faction chest operation
synchronized (containerLock.getLock(factionInfo.id)) {
handleChestOperation(player, fm, factionInfo);
sendChestFullState(player, factionInfo);
}
// When a faction is disbanded:
containerLock.removeLock(factionInfo.id);
ContainerLock uses standard Java synchronized blocks —
there is no built-in timeout. If the code inside the synchronized block can block
indefinitely (e.g. network access), consider a ReentrantLock with
tryLock() instead.
DupeAlertManager
Sends real-time alerts to all online operators (permission level ≥ 2) when a duplication
attempt is detected. Also logs the event to the server console under the
EriAPI-Security logger.
All methods are static — no instance required.
Method alert()
| Parameter | Type | Description |
|---|---|---|
exploiter |
EntityPlayerMP |
The player who triggered the duplication detection |
itemDelta |
int |
Number of items that would have been created (positive integer) |
context |
String |
Short description of the context displayed in the alert (e.g. "faction chest") |
Admin message format
[AntiDupe] PlayerName attempted to duplicate +3 items (faction chest)
Usage example
// After detecting a duplication:
int delta = totalAfter - totalBefore;
LOGGER.warn("[AntiDupe] Duplication detected for {} : +{} items", player.getName(), delta);
DupeAlertManager.alert(player, delta, "faction chest");
// Perform the rollback...
Full Example — Protecting a Custom GUI Container
Here is how to use all four classes together to protect an arbitrary container (e.g. guild chest, marketplace, faction storage).
public class MyGuiHandler {
// ── EriAPI Security instances ─────────────────────────────────────────
// Rate limiter: max 20 clicks per second
private final GuiRateLimiter rateLimiter = new GuiRateLimiter(20);
// Per-container locks (key = guild ID for example)
private final ContainerLock containerLock = new ContainerLock();
public void handleSlotClick(EntityPlayerMP player, String containerId,
ItemStack[] container, ItemStack cursor,
int slotIndex, int button) {
UUID uid = player.getGameProfile().getId();
// 1. Rate limit — reject if too many clicks
if (!rateLimiter.check(uid)) {
LOGGER.warn("[AntiDupe] Rate-limited {}", player.getName());
sendFullState(player, container);
return;
}
// 2. Serialized concurrent access per container
synchronized (containerLock.getLock(containerId)) {
// 3. Snapshot BEFORE the operation
ItemStack[] containerSnap = ItemIntegrityValidator.snapshot(container);
ItemStack cursorSnap = cursor.isEmpty() ? ItemStack.EMPTY : cursor.copy();
ItemStack[] invSnap = ItemIntegrityValidator.snapshotPlayerInventory(player);
// 4. Perform the business operation
doSlotClick(container, cursor, player, slotIndex, button);
// 5. Validate integrity
if (!ItemIntegrityValidator.validate(containerSnap, container,
cursorSnap, cursor, invSnap, player)) {
// Duplication detected
int totalBefore = ItemIntegrityValidator.countItems(containerSnap)
+ (cursorSnap.isEmpty() ? 0 : cursorSnap.getCount())
+ ItemIntegrityValidator.countItems(invSnap);
int totalAfter = ItemIntegrityValidator.countItems(container)
+ (cursor.isEmpty() ? 0 : cursor.getCount())
+ ItemIntegrityValidator.countPlayerInventory(player);
int delta = totalAfter - totalBefore;
LOGGER.warn("[AntiDupe] DUPE DETECTED! Player: {} delta: +{}",
player.getName(), delta);
// 6. Alert admins
DupeAlertManager.alert(player, delta, "container-" + containerId);
// 7. Rollback
ItemIntegrityValidator.rollback(container, containerSnap, player, invSnap);
persistContainer(containerId, containerSnap); // re-persist restored state
return;
}
// 8. No duplication — persist
persistContainer(containerId, container);
}
// Send new state to client
sendFullState(player, container);
}
// Call on PlayerLoggedOutEvent
public void onPlayerDisconnect(EntityPlayerMP player) {
rateLimiter.clear(player.getGameProfile().getId());
}
// Call when the container is permanently deleted
public void onContainerDeleted(String containerId) {
containerLock.removeLock(containerId);
}
}
Order matters: rate limit first (fast rejection without locking), then concurrent lock, then snapshot, then operation, then validation. Never snapshot after the operation — the snapshot must capture the state before it.
ItemIntegrityValidator.rollback() restores the in-memory arrays and
synchronizes the player inventory, but does not persist the container
to the database or NBT. If the container is stored externally, re-persist it manually
after the rollback (using the original snapshot).