diff --git a/Core/GameEngine/Include/Common/FileSystem.h b/Core/GameEngine/Include/Common/FileSystem.h index 51bf9153caa..e96af015c3f 100644 --- a/Core/GameEngine/Include/Common/FileSystem.h +++ b/Core/GameEngine/Include/Common/FileSystem.h @@ -161,6 +161,7 @@ class FileSystem : public SubsystemInterface static AsciiString normalizePath(const AsciiString& path); ///< normalizes a file path. The path can refer to a directory. File path must be absolute, but does not need to exist. Returns an empty string on failure. static Bool isPathInDirectory(const AsciiString& testPath, const AsciiString& basePath); ///< determines if a file path is within a base path. Both paths must be absolute, but do not need to exist. + static Bool hasValidTransferFileContent(const AsciiString& filePath, const UnsignedByte* data, Int dataSize); ///< validates transferred file content in memory before writing to disk. protected: #if ENABLE_FILESYSTEM_EXISTENCE_CACHE diff --git a/Core/GameEngine/Source/Common/System/FileSystem.cpp b/Core/GameEngine/Source/Common/System/FileSystem.cpp index 46d468d4a05..cdb0cd0fa2c 100644 --- a/Core/GameEngine/Source/Common/System/FileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/FileSystem.cpp @@ -457,3 +457,121 @@ Bool FileSystem::isPathInDirectory(const AsciiString& testPath, const AsciiStrin return true; } + +namespace +{ + +enum TransferFileType +{ + TRANSFER_FILE_MAP, + TRANSFER_FILE_INI, + TRANSFER_FILE_STR, + TRANSFER_FILE_TXT, + TRANSFER_FILE_TGA, + TRANSFER_FILE_WAK, + TRANSFER_FILE_COUNT +}; + +struct TransferFileRule +{ + const char* ext; + Int maxSize; + TransferFileType type; +}; + +const TransferFileRule transferFileRules[] = +{ + { ".map", 5 * 1024 * 1024, TRANSFER_FILE_MAP }, + { ".ini", 512 * 1024, TRANSFER_FILE_INI }, + { ".str", 512 * 1024, TRANSFER_FILE_STR }, + { ".txt", 512 * 1024, TRANSFER_FILE_TXT }, + { ".tga", 2 * 1024 * 1024, TRANSFER_FILE_TGA }, + { ".wak", 512 * 1024, TRANSFER_FILE_WAK }, +}; + +const TransferFileRule* getTransferFileRule(const char* extension) +{ + for (Int i = 0; i < ARRAY_SIZE(transferFileRules); ++i) + { + if (stricmp(extension, transferFileRules[i].ext) == 0) + { + return &transferFileRules[i]; + } + } + return nullptr; +} + +} // namespace + +//============================================================================ +// FileSystem::hasValidTransferFileContent +//============================================================================ +// TheSuperHackers @security bobtista 12/02/2026 Validates transferred file +// content in memory before writing to disk. +Bool FileSystem::hasValidTransferFileContent(const AsciiString& filePath, const UnsignedByte* data, Int dataSize) +{ + const char* lastDot = strrchr(filePath.str(), '.'); + if (lastDot == nullptr) + { + DEBUG_LOG(("File '%s' has no extension for content validation.", filePath.str())); + return false; + } + + const TransferFileRule* rule = getTransferFileRule(lastDot); + if (rule == nullptr) + { + DEBUG_LOG(("File '%s' has unrecognized extension '%s' for content validation.", filePath.str(), lastDot)); + return false; + } + + // Check size limit + if (dataSize > rule->maxSize) + { + DEBUG_LOG(("File '%s' exceeds maximum size (%d bytes, limit %d bytes).", filePath.str(), dataSize, rule->maxSize)); + return false; + } + + // Extension-specific content validation + switch (rule->type) + { + case TRANSFER_FILE_MAP: + if (dataSize < 4 || memcmp(data, "CkMp", 4) != 0) + { + DEBUG_LOG(("Map file '%s' has invalid magic bytes.", filePath.str())); + return false; + } + break; + + case TRANSFER_FILE_INI: + { + Int bytesToCheck = std::min(dataSize, 512); + for (Int i = 0; i < bytesToCheck; ++i) + { + if (data[i] == 0) + { + DEBUG_LOG(("INI file '%s' contains null bytes (likely binary).", filePath.str())); + return false; + } + } + break; + } + + case TRANSFER_FILE_TGA: + if (dataSize < 44) + { + DEBUG_LOG(("TGA file '%s' is too small to be valid (minimum header 18 + footer 26 = 44 bytes).", filePath.str())); + return false; + } + if (memcmp(data + dataSize - 18, "TRUEVISION-XFILE.", 18) != 0) + { + DEBUG_LOG(("TGA file '%s' is missing TRUEVISION-XFILE footer signature.", filePath.str())); + return false; + } + break; + + default: + break; + } + + return true; +} diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp index 66b4e0f2b40..71cbed85d7e 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp @@ -31,6 +31,7 @@ #include "Common/CRCDebug.h" #include "Common/Debug.h" #include "Common/file.h" +#include "Common/FileSystem.h" #include "Common/GameAudio.h" #include "Common/LocalFileSystem.h" #include "Common/Player.h" @@ -734,6 +735,20 @@ void ConnectionManager::processFile(NetFileCommandMsg *msg) } #endif // COMPRESS_TARGAS + // TheSuperHackers @security bobtista 12/02/2026 Validate file content in memory before writing to disk + if (!FileSystem::hasValidTransferFileContent(realFileName, buf, len)) + { + DEBUG_LOG(("File '%s' failed content validation. Transfer aborted.", realFileName.str())); +#ifdef COMPRESS_TARGAS + if (deleteBuf) + { + delete[] buf; + buf = nullptr; + } +#endif // COMPRESS_TARGAS + return; + } + File *fp = TheFileSystem->openFile(realFileName.str(), File::CREATE | File::BINARY | File::WRITE); if (fp) {