Class ChatSanitizer
Text-hygiene routines applied to every piece of untrusted text that enters the chat pipeline — player input, and anything bridged in from Discord.
public static class ChatSanitizer
- Inheritance
-
ChatSanitizer
- Inherited Members
Remarks
Deliberately free of every project dependency (no logging, no Unity, no character types) so the whole file can be compiled and exercised on its own. This is security code that is cheap to test and expensive to get wrong, and the old implementation lived inside ChatHelper where nothing could reach it without dragging the server's whole object graph along.
Everything here is fail closed: a call that cannot complete its work returns the empty string rather than a partially-cleaned one. The previous behaviour on a regex timeout was the opposite — it returned the input truncated to 256 characters with every rich-text tag still in it, which handed an attacker a guaranteed bypass: make the pattern slow, and the sanitiser stops sanitising.
Fields
CombinedRichTextPattern
Combined regex pattern for all supported Unity Rich Text tags.
public static readonly string CombinedRichTextPattern
Field Value
MaxPasses
Maximum number of removal passes made before the input is rejected outright.
public const int MaxPasses = 8
Field Value
Remarks
A single pass is not enough. Removing a match can splice its neighbours together into
a brand new tag: <siz<size=500>e=500> contains exactly one match
(<size=500>), and deleting it leaves <size=500> behind — the
tag the filter was written to remove. Nesting that trick n deep needs
n passes, so the loop runs to a fixed point.
The cap exists because "run until nothing changes" on attacker-chosen input is an unbounded loop on the game loop. Reaching it means the input is still growing new tags after this many rounds, which no honest message does, so it is rejected.
Methods
SanitizeIncoming(string, int)
The complete inbound pipeline for untrusted chat text.
public static string SanitizeIncoming(string message, int maxLength)
Parameters
messagestringUntrusted text, from a player or from the Discord bridge.
maxLengthintHard character cap applied after cleaning. Values below one disable truncation.
Returns
Remarks
Order matters. Control characters go first, because they can be used to break a tag or
a control code apart so the later passes do not recognise it. Rich text goes next, and
the control-code prefix last, because removing a tag from the middle of
FISH<b>MMO_ is what would otherwise reassemble the prefix.
Truncation happens at the very end and is a hard cut, not a "…" — the ellipsis the old timeout path appended was three extra characters appearing on a length-limited field.
StripChatCodes(string)
Removes the FISHMMO_ control-code prefix wherever it appears, repeating until
the text stops changing.
public static string StripChatCodes(string message)
Parameters
messagestringUntrusted text.
Returns
- string
The text with no control-code prefix left in it, or empty on failure.
Remarks
The chat protocol carries a handful of in-band control codes — FISHMMO_TELL_RELAYED,
FISHMMO_TARGET_OFFLINE and friends — which the client matches on the first word
of a message and renders specially. They were never stripped from player input, so
/tell Bob FISHMMO_TELL_RELAYED you owe me gold arrived at Bob's client looking
exactly like a whisper Bob had sent to someone else. Removing the prefix is
enough to defuse every code at once, and the codes the server itself emits are
prepended after this runs.
Looped for the same reassembly reason as StripRichText(string):
FISHMMOFISHMMO__ collapses to FISHMMO_ in one pass.
StripControlCharacters(string)
Folds control characters and invisible formatting characters out of the text.
public static string StripControlCharacters(string message)
Parameters
messagestringUntrusted text.
Returns
- string
Single-line text containing no control or format characters.
Remarks
Two separate problems, both of which reached the chat log unfiltered:
-
Newlines. Every chat row is a single-line record; a message carrying
\nrenders as several lines in a wrappingLabel, which lets one player push everyone else's chat off the top of the window from inside a 128-character budget. Line breaks and tabs become a space so words either side of them stay separated. -
Unicode
Formatcharacters — U+202E RIGHT-TO-LEFT OVERRIDE above all. It reverses the display order of everything after it, so a message can be made to render as a different message entirely (including impersonating another player's name), and it leaks out of the message into the rest of the line. Zero-width spaces and joiners are in the same category and are what defeats a "no duplicate messages" filter.
Surrogate pairs are left alone — emoji are fine.
StripRichText(string)
Removes every Unity Rich Text tag from message, repeating until the
text stops changing.
public static string StripRichText(string message)
Parameters
messagestringUntrusted text.