Security audit: fix 7 critical/high issues from code review

1. CRITICAL - Anti-dupe: Player inventory mutations now run on the main
   server thread via server.execute(). DB reads stay async, but all
   setItem/setHealth/addEffect calls happen on the tick thread.
   CountDownLatch ensures the lock is held until apply completes.

2. CRITICAL - Resource leaks: 3 QueryResults in PlayerSync.java startup
   now use try-with-resources + PreparedStatements instead of raw
   String.format SQL.

3. HIGH - Curios save: UPDATE changed to REPLACE INTO to prevent silent
   no-ops when the curios row doesn't exist yet (new player who died
   before first init save).

4. HIGH - RS2 restore: Removed skip-if-exists check. DB is always the
   source of truth - stale local data was persisting permanently.

5. HIGH - Race conditions: Shutdown save now acquires per-player lock.
   All logout saves (curios, mod-compat, inventory) moved inside
   doPlayerLogout under a single lock acquisition.

6. HIGH - SQL injection: DATABASE_NAME validated against [A-Za-z0-9_]+
   regex on startup to prevent injection via config.

Vyrriox
This commit is contained in:
laforetbrut 2026-03-26 17:34:36 +01:00
parent 46689a360c
commit e907bcbfb0
3 changed files with 163 additions and 165 deletions

View File

