Somebody beat my profanity filter with a 6

My own leetspeak table mapped 6 to g, so 6ex normalized to gex and walked straight onto the leaderboard.

I built a global leaderboard for the games on this site. Anyone can submit a name, so I wrote a profanity filter, and I was quite pleased with it. Then somebody got 6ex onto the board, and the reason it worked is the single most embarrassing kind of bug: my own cleverness did it.

The filter, version one

The obvious attack on a wordlist is leetspeak. Nobody types the banned word, they type s3x or f4ggot or $hit. So before comparing against the list, I normalized: lowercase everything, map each leetspeak character back to the letter it is imitating, throw away anything that is not a letter, and collapse repeats so seeeex becomes sex.

shared/leaderboard-core.tstypescript
const LEET: Record<string, string> = {
  "0": "o", "1": "i", "2": "z", "3": "e", "4": "a", "5": "s",
  "6": "g", "7": "t", "8": "b", "9": "g", "@": "a", "
quot;: "s", "!": "i", "+": "t", "|": "i", "€": "e", "£": "l", };

Read that map again and see if you can spot the hole before I tell you. I could not, and I wrote it.

Why 6ex sailed straight through

"6": "g". Six looks like a lowercase g. That is a perfectly reasonable mapping, and it is the mapping most leetspeak tables use.

Which means 6ex normalizes to gex. And gex is not on any banned list, because gex is not a word. The filter looked at it, correctly applied my rule, and let it through.

6exsubmittednormalize6 maps to ggexnot a banned wordacceptedonto the board
The normalizer did its job perfectly. Its job was the problem.

The fix: stop guessing what the character means

The second version does not try to decide what a digit stands for. It treats every digit and symbol as a wildcard that can stand for any letter, then slides that pattern along each banned word.

So 6ex against sex becomes: wildcard, then e, then x. Both real letters line up. Match.

Which immediately raises the obvious objection: if every digit matches every letter, does Player 123 now get banned for something?

shared/leaderboard-core.tstypescript
const w = word.length;
const needLetters = Math.max(1, Math.floor(w / 2));
// ... slide the window, then:
if (match && letters >= needLetters) return true;

That is the guard. A hit only counts if at least half the characters matched were genuine letters. 6ex against sex contributes two real letters out of a required one, so it is caught. Player 123 lines its digits up against sex and contributes zero real letters, so it is left alone.

Two nets, not one

The filter now runs both passes, and they fail in opposite directions on purpose:

Neither is good enough alone. Together they have held so far, which is the most confident sentence anybody should ever write about a profanity filter.

What I would tell past me