Audit fixes: dupe/economy exploits, race conditions, DB bugs, cleanups#217
Conversation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The economy fee was withdrawn before the parcel was saved and never refunded if the save failed, charging the player for a parcel that was never created. Track the charged amount and deposit it back in the failure path. Also consolidate the rollback so the fee is refunded and the failure notice is sent exactly once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
When itemStorageManager.delete failed after the parcel and its content had been persisted (and the fee charged), the dispatch path deleted only the parcel row - leaving the ParcelContent orphaned and the fee lost. Add ParcelService#rollbackSend which deletes the parcel and its content and refunds the fee, and call it from ParcelDispatchService. Extract a shared feeFor(size) helper so the charge and refund cannot diverge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
onBlockPlace cancels the placement (so vanilla never consumes the item) and re-places the block manually, but never removed the locker item from the player's inventory - letting one item create unlimited lockers. Consume one matching item from the main/off hand on the main thread when the locker is actually created. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
onBlockBreak never cancelled the BlockBreakEvent, so a non-admin break physically destroyed the block (spawning the chest and its drops) before the block was restored on the next tick - a duplication exploit. Add LockerManager#getCached for a synchronous, cache-only lookup and use it to cancel the break within the same tick when the locker is cached. Admin breaks now clear the block without dropping it. The async fallback (uncached locker) still restores the block as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
freeSlotsInInventory was evaluated on the async DB thread and the parcel was deleted before items were handed back on the main thread, so a full or changing inventory could lose items permanently. Re-check inventory space on the main thread, delete the parcel/content first (so it cannot be collected twice), and only then give the items - guarded by the delete result so a failed delete never destroys items. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
Two concurrent sends to the same near-full locker could both pass isLockerFull and both store, exceeding maxParcelsPerLocker. Count all parcels occupying a destination locker (collected parcels are already deleted, so in-transit + delivered = the real occupancy), which reserves a slot at send time, and serialize dispatches per destination locker via a per-locker future chain so the check and the save cannot interleave. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
LockerManager.get(Page) returned an arbitrary, partially-evicted subset of the cache as page 0, hiding any locker that was not currently cached and ordering results non-deterministically. Always query the repository and warm the cache with the results. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The locker lookup was async and event.setCancelled ran a tick later, so the vanilla chest had already opened underneath the locker GUI. Use the synchronous locker cache to cancel the interaction in the same tick; on a cache miss, close the just-opened inventory before showing the GUI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
UserManagerImpl.get(UUID)/get(String) returned straight from the repository without caching, so every lookup of an uncached user hit the database and the cache only ever filled via create/changeName. Cache the fetched user (by UUID and name) like the other managers do. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
getOrCreate and create only checked the in-memory cache for conflicts, so a user present in the database but evicted/absent from the cache passed validation and a duplicate create was attempted. getOrCreate now fetches from the repository before creating, and create validates UUID/name conflicts against repository lookups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
ParcelSendTask updated the parcel to DELIVERED and deleted the delivery as two independent fire-and-forget calls. If the update failed but the delete succeeded, the parcel was stranded in SENT with no delivery row, so it was never re-scheduled on restart and never delivered. Chain the delete after a successful update. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
ItemStorageManager.getOrCreate called create() as the cache.get loader, and create() writes the same key back via cache.put - which Caffeine forbids for the key being computed and can throw or corrupt the entry. Check the cache first and call create() outside the loader instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
On GUI close the staged items were read, the storage row deleted, and the new storage saved fire-and-forget - if the save failed after the delete, the items were silently lost. Make create/getOrCreate and GuiManager.saveItemStorage return the save future, chain delete -> save in ItemStorageGui, and give the items back to the player if saving fails. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
ItemStorageRepositoryOrmLite swallowed a failed createTableIfNotExists with printStackTrace, unlike every other repository, so a missing table only surfaced later as confusing query errors. Throw DatabaseException for consistency and fail-fast startup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The wrapper method named save() performed createOrUpdate (an upsert) while saveIfNotExist() performed createIfNotExists, an inversion that made "save" unable to persist updates. Rename to upsert() and insertIfAbsent() with doc comments, and update all callers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
getDao used a non-atomic get-then-put on the ConcurrentHashMap, so two threads could create and store the DAO twice. Use computeIfAbsent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The Hikari pool was hard-capped at 5 connections with a leak-detection threshold equal to the connection timeout (5s), which logs false leak warnings for any slow query. Expose connectionPoolSize, connectionTimeoutMillis and leakDetectionThresholdMillis in the config and default the leak threshold to a safer 30s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
Whether a callEvent was legal depended on the incidental calling thread: UserChangeNameEvent (a sync event) was fired from the async user futures, and LockerDeleteEvent was fired from either the main thread (cache hit) or an async DB thread (cache miss) - both could throw IllegalStateException. Mark UserChangeNameEvent asynchronous to match its firing context (consistent with UserCreateEvent), and fire LockerDeleteEvent on the main thread deterministically via the scheduler so it stays a valid sync event. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
databaseType is already a DatabaseType, so DatabaseType.valueOf( databaseType.toString().toUpperCase()) was a redundant round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
onDisable closed the datasource first, so in-flight async DB tasks failed against a closed pool. Unregister listeners, cancel plugin tasks, and shut down commands/Discord before disconnecting the database. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The "Replace with actual config message" comment was outdated - the locker description prompt already comes from MessageConfig. (The remaining hardcoded English dialog button/input labels are left for a dedicated i18n pass.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The collect space check compared item count to fully-empty slots only, so it could refuse a parcel whose items would stack into partially-filled slots. Add InventoryUtil.canHold which simulates placement (topping up matching stacks, then spilling into empty slots) and use it in collect(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The two SendingGui.createActiveItem overloads differed only in add vs addAll. Have the single-line variant delegate to the list variant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
Every *OrmLite constructor duplicated the same createTableIfNotExists try/catch. Add AbstractRepositoryOrmLite#createTable and call it from each repository. This also fixes the Delivery and User repositories, which silently swallowed table-creation failures with printStackTrace (the same bug fixed for ItemStorage in M5) - they now fail fast like the others. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
Parcel, Locker and User repositories each reimplemented the limit+1 / offset / hasNext / removeLast pagination dance. Add AbstractRepositoryOrmLite#queryPage and route all three through it. This also fixes UserRepository.fetchPage, which fetched only `limit` rows yet checked size > limit, so it never reported a next page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
The six cancellable plugin events each repeated the cancelled field plus isCancelled/setCancelled. Introduce shared/event/CancellableEvent holding that boilerplate (subclasses still own their HandlerList as Bukkit requires) and extend it from each event. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
There was a problem hiding this comment.
Code Review
This pull request implements comprehensive fixes for critical and high-severity issues identified in a code audit of the ParcelLockers service. Key changes include resolving TOCTOU races in locker fullness checks, preventing item duplication exploits during locker placement and breaking, ensuring proper fee refunds on send failures, and refactoring database repositories to reduce duplication. The review feedback highlights several crucial edge cases where asynchronous database operations or failures could lead to cache-DB desyncs, item duplication, or permanent item loss. Specifically, the reviewer recommends invalidating or reverting caches if saves fail in ItemStorageManager and UserManagerImpl, handling exceptional failures during item storage deletion in ParcelDispatchService to guarantee rollbacks, ensuring items are still delivered even if content deletion fails during collection, and verifying item consumption in LockerPlaceController.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
- ItemStorageManager.create: revert the optimistic cache entry if the save fails, so the cache never holds an unpersisted storage (which, with items re-given to the player, would duplicate them). - LockerPlaceController: consume the locker item before creating and abort if it is gone; refund the item and clear the block if creation fails. consumeLockerItem now returns whether it consumed. - ParcelServiceImpl.collect: once the parcel is deleted, treat content deletion as best-effort and always give the items back (a failed content delete no longer loses them permanently). - UserManagerImpl.create: chain the save future and invalidate the cache on failure instead of fire-and-forget, avoiding a cache-DB desync. - ParcelDispatchService: map an exceptional itemStorage delete to false so it triggers rollbackSend instead of bypassing it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
|
@claude review |
|
Claude finished @Jakubk15's task in 6m 4s —— View job Code Review
SummaryOverall, this is a thorough and well-structured audit with correct fixes for the most dangerous issues. The Gemini follow-up (f859387) addressed all five flagged regressions properly. A few issues remain — one medium-severity cache-DB desync that was missed, and a couple of minor problems in What's SolidC1 — Fee refund on persistence failure ( C2 — rollbackSend ( C3 — Item consumption ( C4 / H4 — Synchronous cancel via cache ( H1 — Collection TOCTOU ( H2 — Locker fullness serialization ( H3 — Page from repository ( H5/H6 — User conflict checks ( AbstractRepositoryOrmLite.queryPage: The Gemini's 5 flagged items (f859387): Issues FoundMedium —
|
- LockerManager.create: revert both cache maps if the save fails, so the cache never holds a locker absent from the DB (the same desync pattern fixed earlier for User/ItemStorage, missed here). - UserManagerImpl.changeName: fall back to the repository on a cache miss (no longer fails for an evicted-but-existing user), and revert the cache if the persist fails. Run via thenComposeAsync so the async event never fires on the main thread. - ParcelDispatchService/ParcelServiceImpl: issue the "sent" notice only after the whole dispatch succeeds, avoiding a "sent" immediately followed by "cannot send" on a storage-clear rollback. - LockerBreakController: explosion/fire/ignite/damage handlers now cancel synchronously via the locker cache (removing exploded locker blocks from the blast), instead of only restoring after the fact - closing the same drop/dupe window as C4 in those handlers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyikQbSDrV12b3RTMJvd3S
|
Addressed the second review round in
|
Full audit of
src/and fixes for every finding, one commit each. SeeAUDIT.mdfor the detailed report and resolution status../gradlew compileJavaand./gradlew testpass.🔴 Critical
rollbackSenddeletes the parcel and its content and refunds the fee, instead of orphaning content + losing money.🟠 High
🟡 Medium
M1 (chain delivery delete after status update), M2 (deterministic event-firing threads — fixes two real
IllegalStateExceptionpaths), M3 (no reentrant cache loader), M4 (surface item-storage save failures, re-give items), M5 (fail fast on table create), M6 (renameupsert/insertIfAbsent), M7 (atomic DAO cache), M8 (configurable connection-pool tuning).🔵 Low
L1, L3, L4, L5. (L2 was folded into the H2 fix.)
Duplication
createTablehelper — also fixes the same swallowed table-creation failure in the Delivery and User repos.queryPagehelper — also fixes a latent always-false "has next page" inUserRepository.fetchPage.CancellableEventbase for shared cancel handling.createActiveItemoverloads.Intentionally deferred: D3 (generic cache-or-fetch helper) — the managers use their caches differently enough that one abstraction risks masking per-cache nuance, and the concrete inconsistency it could hide was already fixed by H6.
🤖 Generated with Claude Code