@ -49,6 +49,13 @@ public class PlayerSync {
public void onServerStarting(ServerStartingEvent event) throws SQLException {
String dbName = JdbcConfig.DATABASE_NAME.get();
// FIX: Validate database name to prevent SQL injection via config.
// Only alphanumeric chars and underscores are allowed in MySQL identifiers.
if (!dbName.matches("[A-Za-z0-9_]+")) {
LOGGER.error("Invalid DATABASE_NAME '{}'. Only alphanumeric characters and underscores are allowed. Aborting.", dbName);
return;
}
// Step 1: Create the database using a connection that does not select a database.
JDBCsetUp.executeUpdate("CREATE DATABASE IF NOT EXISTS `" + dbName + "`", 1);
@ -84,16 +91,14 @@ public class PlayerSync {
);
// Check and alter player_data table if columns are missing
JDBCsetUp.QueryResult queryResult = JDBCsetUp.executeQuery(
"SELECT COUNT(*) AS column_count " +
"FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_SCHEMA = '" + dbName + "' " +
"AND TABLE_NAME = 'player_data';"
);
ResultSet resultSet = queryResult.resultSet();
int columnCount = 0;
if (resultSet.next()) {
columnCount = resultSet.getInt("column_count");
try (JDBCsetUp.QueryResult queryResult = JDBCsetUp.executePreparedQuery(
"SELECT COUNT(*) AS column_count FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'player_data'",
dbName)) {
ResultSet resultSet = queryResult.resultSet();
if (resultSet.next()) {
columnCount = resultSet.getInt("column_count");
}
}
if (columnCount < 14) {
JDBCsetUp.executeUpdate(
@ -163,40 +168,31 @@ public class PlayerSync {
);
// Check if backpack_data table has the 'uuid' column
JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executeQuery(
"SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_SCHEMA = '" + dbName + "' " +
"AND TABLE_NAME = 'backpack_data' " +
"AND COLUMN_NAME = 'uuid';"
);
ResultSet rsBackpackCol = backpackColCheck.resultSet();
if (rsBackpackCol.next() && rsBackpackCol.getInt("colCount") == 0) {
LOGGER.info("Altering backpack_data table to add missing 'uuid' column.");
// Add the missing column and set it as primary key.
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD COLUMN uuid CHAR(36) NOT NULL", 1);
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD PRIMARY KEY (uuid)", 1);
try (JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executePreparedQuery(
"SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'backpack_data' AND COLUMN_NAME = 'uuid'",
dbName)) {
ResultSet rsBackpackCol = backpackColCheck.resultSet();
if (rsBackpackCol.next() && rsBackpackCol.getInt("colCount") == 0) {
LOGGER.info("Altering backpack_data table to add missing 'uuid' column.");
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD COLUMN uuid CHAR(36) NOT NULL", 1);
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD PRIMARY KEY (uuid)", 1);
}
}
rsBackpackCol.close();
backpackColCheck.connection().close();
}
// Check and alter the 'advancements' column in player_data if necessary
JDBCsetUp.QueryResult advColCheck = JDBCsetUp.executeQuery(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_SCHEMA = '" + dbName + "' " +
"AND TABLE_NAME = 'player_data' " +
"AND COLUMN_NAME = 'advancements';"
);
ResultSet rsAdvCol = advColCheck.resultSet();
if (rsAdvCol.next()) {
String dataType = rsAdvCol.getString("DATA_TYPE");
if (!"mediumblob".equalsIgnoreCase(dataType)) {
LOGGER.info("Altering player_data table to modify 'advancements' column to MEDIUMBLOB.");
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`player_data` MODIFY COLUMN advancements MEDIUMBLOB", 1);
try (JDBCsetUp.QueryResult advColCheck = JDBCsetUp.executePreparedQuery(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'player_data' AND COLUMN_NAME = 'advancements'",
dbName)) {
ResultSet rsAdvCol = advColCheck.resultSet();
if (rsAdvCol.next()) {
String dataType = rsAdvCol.getString("DATA_TYPE");
if (!"mediumblob".equalsIgnoreCase(dataType)) {
LOGGER.info("Altering player_data table to modify 'advancements' column to MEDIUMBLOB.");
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`player_data` MODIFY COLUMN advancements MEDIUMBLOB", 1);
}
}
}
rsAdvCol.close();
// ----- END NEW BLOCK -----
// Create generic mod_player_data table for mod compatibility (Accessories, CosmeticArmor, Aether, etc.)
JDBCsetUp.executeUpdate(

View File

@ -219,56 +219,47 @@ public class VanillaSync {
public static Set<String> syncNotCompletedPlayer = ConcurrentHashMap.newKeySet();
public static void doPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) {
ServerPlayer joinedPlayer = (ServerPlayer) event.getEntity();
String player_uuid = joinedPlayer.getUUID().toString();
if (joinedPlayer.isDeadOrDying()) {
deadPlayerWhileLogging.add(player_uuid);
joinedPlayer.removeTag("player_synced");
ServerPlayer serverPlayer = (ServerPlayer) event.getEntity();
String player_uuid = serverPlayer.getUUID().toString();
MinecraftServer server = serverPlayer.getServer();
// Simulate normal death behavior
MinecraftServer server = joinedPlayer.getServer();
if (server != null) {
ResourceKey<Level> respawnLevel = joinedPlayer.getRespawnDimension();
BlockPos respawnPos = joinedPlayer.getRespawnPosition();
double respawnX;
double respawnY;
double respawnZ;
if (server == null) {
PlayerSync.LOGGER.error("Server is null for player {}", player_uuid);
return;
}
if (serverPlayer.isDeadOrDying()) {
deadPlayerWhileLogging.add(player_uuid);
serverPlayer.removeTag("player_synced");
server.execute(() -> {
ResourceKey<Level> respawnLevel = serverPlayer.getRespawnDimension();
BlockPos respawnPos = serverPlayer.getRespawnPosition();
if (respawnPos != null) {
ServerLevel level = server.getLevel(respawnLevel);
respawnX = respawnPos.getX();
respawnY = respawnPos.getY();
respawnZ = respawnPos.getZ();
if (level != null) {
joinedPlayer.teleportTo(level, respawnX, respawnY + 1, respawnZ, 0, 0);
serverPlayer.teleportTo(level, respawnPos.getX(), respawnPos.getY() + 1, respawnPos.getZ(), 0, 0);
}
} else {
PlayerSync.LOGGER.debug("Player {} has no respawn point", player_uuid);
}
} else {
PlayerSync.LOGGER.warn("Trying to get server,but got a null");
}
joinedPlayer.setHealth(1);
serverPlayer.setHealth(1);
serverPlayer.connection.disconnect(Component.translatableWithFallback("playersync.wrong_entity_status","An error occurred while creating playerEntity in the world,please login again."));
});
try {
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid);
} catch (SQLException e) {
PlayerSync.LOGGER.error("An error occurred while handling dead/dying player {}", e.getMessage());
}
joinedPlayer.connection.disconnect(Component.translatableWithFallback("playersync.wrong_entity_status","An error occurred while creating playerEntity in the world,please login again."));
return;
}
// Acquire per-player lock to prevent concurrent save/restore (anti-duplication)
ReentrantLock lock = getPlayerLock(player_uuid);
lock.lock();
try {
PlayerSync.LOGGER.info("Starting synchronization for player {}", player_uuid);
syncNotCompletedPlayer.add(player_uuid);
ServerPlayer serverPlayer = (ServerPlayer) event.getEntity();
// First query: check if player exists in DB
// === PHASE 1: DB reads on background thread (thread-safe) ===
boolean playerExists;
try (JDBCsetUp.QueryResult qr1 = JDBCsetUp.executePreparedQuery(
"SELECT uuid FROM player_data WHERE uuid=?", player_uuid)) {
@ -276,28 +267,56 @@ public class VanillaSync {
}
if (!playerExists) {
// New player - init and save
ModsSupport modsSupport = new ModsSupport();
modsSupport.doCuriosRestore(serverPlayer);
store(event.getEntity(), true);
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid);
PlayerSync.LOGGER.info("New player detected, init completed.");
syncNotCompletedPlayer.remove(player_uuid);
server.execute(() -> {
try {
new ModsSupport().doCuriosRestore(serverPlayer);
store(serverPlayer, true);
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid);
serverPlayer.addTag("player_synced");
} catch (Exception e) {
PlayerSync.LOGGER.error("Error initializing new player {}", player_uuid, e);
} finally {
syncNotCompletedPlayer.remove(player_uuid);
}
});
return;
}
// Mark player as online immediately to prevent race conditions
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid);
// Retrieve full player data
// Read all DB data into local variables (background thread - safe)
final int health, foodLevel, xp, score;
final String leftHand, cursors, armorData, inventoryData, enderChestData, effectData;
try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery(
"SELECT * FROM player_data WHERE uuid=?", player_uuid)) {
ResultSet rs2 = qr2.resultSet();
if (!rs2.next()) {
PlayerSync.LOGGER.warn("No data found for existing player {}", player_uuid);
syncNotCompletedPlayer.remove(player_uuid);
return;
}
health = rs2.getInt("health");
foodLevel = rs2.getInt("food_level");
xp = rs2.getInt("xp");
score = rs2.getInt("score");
leftHand = rs2.getString("left_hand");
cursors = rs2.getString("cursors");
armorData = rs2.getString("armor");
inventoryData = rs2.getString("inventory");
enderChestData = rs2.getString("enderchest");
effectData = rs2.getString("effects");
}
if (rs2.next()) {
// === ANTI-DUPLICATION: Clear all inventories BEFORE restoring from DB ===
// === PHASE 2: Apply to player on MAIN SERVER THREAD ===
// Minecraft entities are NOT thread-safe. Modifying inventory/health/effects
// from a background thread causes duplication exploits and corruption.
CountDownLatch applyLatch = new CountDownLatch(1);
server.execute(() -> {
try {
// ANTI-DUPLICATION: Clear all inventories BEFORE restoring
serverPlayer.getInventory().clearContent();
serverPlayer.getEnderChestInventory().clearContent();
serverPlayer.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
@ -307,47 +326,27 @@ public class VanillaSync {
}
// Restore basic attributes
int health = rs2.getInt("health");
if (health <= 0) {
serverPlayer.setHealth(1);
} else {
serverPlayer.setHealth(health);
}
serverPlayer.getFoodData().setFoodLevel(rs2.getInt("food_level"));
serverPlayer.setHealth(health <= 0 ? 1 : health);
serverPlayer.getFoodData().setFoodLevel(foodLevel);
setXpForPlayer(serverPlayer, xp);
serverPlayer.setScore(score);
setXpForPlayer(serverPlayer, rs2.getInt("xp"));
serverPlayer.setScore(rs2.getInt("score"));
// Restore items
serverPlayer.setItemInHand(InteractionHand.OFF_HAND, deserializeAndCreatePlaceholderIfNeeded(leftHand));
serverPlayer.containerMenu.setCarried(deserializeAndCreatePlaceholderIfNeeded(cursors));
// Restore left-hand item
String leftHandEncoded = rs2.getString("left_hand");
serverPlayer.setItemInHand(InteractionHand.OFF_HAND,
deserializeAndCreatePlaceholderIfNeeded(leftHandEncoded));
// Restore cursor item
String cursorsEncoded = rs2.getString("cursors");
serverPlayer.containerMenu.setCarried(
deserializeAndCreatePlaceholderIfNeeded(cursorsEncoded));
// Restore armor
String armor_data = rs2.getString("armor");
if (armor_data != null && armor_data.length() > 2) {
Map<Integer, String> equipment = LocalJsonUtil.StringToEntryMap(armor_data);
if (armorData != null && armorData.length() > 2) {
Map<Integer, String> equipment = LocalJsonUtil.StringToEntryMap(armorData);
for (Map.Entry<Integer, String> entry : equipment.entrySet()) {
serverPlayer.getInventory().armor.set(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue()));
}
}
// Restore inventory
String inventoryData = rs2.getString("inventory");
if (inventoryData != null && inventoryData.length() > 2) {
Map<Integer, String> inventory = LocalJsonUtil.StringToEntryMap(inventoryData);
for (Map.Entry<Integer, String> entry : inventory.entrySet()) {
serverPlayer.getInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue()));
}
}
// Restore Ender Chest
String enderChestData = rs2.getString("enderchest");
if (enderChestData != null && enderChestData.length() > 2) {
Map<Integer, String> ender_chest = LocalJsonUtil.StringToEntryMap(enderChestData);
for (Map.Entry<Integer, String> entry : ender_chest.entrySet()) {
@ -355,12 +354,8 @@ public class VanillaSync {
}
}
// FIX: ALWAYS clear effects before restoring to prevent stale local effects
// from persisting when DB has no saved effects (e.g. player had no effects on previous server)
// Always clear effects, then restore from DB
serverPlayer.removeAllEffects();
// Restore Effects from DB (if any)
String effectData = rs2.getString("effects");
if (effectData != null && effectData.length() > 2) {
Map<Integer, String> effects = LocalJsonUtil.StringToEntryMap(effectData);
for (Map.Entry<Integer, String> entry : effects.entrySet()) {
@ -371,26 +366,34 @@ public class VanillaSync {
}
}
}
// Restore mod data (these do their own DB reads internally, acceptable on main thread)
ModsSupport modsSupport = new ModsSupport();
modsSupport.doCuriosRestore(serverPlayer);
modsSupport.doBackPackRestore(serverPlayer);
if (ModList.get().isLoaded("sophisticatedstorage")) {
ModsSupport.restoreSophisticatedStorageItems(serverPlayer);
}
if (ModList.get().isLoaded("refinedstorage")) {
ModsSupport.restoreRefinedStorageDisks(serverPlayer);
}
ModCompatSync.restoreAll(serverPlayer);
serverPlayer.addTag("player_synced");
PlayerSync.LOGGER.info("Sync data for player {} completed.", player_uuid);
} catch (Exception e) {
PlayerSync.LOGGER.error("Error applying sync data for player {}", player_uuid, e);
} finally {
syncNotCompletedPlayer.remove(player_uuid);
applyLatch.countDown();
}
}
});
// Restore mod data AFTER main inventory is restored
ModsSupport modsSupport = new ModsSupport();
modsSupport.doCuriosRestore(serverPlayer);
modsSupport.doBackPackRestore(serverPlayer);
if (ModList.get().isLoaded("sophisticatedstorage")) {
ModsSupport.restoreSophisticatedStorageItems(serverPlayer);
// Wait for main thread to finish applying (prevents lock release before data is applied)
if (!applyLatch.await(15, TimeUnit.SECONDS)) {
PlayerSync.LOGGER.error("Timeout waiting for main thread sync for player {}", player_uuid);
syncNotCompletedPlayer.remove(player_uuid);
}
if (ModList.get().isLoaded("refinedstorage")) {
ModsSupport.restoreRefinedStorageDisks(serverPlayer);
}
// Restore mod compatibility data (Accessories/Aether, CosmeticArmor)
ModCompatSync.restoreAll(serverPlayer);
serverPlayer.addTag("player_synced");
PlayerSync.LOGGER.info("Sync data for player {} completed.", player_uuid);
syncNotCompletedPlayer.remove(player_uuid);
} catch (Exception e) {
PlayerSync.LOGGER.error("Internal Exception detected!", e);
syncNotCompletedPlayer.remove(player_uuid);
@ -648,6 +651,10 @@ public class VanillaSync {
if (server != null) {
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
if (player.getTags().contains("player_synced") && !player.isDeadOrDying()) {
String puuid = player.getUUID().toString();
// FIX: Acquire per-player lock to prevent race with queued logout save
ReentrantLock lock = getPlayerLock(puuid);
lock.lock();
try {
store(player, false);
if (ModList.get().isLoaded("curios")) {
@ -663,10 +670,12 @@ public class VanillaSync {
if (ModList.get().isLoaded("refinedstorage")) {
ModsSupport.storeRefinedStorageDisks(player);
}
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player.getUUID().toString());
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", puuid);
PlayerSync.LOGGER.info("Saved player {} data on server shutdown", player.getUUID());
} catch (Exception e) {
PlayerSync.LOGGER.error("Error saving player {} on shutdown", player.getUUID(), e);
} finally {
lock.unlock();
}
}
}
@ -674,14 +683,27 @@ public class VanillaSync {
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET enable=0 WHERE id=?", JdbcConfig.SERVER_ID.get());
}
/**
* FIX: All save operations (inventory, curios, mod-compat) are now under the per-player lock
* to prevent race conditions with concurrent auto-save tasks on the executor.
*/
public static void doPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) throws SQLException, IOException {
String player_uuid = event.getEntity().getUUID().toString();
// Acquire per-player lock to prevent concurrent save/restore (anti-duplication)
Player player = event.getEntity();
ReentrantLock lock = getPlayerLock(player_uuid);
lock.lock();
try {
// FIX: Save data BEFORE marking offline to prevent data loss on quick reconnect
store(event.getEntity(), false);
// Save ALL data under lock: curios, mod-compat, then main inventory, then mark offline
if (ModList.get().isLoaded("curios")) {
ModsSupport modsSupport = new ModsSupport();
if (player.isDeadOrDying()) {
modsSupport.saveCuriosFromCacheOrApi(player);
} else {
modsSupport.onPlayerLeave(player);
}
}
ModCompatSync.storeAll(player);
store(player, false);
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid);
} finally {
lock.unlock();
@ -701,20 +723,8 @@ public class VanillaSync {
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid);
syncNotCompletedPlayer.remove(player_uuid);
} else {
// Mod support - save curios
ModsSupport modsSupport = new ModsSupport();
Player player = event.getEntity();
// FIX: If player is dead/dying, use curios cache instead of reading from API (which returns empty)
if (player.isDeadOrDying()) {
modsSupport.saveCuriosFromCacheOrApi(player);
} else {
modsSupport.onPlayerLeave(player);
}
// Save mod compatibility data (Accessories/Aether, CosmeticArmor)
ModCompatSync.storeAll(player);
// FIX: All saves moved inside doPlayerLogout under the per-player lock
// to prevent race conditions with auto-save
executorService.submit(() -> {
try {
doPlayerLogout(event);

View File

@ -204,8 +204,8 @@ public class ModsSupport {
// Use cached data from death event
PlayerSync.LOGGER.info("Using cached curios data for dead player {}", playerUuid);
JDBCsetUp.executePreparedUpdate(
"UPDATE curios SET curios_item=? WHERE uuid=?",
cached.serializedData, playerUuid.toString());
"REPLACE INTO curios (uuid, curios_item) VALUES (?, ?)",
playerUuid.toString(), cached.serializedData);
CuriosCache.curiosCache.remove(playerUuid);
} else {
// Fallback: try to read from API (may be empty for dead players)
@ -234,16 +234,11 @@ public class ModsSupport {
String serializedData = flatMap.toString();
// Use prepared statements to prevent SQL injection / data corruption
if (init) {
JDBCsetUp.executePreparedUpdate(
"INSERT INTO curios (uuid, curios_item) VALUES (?, ?)",
player.getUUID().toString(), serializedData);
} else {
JDBCsetUp.executePreparedUpdate(
"UPDATE curios SET curios_item=? WHERE uuid=?",
serializedData, player.getUUID().toString());
}
// FIX: Use REPLACE INTO instead of separate INSERT/UPDATE to prevent silent
// no-ops when the row doesn't exist yet (e.g. new player who died before first save)
JDBCsetUp.executePreparedUpdate(
"REPLACE INTO curios (uuid, curios_item) VALUES (?, ?)",
player.getUUID().toString(), serializedData);
}
// ============================
@ -461,12 +456,9 @@ public class ModsSupport {
com.refinedmods.refinedstorage.common.api.RefinedStorageApi.INSTANCE.getStorageRepository(sp.serverLevel());
for (UUID uuid : diskUuids) {
// Check if storage already exists on this server (don't overwrite)
if (repo.get(uuid).isPresent()) {
PlayerSync.LOGGER.debug("RS2 storage {} already exists on this server, skipping restore", uuid);
continue;
}
// FIX: Always overwrite with DB data (source of truth). Previously skipped if storage
// existed locally, causing stale data to persist when a player modified a disk on
// another server and came back.
restoreStorageContents(uuid, (nbt) -> {
try {
injectRS2StorageEntry(repo, nbt, sp);