diff --git a/include/auth/srp.hpp b/include/auth/srp.hpp index 1cb8cae4..eed54467 100644 --- a/include/auth/srp.hpp +++ b/include/auth/srp.hpp @@ -48,6 +48,10 @@ public: // Get session key (K) - used for encryption std::vector getSessionKey() const; + // Securely erase stored plaintext credentials from memory. + // Called automatically at the end of feed() once the SRP values are computed. + void clearCredentials(); + private: // WoW-specific SRP multiplier (k = 3) static constexpr uint32_t K_VALUE = 3; diff --git a/include/game/entity.hpp b/include/game/entity.hpp index bd5dfc5f..4845e880 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -361,6 +361,8 @@ public: } private: + // MAIN-THREAD-ONLY: all entity map mutations happen via dispatchQueuedPackets() + // which runs on the main thread. Do NOT access from the async network pump thread. std::unordered_map> entities; }; diff --git a/include/network/world_socket.hpp b/include/network/world_socket.hpp index 543e570f..d09deb34 100644 --- a/include/network/world_socket.hpp +++ b/include/network/world_socket.hpp @@ -94,15 +94,18 @@ private: void recordRecentPacket(bool outbound, uint16_t opcode, uint16_t payloadLen); void dumpRecentPacketHistoryLocked(const char* reason, size_t bufferedBytes); - socket_t sockfd = INVALID_SOCK; - bool connected = false; - bool encryptionEnabled = false; + socket_t sockfd = INVALID_SOCK; // THREAD-SAFE: protected by ioMutex_ + bool connected = false; // THREAD-SAFE: protected by ioMutex_ + bool encryptionEnabled = false; // THREAD-SAFE: protected by ioMutex_ bool useVanillaCrypt = false; // true = XOR cipher, false = RC4 bool useAsyncPump_ = true; std::thread asyncPumpThread_; - std::atomic asyncPumpStop_{false}; - std::atomic asyncPumpRunning_{false}; + std::atomic asyncPumpStop_{false}; // THREAD-SAFE: atomic + std::atomic asyncPumpRunning_{false}; // THREAD-SAFE: atomic + // Guards sockfd, connected, encryptionEnabled, receiveBuffer, cipher state, + // headerBytesDecrypted, and recentPacketHistory_. mutable std::mutex ioMutex_; + // Guards pendingPacketCallbacks_ (asyncPumpThread_ produces, main thread consumes). mutable std::mutex callbackMutex_; // WotLK RC4 ciphers for header encryption/decryption @@ -112,11 +115,12 @@ private: // Vanilla/TBC XOR+addition cipher auth::VanillaCrypt vanillaCrypt; - // Receive buffer + // THREAD-SAFE: protected by ioMutex_ std::vector receiveBuffer; size_t receiveReadOffset_ = 0; // Optional reused packet queue (feature-gated) to reduce per-update allocations. std::vector parsedPacketsScratch_; + // THREAD-SAFE: protected by callbackMutex_. // Parsed packets waiting for callback dispatch; drained with a strict per-update budget. std::deque pendingPacketCallbacks_; diff --git a/include/pipeline/asset_manager.hpp b/include/pipeline/asset_manager.hpp index ca6a44c1..3b8de34d 100644 --- a/include/pipeline/asset_manager.hpp +++ b/include/pipeline/asset_manager.hpp @@ -4,6 +4,7 @@ #include "pipeline/dbc_loader.hpp" #include "pipeline/asset_manifest.hpp" #include "pipeline/loose_file_reader.hpp" +#include #include #include #include @@ -166,7 +167,11 @@ private: */ std::string resolveFile(const std::string& normalizedPath) const; + // Guards fileCache, dbcCache, fileCacheTotalBytes, fileCacheAccessCounter, and + // fileCacheBudget. Shared lock for read-only cache lookups (readFile cache hit, + // loadDBC cache hit); exclusive lock for inserts and eviction. mutable std::shared_mutex cacheMutex; + // THREAD-SAFE: protected by cacheMutex (exclusive lock for writes). std::unordered_map> dbcCache; // File cache (LRU, dynamic budget based on system RAM) @@ -174,11 +179,14 @@ private: std::vector data; uint64_t lastAccessTime; }; + // THREAD-SAFE: protected by cacheMutex (shared_mutex — shared_lock for reads, + // exclusive lock_guard for writes/eviction). mutable std::unordered_map fileCache; mutable size_t fileCacheTotalBytes = 0; mutable uint64_t fileCacheAccessCounter = 0; - mutable size_t fileCacheHits = 0; - mutable size_t fileCacheMisses = 0; + // THREAD-SAFE: atomic — incremented from any thread after releasing cacheMutex. + mutable std::atomic fileCacheHits{0}; + mutable std::atomic fileCacheMisses{0}; mutable size_t fileCacheBudget = 1024 * 1024 * 1024; // Dynamic, starts at 1GB void setupFileCacheBudget(); diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index 9333ea38..85090dab 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -362,10 +362,13 @@ private: // Background loading worker pool std::vector workerThreads; int workerCount = 0; + // THREAD-SAFE: guards loadQueue, readyQueue, and pendingTiles. + // Workers wait on queueCV; main thread signals when new tiles are enqueued + // or when readyQueue drains below maxReadyQueueSize_. std::mutex queueMutex; std::condition_variable queueCV; - std::deque loadQueue; - std::queue> readyQueue; + std::deque loadQueue; // THREAD-SAFE: protected by queueMutex + std::queue> readyQueue; // THREAD-SAFE: protected by queueMutex // Maximum number of prepared-but-not-finalized tiles in readyQueue. // Each prepared tile can hold 100–500 MB of decoded textures in RAM. // Workers sleep when this limit is reached, letting the main thread @@ -378,6 +381,7 @@ private: size_t bytes = 0; std::list::iterator lruIt; }; + // THREAD-SAFE: protected by tileCacheMutex_. std::unordered_map tileCache_; std::list tileCacheLru_; size_t tileCacheBytes_ = 0; @@ -391,8 +395,8 @@ private: std::atomic workerRunning{false}; // Track tiles currently queued or being processed to avoid duplicates - std::unordered_map pendingTiles; - std::unordered_set missingAdtWarnings_; + std::unordered_map pendingTiles; // THREAD-SAFE: protected by queueMutex + std::unordered_set missingAdtWarnings_; // THREAD-SAFE: protected by missingAdtWarningsMutex_ std::mutex missingAdtWarningsMutex_; // Thread-safe set of M2 model IDs already uploaded to GPU @@ -405,10 +409,11 @@ private: std::unordered_set preparedWmoUniqueIds_; std::mutex preparedWmoUniqueIdsMutex_; - // Dedup set for doodad placements across tile boundaries + // MAIN-THREAD-ONLY: checked and modified in processReadyTiles() and unloadDistantTiles(), + // both of which run exclusively on the main thread. std::unordered_set placedDoodadIds; - // Dedup set for WMO placements across tile boundaries (prevents rendering Stormwind 16x) + // MAIN-THREAD-ONLY: same contract as placedDoodadIds. std::unordered_set placedWmoIds; // Tiles currently being incrementally finalized across frames diff --git a/src/audio/audio_engine.cpp b/src/audio/audio_engine.cpp index c6b060e8..5db9e2cc 100644 --- a/src/audio/audio_engine.cpp +++ b/src/audio/audio_engine.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace wowee { @@ -26,6 +27,10 @@ struct DecodedWavCacheEntry { }; static std::unordered_map gDecodedWavCache; +// Protects gDecodedWavCache — shared_lock for reads, unique_lock for writes. +// Required because playSound2D() can be called from multiple threads +// (main thread, async loaders, animation callbacks). +static std::shared_mutex gDecodedWavCacheMutex; static uint64_t makeWavCacheKey(const std::vector& wavData) { // FNV-1a over the first 256 bytes + last 256 bytes + total size. @@ -53,9 +58,14 @@ static bool decodeWavCached(const std::vector& wavData, DecodedWavCache if (wavData.empty()) return false; const uint64_t key = makeWavCacheKey(wavData); - if (auto it = gDecodedWavCache.find(key); it != gDecodedWavCache.end()) { - out = it->second; - return true; + + // Fast path: shared (read) lock for cache hits — allows concurrent lookups. + { + std::shared_lock readLock(gDecodedWavCacheMutex); + if (auto it = gDecodedWavCache.find(key); it != gDecodedWavCache.end()) { + out = it->second; + return true; + } } ma_decoder decoder; @@ -102,13 +112,22 @@ static bool decodeWavCached(const std::vector& wavData, DecodedWavCache // Evict oldest half when cache grows too large. 256 entries ≈ 50-100 MB of decoded // PCM data depending on file lengths; halving keeps memory bounded while retaining // recently-heard sounds (footsteps, UI clicks, combat hits) for instant replay. - constexpr size_t kMaxCachedSounds = 256; - if (gDecodedWavCache.size() >= kMaxCachedSounds) { - auto it = gDecodedWavCache.begin(); - std::advance(it, gDecodedWavCache.size() / 2); - gDecodedWavCache.erase(gDecodedWavCache.begin(), it); + // Exclusive (write) lock — only one thread can evict + insert. + { + std::lock_guard writeLock(gDecodedWavCacheMutex); + // Re-check in case another thread inserted while we were decoding. + if (auto it = gDecodedWavCache.find(key); it != gDecodedWavCache.end()) { + out = it->second; + return true; + } + constexpr size_t kMaxCachedSounds = 256; + if (gDecodedWavCache.size() >= kMaxCachedSounds) { + auto it = gDecodedWavCache.begin(); + std::advance(it, gDecodedWavCache.size() / 2); + gDecodedWavCache.erase(gDecodedWavCache.begin(), it); + } + gDecodedWavCache.emplace(key, entry); } - gDecodedWavCache.emplace(key, entry); out = entry; return true; } diff --git a/src/auth/auth_handler.cpp b/src/auth/auth_handler.cpp index 2cb2bb48..4b466a5a 100644 --- a/src/auth/auth_handler.cpp +++ b/src/auth/auth_handler.cpp @@ -68,6 +68,26 @@ void AuthHandler::disconnect() { socket->disconnect(); socket.reset(); } + + // Scrub sensitive material when tearing down the auth session. + if (!password.empty()) { + volatile char* p = const_cast(password.data()); + for (size_t i = 0; i < password.size(); ++i) + p[i] = '\0'; + password.clear(); + password.shrink_to_fit(); + } + if (!sessionKey.empty()) { + volatile uint8_t* k = const_cast(sessionKey.data()); + for (size_t i = 0; i < sessionKey.size(); ++i) + k[i] = 0; + sessionKey.clear(); + sessionKey.shrink_to_fit(); + } + if (srp) { + srp->clearCredentials(); + } + setState(AuthState::DISCONNECTED); LOG_INFO("Disconnected from auth server"); } @@ -354,6 +374,16 @@ void AuthHandler::handleLogonProofResponse(network::Packet& packet) { sessionKey = srp->getSessionKey(); setState(AuthState::AUTHENTICATED); + // Plaintext password is no longer needed — zero-fill and release it so it + // doesn't sit in process memory for the rest of the session. + if (!password.empty()) { + volatile char* p = const_cast(password.data()); + for (size_t i = 0; i < password.size(); ++i) + p[i] = '\0'; + password.clear(); + password.shrink_to_fit(); + } + LOG_INFO("========================================"); LOG_INFO(" AUTHENTICATION SUCCESSFUL!"); LOG_INFO("========================================"); diff --git a/src/auth/srp.cpp b/src/auth/srp.cpp index 99498164..de3cb339 100644 --- a/src/auth/srp.cpp +++ b/src/auth/srp.cpp @@ -96,6 +96,10 @@ void SRP::feed(const std::vector& B_bytes, // 5. Compute proofs (M1, M2) computeProofs(stored_username); + // Credentials are no longer needed — zero and release them so they don't + // linger in process memory longer than necessary. + clearCredentials(); + // Log key values for debugging auth issues auto hexStr = [](const std::vector& v, size_t maxBytes = 8) -> std::string { std::ostringstream ss; @@ -314,5 +318,26 @@ std::vector SRP::getSessionKey() const { return K; } +void SRP::clearCredentials() { + // Overwrite plaintext password bytes before releasing storage so that a + // heap dump / core file doesn't leak the user's credentials. This is + // not a guarantee against a privileged attacker with live memory access, + // but it removes the most common exposure vector. + if (!stored_password.empty()) { + volatile char* p = const_cast(stored_password.data()); + for (size_t i = 0; i < stored_password.size(); ++i) + p[i] = '\0'; + stored_password.clear(); + stored_password.shrink_to_fit(); + } + if (!stored_auth_hash.empty()) { + volatile uint8_t* h = const_cast(stored_auth_hash.data()); + for (size_t i = 0; i < stored_auth_hash.size(); ++i) + h[i] = 0; + stored_auth_hash.clear(); + stored_auth_hash.shrink_to_fit(); + } +} + } // namespace auth } // namespace wowee diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index a3cc56b9..04dc195e 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -571,7 +571,21 @@ void WorldSocket::pumpNetworkIO() { } receiveBuffer.insert(receiveBuffer.end(), buffer, buffer + receivedSize); } else { - receiveBuffer.insert(receiveBuffer.end(), buffer, buffer + received); + // Non-fast path: same overflow pre-check as fast path to prevent + // unbounded buffer growth before the post-check below. + size_t liveBytes = bufferedBytes(); + if (liveBytes > kMaxReceiveBufferBytes || receivedSize > (kMaxReceiveBufferBytes - liveBytes)) { + compactReceiveBuffer(); + liveBytes = bufferedBytes(); + } + if (liveBytes > kMaxReceiveBufferBytes || receivedSize > (kMaxReceiveBufferBytes - liveBytes)) { + LOG_ERROR("World socket receive buffer would overflow (buffered=", liveBytes, + " incoming=", receivedSize, " max=", kMaxReceiveBufferBytes, + "). Disconnecting to recover framing."); + closeSocketNoJoin(); + return; + } + receiveBuffer.insert(receiveBuffer.end(), buffer, buffer + receivedSize); } if (bufferedBytes() > kMaxReceiveBufferBytes) { LOG_ERROR("World socket receive buffer overflow (", bufferedBytes(),