Skip to content
Open
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
97d61d7
fix(security): add file content validation for map transfer
bobtista Nov 6, 2025
73e763d
refactor(security): add STRICMP macro for cross-platform string compa…
bobtista Dec 17, 2025
0599333
refactor(security): split validation into helper function with early …
bobtista Dec 17, 2025
c2d0135
refactor(security): simplify .str/.txt extension handling logic
bobtista Dec 17, 2025
58ae26c
fix(security): reduce file size limits to reasonable values
bobtista Dec 17, 2025
59f93ca
docs(security): add comment explaining transfer abort behavior on val…
bobtista Dec 17, 2025
41048c3
feat(network): Validate transfer file content in memory before writin…
bobtista Feb 12, 2026
c87d395
style(security): Replace NULL with nullptr in transfer file validation
bobtista Feb 12, 2026
1812801
style(security): Use std::min for INI null-byte check byte count
bobtista Feb 16, 2026
fc9a68c
style(security): Use memcmp for map file magic bytes check
bobtista Feb 16, 2026
2bdc12a
feat(security): Add TGA 2.0 footer signature validation for transferr…
bobtista Feb 16, 2026
4505e7b
refactor(security): Extract TransferFileRule into anonymous namespace…
bobtista Feb 16, 2026
bc37447
fix(security): Require minimum 44 bytes for TGA validation (18 header…
bobtista Feb 16, 2026
f846100
fix(security): Use UnsignedInt for dataSize and maxSize in transfer f…
bobtista Feb 17, 2026
9e25dda
style(security): Rename TransferFileType enum values to PascalCase
bobtista Feb 17, 2026
237cf08
refactor(security): Use enum as array index for transfer file rules
bobtista Feb 17, 2026
ce419b8
style(security): Add consistent braces to switch cases in transfer va…
bobtista Feb 17, 2026
21f2594
refactor(security): Use TGA2Footer struct for TGA footer validation
bobtista Feb 17, 2026
94fc33c
fix(security): Scan entire INI file for null bytes instead of first 5…
bobtista Feb 17, 2026
08b64fd
style(security): Use TGA2_SIGNATURE macro instead of hardcoded string
bobtista Feb 17, 2026
71c7464
style(security): Rename lastDot to fileExt for clarity
bobtista Feb 19, 2026
63312bc
refactor(security): Add explicit TransferFileType_Invalid sentinel value
bobtista Feb 19, 2026
871abfb
style(security): Extract isTGA2 boolean for TGA footer validation rea…
bobtista Feb 19, 2026
44680b4
refactor(security): Move transfer file validation from FileSystem to …
bobtista Feb 24, 2026
f73fe40
nit: remove extra newline
bobtista Feb 24, 2026
bf1adbd
nit: remove redundant comment
bobtista Feb 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -52,6 +53,7 @@
#include "GameLogic/VictoryConditions.h"
#include "GameClient/DisconnectMenu.h"
#include "GameClient/InGameUI.h"
#include "TARGA.h"

static Bool hasValidTransferFileExtension(const AsciiString& filePath)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this is merged, can you make a follow refactor and merge this logic with the new stuff added below? It looks like this function can be greatly simplified.

See getTransferFileType

{
Expand Down Expand Up @@ -84,6 +86,125 @@ static Bool hasValidTransferFileExtension(const AsciiString& filePath)
return false;
}

enum TransferFileType
{
TransferFileType_Invalid = -1,
TransferFileType_Map,
TransferFileType_Ini,
TransferFileType_Str,
TransferFileType_Txt,
TransferFileType_Tga,
TransferFileType_Wak,
TransferFileType_Count
};

struct TransferFileRule
{
const char* ext;
UnsignedInt maxSize;
};

static const TransferFileRule transferFileRules[TransferFileType_Count] =
{
{ ".map", 5 * 1024 * 1024 },
{ ".ini", 512 * 1024 },
{ ".str", 512 * 1024 },
{ ".txt", 512 * 1024 },
{ ".tga", 2 * 1024 * 1024 },
{ ".wak", 512 * 1024 },
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was about to merge this change, but looking over these sizes I do wonder how they came to be. TGA size looks pretty big, INI maybe too small for a massive Mod Map?

And why is TXT there? Does the game actually transfer it?

Also, I think we need to make these values configurable in an INI file, so that Mods can customize file transfer limits.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to Kabuse, a 2 MB map is already insane, so 5 MB is on the safe side.
.str, .wak and .txt are always small, so 512 kb is more then enough.
Can't find the data on .tga and ini anymore. I think tga is fine, ini may need to go to 1 MB.

I would be against a configurable ini. This would defeat the purpose of the security measure as a malicious host could still transfer large files.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HLH - Black Dawn has a map.ini file slightly larger than 1024 KB fwiw.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be against a configurable ini. This would defeat the purpose of the security measure as a malicious host could still transfer large files.

Can you elaborate on this security threat? How would that work?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be against a configurable ini. This would defeat the purpose of the security measure as a malicious host could still transfer large files.

Can you elaborate on this security threat? How would that work?

Uncapped file size in general poses a security thread (denial of service), which could lead to overfilled memory and/or disk - causing game or computer crash.

The 2 minute timeout in the transfer window partially that infinite dos isn't possible - which alleviates some of the thread.

File size of transfers should be capped in the application for proper security. Allowing users to configure that as an option defeats the purpose of such a cap.

Make it 5 or 10 MB if you want to play it safe, but I think it is better to understand what a realistic filesize is for each type. I've got 500 maps installed on my PC, the largest files are .tga and .map, each still well under 1 MB, so we are looking at the extreme maps.

Based on @Caball009 feedback, ini may have to go to 2 MB, but the rest should be good.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowing users to configure that as an option defeats the purpose of such a cap.

It still is unclear how that would become an issue. INI file settings are local. If the local user does not change them, then there is no threat level for his local machine.

The INI configurability allows Mod Creators to customize the limits for when they ship larger content than the base game does.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point. Agreed, making this configurable in the ini options gives mod creators the freedom they need.

Copy link

@githubawn githubawn Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No legitimate mod maker is going to instruct a player in the match chat to go edit their .ini files; if we're priming users to make local security changes based on lobby chat, we have much bigger problems than file size limits, the threat is social not technical.
We have GenHub for larger mods and we shouldn't increase the configuration surface here. Adding a file limit user option implies we also have to add a configurable timeout limit. I think bobtista original approach is the correct one.

Copy link

@xezon xezon Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No legitimate mod maker is going to instruct a player in the match chat to go edit their .ini files

That is not the intent. The intent is that the Mod Creator can customize these limits that previously did not exist, if he knows that his Mod has different requirements than the retail game content.

};

static TransferFileType getTransferFileType(const char* extension)
{
for (Int i = 0; i < TransferFileType_Count; ++i)
{
if (stricmp(extension, transferFileRules[i].ext) == 0)
{
return static_cast<TransferFileType>(i);
}
}
return TransferFileType_Invalid;
}

static Bool hasValidTransferFileContent(const AsciiString& filePath, const UnsignedByte* data, UnsignedInt dataSize)
{
const char* fileExt = strrchr(filePath.str(), '.');
if (fileExt == nullptr)
{
DEBUG_LOG(("File '%s' has no extension for content validation.", filePath.str()));
return false;
}

const TransferFileType fileType = getTransferFileType(fileExt);
if (fileType == TransferFileType_Invalid)
{
DEBUG_LOG(("File '%s' has unrecognized extension '%s' for content validation.", filePath.str(), fileExt));
return false;
}

// Check size limit
const TransferFileRule& rule = transferFileRules[fileType];
if (dataSize > rule.maxSize)
{
DEBUG_LOG(("File '%s' exceeds maximum size (%u bytes, limit %u bytes).", filePath.str(), dataSize, rule.maxSize));
return false;
}

// Extension-specific content validation
switch (fileType)
{
case TransferFileType_Map:
{
if (dataSize < 4 || memcmp(data, "CkMp", 4) != 0)
{
DEBUG_LOG(("Map file '%s' has invalid magic bytes.", filePath.str()));
return false;
}
break;
}

case TransferFileType_Ini:
{
for (UnsignedInt i = 0; i < dataSize; ++i)
{
if (data[i] == 0)
{
DEBUG_LOG(("INI file '%s' contains null bytes (likely binary).", filePath.str()));
return false;
}
}
break;
}

case TransferFileType_Tga:
{
if (dataSize < sizeof(TGAHeader) + sizeof(TGA2Footer))
{
DEBUG_LOG(("TGA file '%s' is too small to be valid.", filePath.str()));
return false;
}
TGA2Footer footer;
memcpy(&footer, data + dataSize - sizeof(footer), sizeof(footer));
const Bool isTGA2 = memcmp(footer.Signature, TGA2_SIGNATURE, sizeof(footer.Signature)) == 0
&& footer.RsvdChar == '.'
&& footer.BZST == '\0';
if (!isTGA2)
{
DEBUG_LOG(("TGA file '%s' is missing TRUEVISION-XFILE footer signature.", filePath.str()));
return false;
}
break;
}

default:
{
break;
}
}

return true;
}

/**
* Le destructor.
*/
Expand Down Expand Up @@ -734,6 +855,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 (!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)
{
Expand Down
Loading