fix: address the findings from a full-repo adversarial review - #519
Merged
Conversation
applyRestore unloaded the world and recursively deleted its directory before extractBackup ever opened the archive, so any unreadable zip left nothing to restore and no rollback. Validation now runs first, and a failed delete aborts instead of extracting over a half-deleted world. The SFTP upload wrote straight to the final name with no failure handling. Because the filename timestamp is assigned before the write, a truncated archive sorted as the newest backup and the menu shows no size or integrity hint. It now writes to a temp name and renames on success. The scheduled auto-backup discarded createBackup()'s future entirely, so a failure on the default path produced no log line at all. Backups also stagger one world per tick rather than firing every due world's save in the same tick, and createBackup() no longer runs World::save on the caller's thread now that it is public API returning a future. S3Client.get deletes the partially written target when the transfer itself throws, not only when S3 returns a failure status.
YamlStore.save truncated and rewrote in place, so a crash mid-write left a truncated file that reload() logged and then proceeded past, silently losing world and player metadata. It now writes a temp file and moves it atomically, falling back to a plain replace where the filesystem refuses. AbstractYamlStorage called reload/save directly, bypassing the ioLock the cache-backed storages use, so spawn.yml, setup.yml, status.yml and categories.yml could be written by two threads at once. onDisable also cancelled the periodic save only after joining the shutdown save, which cannot stop a tick already in flight. Config migration rewrote config.yml with no backup, unlike the storage layer that deliberately backs up first. It now copies the file aside and aborts rather than migrating unprotected if that copy fails. The SFTP and S3 settings records redact their secrets in toString(), so a future log of the record cannot leak plaintext credentials.
permission() can only express a single permission node, so every check that depends on the resource being acted on -- whether the player created this world, whether they are under their world limit -- became a hand-rolled early return inside onClick. That is how authorization came to be decided by comparing a clicked item's Material to what was drawn at populate time. Buttons can now declare usableBy(Predicate<Player>) alongside the node. canClick(Player) is the single place the two combine and the only thing handleClick consults, so declaring either is enough to have it enforced. The node stays introspectable, so the golden permissionBySlot tests are unaffected. register() also throws on a slot collision within one population pass. EditMenu draws its 54 slots from two independent catalogs and a collision silently deleted whichever button registered second.
WorldServiceImpl.deleteWorld(Player, ...) checked existence and path safety but never creator, admin or any permission, so the menu was the only enforcement point for an irreversible action. It now applies the same gate the command path uses; the no-Player overload stays unchecked for internal and API callers, and says why. The GUI add-builder flow called AddBuilderSubCommand.getAddBuilderInput directly, skipping execute() and with it the only canPerformCommand call in that path. Both entry points now share one gate. newWorld validated names against reserved and path-escaping forms while importWorld, four lines below and with the same shape, validated nothing -- despite a comment that reads as class-wide policy. Both now call one helper. This makes importWorld reject names it previously accepted. The registries persisted unconditionally, so an editor left open on an entry another admin deleted wrote it back to disk on save. startWorldNameInput's two adjacent booleans are now a record; the compiler would previously have accepted them transposed.
EditMenu's builders and visibility handlers, and BuilderMenu's add button, inferred whether the player was allowed by comparing the clicked item's Material to what was drawn at populate time. Remove-builder had no check at all. Status assignment gated only on flat nodes, so a moderator with edit.other, edit.status and setstatus.finished could set status on anyone's world while the equivalent command correctly refused them. These now declare permission() and usableBy() and let the framework enforce them. BuilderMenu and CreateMenu override onPermissionDenied to keep their existing outcomes -- return to the editor, and play the deny sound without ejecting the player from the creation flow. EditMenu also drops from 728 to 551 lines: the async head resolution moved into MenuItems, which already owned that concern and no longer needed a TaskScheduler and Logger threaded through EditMenu's constructor, and the render helpers that carry no authorization moved to EditMenuRenderer. The builders and visibility renders stayed put so they cannot drift from the predicates that gate them.
buildsystem.create.<categoryId> and buildsystem.navigator.<categoryId> collided with the reserved create.folder, create.private, create.type.*, create.template.*, create.generator.* and navigator.item nodes whenever a category id matched one of those words, and with the create.<visibility>.<amount> world-limit nodes. They are now create.category.<id> and navigator.category.<id>, so a collision is structurally impossible. This is not a migration concern: 4.0.0 is unreleased, so neither form has ever shipped. Bypassable was attached to three properties and read by nothing -- getCapability is only ever called for Overridable -- while the real enforcement re-typed the same permission literals elsewhere. Removed, along with the buildsystem.bypass.permission node it carried, which granted nothing. WorldSetting keeps its bypass literal in one place. Permissions' class javadoc no longer claims the full node set is readable in one place. That was false for the runtime factory methods, and is why the collision above went unnoticed.
… thread UpdateChecker used HttpURLConnection with no connect or read timeout and closed its reader only on the happy path, and was the only caller still on that API. It now uses HttpClient with an explicit timeout, matching PlayerLookupService and S3Client. Its continuation ran on whichever ForkJoinPool thread completed the future and then called player.sendMessage -- on every player join, since the checker is enabled by default. It now hops to the main thread and guards the exceptional path, which previously dereferenced the result without checking the throwable. The message is built from Placeholders rather than chained replace calls. buildModePlayers was a plain HashSet mutated on the main thread but read by BuildModeCalculator, which LuckPerms invokes from arbitrary threads; a contains() racing an add() resize returns a wrong build-mode context. evaluateModify runs on every block break, place and interact. It evaluated a LuckPerms-routed hasPermission before a Set.contains, and a bypass-permission lookup before the map read whose default makes it moot. zipDirectoryToMemory uses zip4j rather than hand-rolling a second zip codec beside the zip4j one in the same file.
ConfigurableProperty.get() honours an active Overridable provider while set() only writes the underlying field, so /worlds setPermission and setProject on a world inside an override-enabled folder reported success, stored the value, and changed nothing that was enforced or displayed. The world then silently changed to the operator's months-old value whenever the override was later disabled. Both commands now say when the saved value is not the one in effect. DisplayablesMenu rebuilt cachedDisplayables on every populate, and the page arrows call populate directly -- so each page flip re-ran the folder filter, the per-world canEnter checks and the sort over every world. At 300 worlds in one category that is roughly 1,200 permission lookups per flip, on the main thread inside the click handler. Collection moved to refreshData(), called from open(); flipping a page re-renders the cached slice. The sort and filter controls are grouped into DisplayBar, and the two create-button click paths share one method that documents the player-head-versus-glass invariant they both depend on. CategoryWorldsMenu stops redeclaring three slot literals the base class already owns.
…y run PromptFlowTest's only test was skipped on a MockBukkit gap, so the reject-then-reprompt-then-complete contract it documents had zero executed assertions while still counting toward a green suite. It now drives the listener directly and runs. BackupProfileImplTest is new. deleteExcess decides which archives are deleted when a world exceeds its cap and had no test at all. The arithmetic and comparator were verified correct, so this pins them rather than fixing anything; insertion order is deliberately shuffled so a "delete the first N" regression fails. BuildSystemPluginSingletonGuardTest is renamed to BuildSystemPluginNoStaticSelfReferenceTest. It never loads or enables the plugin -- it is a bytecode guard against reintroducing a static singleton accessor -- so the old name promised runtime double-enable protection it cannot give. Assertions are unchanged.
It was declared in plugin.yml as a child of buildsystem.create with default TRUE, but no hasPermission call anywhere referenced it, so operators granting it to allow private world creation got nothing. The node that actually gates this is buildsystem.create.category.private. Registers buildsystem.create.category in its place, defaulted to OP to match how its dynamic children resolve. Category ids come from the registry at runtime so the individual nodes cannot be declared here; the parent exists for discoverability, in the same spirit as the existing create.template and create.generator entries.
canCreateWorld was consulted in exactly two places and both were render decisions: whether to draw the create button in CategoryWorldsMenu, and which icon the visibility toggle shows in EditMenu. Nothing on the path that actually creates a world ever asked, so the configured limit bound only the GUI that happened to be the entry point and no other caller would inherit it. Checked in startWorldNameInput, the single funnel all three creation flows pass through. Deliberately not checking createType here: template and custom-generator creation are gated by create.template.<name> and create.generator.<name>, so a blanket create.type.<name> check would reject them.
…o be The key descends from world.default.settings.public-worlds/private-worlds, renamed by MigrationV2ToV3 -- a per-player default, not a server capacity cap. The implementation had drifted from that: it selected the limit by visibility but compared it against the count of every build world on the server, so world.limits.private: 5 stopped all world creation once the server held five worlds of any kind, silently capping public worlds too. An operator could not discover that without reading the source. canCreateWorld now reads one ladder: the buildsystem.create.<visibility>.<amount> node if the player holds one, otherwise the configured default, otherwise unlimited. Counting is per player and per visibility throughout, so a limit on one visibility no longer blocks the other. buildsystem.admin short-circuits first. The resolver returns -1 both for "admin, unlimited" and for "no node held", so without that an admin would inherit the configured fallback -- which a new test caught. This is a behavior change from the old code, where the config cap applied to admins too, and it matches how buildsystem.admin behaves everywhere else. Out-of-the-box behavior is unchanged: the shipped default is -1/-1.
thomasmny
force-pushed
the
fix/gauntlet-extreme-findings
branch
from
July 30, 2026 08:31
3b8009d to
3220576
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the findings from a full-repo adversarial review (16 lenses, ~42 raw findings, refutation pass over the highest-stakes ones).
Heads-up on scope: this is large. Every finding that survived review is fixed here, plus the framework change and readability work that came out of it. Commits are kept separable so anything can be dropped or cherry-picked.
Data safety
BackupProfileImpl.applyRestoreunloaded the world, recursively deleted its directory, and only then opened the zip — so any unreadable archive (truncated upload, disk error, hand-edited file) left nothing to restore and no rollback. Validation now happens first, via a newvalidateBackup. A failed delete also aborts instead of extracting over a half-deleted world.storeBackupwrote directly to the final name with no failure handling; because the filename timestamp is assigned before the write, a partial.zipsorted first andBackupsMenushows no size or integrity hint. Now writes to a temp name and renames on success, so a partial upload can never occupy the real filename.createBackup()'s future entirely — nowhenComplete, noexceptionally— so on the default path a failure produced no log line at all. Only the manually-triggered helper logged.S3Client.getcleans up a partially-written file when the transfer throws mid-stream (BodyHandlers.ofFilehas already created and written it, so the existing cleanup branch was skipped).YamlStore.saveis now crash-atomic — temp file plusATOMIC_MOVE, falling back to a non-atomic replace where the filesystem refuses. Previously an in-place truncating write; a crash mid-write left a truncated file thatreload()logged and then proceeded past, silently losing world/player metadata.AbstractYamlStorageroutes through theioLock.spawn.yml,setup.yml,status.ymlandcategories.ymlbypassed the lock the cache-backed storages use, so two threads could write the same file.onDisablecancels the periodic save before joining the shutdown save, not after — cancelling can't stop a tick already in flight.config.ymlbefore mutating it, matching what the storage layer already did deliberately, and aborts rather than migrating unprotected if the copy fails.createBackup()no longer callsWorld::save()on the caller's thread. It is public API returning aCompletableFuture, so an off-thread caller tripped the async catcher on their own thread instead of getting a failed future.password/secretKeyintoString(), so a futurelogger.info("settings: " + settings)can't leak them.Authorization
WorldServiceImpl.deleteWorld(Player, ...)had no permission check at all — the menu was the only enforcement point. The no-Playeroverload stays unchecked for internal/API callers.X/X.self/X.other; the menu path gated only on flat nodes, so a moderator withedit.other+edit.status+setstatus.finishedcould set status on anyone's world while/worlds setstatuscorrectly refused them.EditMenu's builders/visibility handlers andBuilderMenu's add button decided access by comparing the clicked item'sMaterialto what was drawn at populate time. Remove-builder had no check at all.AddBuilderSubCommand.getAddBuilderInputdirectly, skipping the only placecanPerformCommandran.persist()wrote unconditionally, so a stale open editor rewrote a status/category another admin had deleted.New:
MenuButton.usableBy/canClickpermission()could only express a single node, so every resource-scoped or creator-based check became a hand-rolled early return insideonClick— which is how the icon-material bug happened. Buttons can now declareusableBy(Predicate<Player>)alongside the node, andcanClick(Player)is the single place the two combine and the only thinghandleClickconsults. The node stays introspectable, so the goldenpermissionBySlot()tests are unaffected.Permissions
buildsystem.create.<categoryId>→buildsystem.create.category.<categoryId>andbuildsystem.navigator.<categoryId>→buildsystem.navigator.category.<categoryId>. The old forms collided with the reservedcreate.folder/create.private/create.type.*/create.template.*/create.generator.*andnavigator.itemnodes whenever a category id matched one of those words — and with thecreate.<visibility>.<amount>world-limit nodes. Not a migration concern: v4.0.0 is unreleased (4.0.0-SNAPSHOT, latest tag3.0.2), so these never shipped.Also deleted the dead
Bypassablecapability — attached to three properties, read by nothing, with real enforcement re-typing the same literals elsewhere — plus thebuildsystem.bypass.permissionnode it carried, which granted nothing.Permissions' class javadoc no longer claims the full node set is readable in one place; that was false for the runtime factory methods and is why the collision went unnoticed.Correctness and performance
UpdateChecker:HttpURLConnectionwith no timeouts and a reader closed only on the happy path →HttpClientwith an explicit 5s timeout, matchingPlayerLookupServiceandS3Client. Its continuation ran on aForkJoinPoolthread and calledplayer.sendMessageoff the main thread, on every player join; now hops to the main thread and guards the exceptional path (it dereferencedresultwithout checking the throwable).buildModePlayersis aConcurrentHashMap.newKeySet()— a plainHashSetmutated on the main thread but read byBuildModeCalculator, which LuckPerms invokes from arbitrary threads.evaluateModify(hot path, every block break/place/interact) evaluated a LuckPerms-routedhasPermissionbefore aSet.contains, and a bypass-permission lookup before the map read whose default makes it moot.DisplayablesMenuno longer re-runs the full filter+sort on every page flip (~1,200 permission lookups per populate at 300 worlds); data collection moved torefreshData(), page arrows re-render the cached slice.World.save()in a single tick.ButtonMenu.registerthrows on a slot collision within a population pass.EditMenudraws its 54 slots from two independent catalogs, and a collision silently deleted a button.zipDirectoryToMemoryuses zip4j instead of hand-rolling a second zip codec beside the zip4j one in the same file./worlds setPermissionandsetProjectwarn when a folder override means the saved value is not the one in effect — previously they reported success unconditionally while enforcement kept using the folder's value.newWorld's name validation now also applies toimportWorld, which shared the same shape and enforced nothing.Tests
418 → 421, and skipped 1 → 0.
PromptFlowTest's only test was skipped on a MockBukkit gap, so the reject → reprompt → complete contract had zero executed assertions while counting toward a green suite. It now runs.BackupProfileImplTestpins the retention boundaries (at-cap, under-cap, cap-lowered) with shuffled insertion order so a "delete first N" regression fails. The math was verified correct — this guards it, it does not fix it.BuildSystemPluginSingletonGuardTest→BuildSystemPluginNoStaticSelfReferenceTest: it never enables the plugin, so the old name promised runtime double-enable protection it cannot give.Readability
EditMenu728 → 551 lines, with async head resolution moved intoMenuItems(which already owned that concern) and the non-authorization render helpers extracted toEditMenuRenderer. The builders/visibility renders deliberately stayed inEditMenuso they cannot drift from the predicates that gate them.Caveat worth reading
Of the review's findings, 24 were never adversarially verified — and every finding that was verified got downgraded or refuted outright (one
critical→high, fourhighTOCTOUs → twomediums, one refuted entirely). The unverified fixes are implemented conservatively, but some may be addressing problems that aren't real.Docs for the permission changes are a separate PR in
buildsystem-docs(v4).