mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 14:50:44 +00:00
ffbe48ad10
Re-add skipNonAlphabeticTransformer to both matcher chains so bypass attempts like "n.i.g.g.e.r" are detected.
309 lines
9.5 KiB
TypeScript
309 lines
9.5 KiB
TypeScript
import {
|
|
DataSet,
|
|
RegExpMatcher,
|
|
collapseDuplicatesTransformer,
|
|
englishDataset,
|
|
pattern,
|
|
resolveConfusablesTransformer,
|
|
resolveLeetSpeakTransformer,
|
|
skipNonAlphabeticTransformer,
|
|
toAsciiLowerCaseTransformer,
|
|
} from "obscenity";
|
|
import countries from "resources/countries.json";
|
|
|
|
import { Cosmetics } from "../core/CosmeticSchemas";
|
|
import { decodePatternData } from "../core/PatternDecoder";
|
|
import {
|
|
PlayerColor,
|
|
PlayerCosmeticRefs,
|
|
PlayerCosmetics,
|
|
PlayerPattern,
|
|
} from "../core/Schemas";
|
|
import { simpleHash } from "../core/Util";
|
|
|
|
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
|
|
|
|
export const shadowNames = [
|
|
"UnhuggedToday",
|
|
"DaddysLilChamp",
|
|
"BunnyKisses67",
|
|
"SnugglePuppy",
|
|
"CuddleMonster67",
|
|
"DaddysLilStar",
|
|
"SnuggleMuffin",
|
|
"PeesALittle",
|
|
"PleaseFullSendMe",
|
|
"NanasLilMan",
|
|
"NoAlliances",
|
|
"TryingTooHard67",
|
|
"MommysLilStinker",
|
|
"NeedHugs",
|
|
"MommysLilPeanut",
|
|
"IWillBetrayU",
|
|
"DaddysLilTater",
|
|
"PreciousBubbles",
|
|
"67 Cringelord",
|
|
"Peace And Love",
|
|
"AlmostPottyTrained",
|
|
];
|
|
|
|
function buildDataset(bannedWords: string[], dedup: boolean) {
|
|
const dataset = new DataSet<{ originalWord: string }>().addAll(
|
|
englishDataset,
|
|
);
|
|
for (const word of bannedWords) {
|
|
try {
|
|
const w = dedup ? word.toLowerCase().replace(/(.)\1+/g, "$1") : word;
|
|
dataset.addPhrase((phrase) =>
|
|
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${w}`),
|
|
);
|
|
} catch (e) {
|
|
console.error(`Invalid banned word pattern "${word}": ${e}`);
|
|
}
|
|
}
|
|
return dataset.build();
|
|
}
|
|
|
|
export function createMatcher(bannedWords: string[]): RegExpMatcher {
|
|
const baseTransformers = [
|
|
toAsciiLowerCaseTransformer(),
|
|
resolveConfusablesTransformer(),
|
|
resolveLeetSpeakTransformer(),
|
|
];
|
|
// substringMatcher: literal patterns, no collapse — catches "niggertesting" as a substring
|
|
// collapseMatcher: deduped patterns + collapse transformer — catches "niiiigger", "hiiitler"
|
|
// skipNonAlphabeticTransformer is applied last to catch punctuation-separated bypasses
|
|
// like "n.i.g.g.e.r".
|
|
const substringMatcher = new RegExpMatcher({
|
|
...buildDataset(bannedWords, false),
|
|
blacklistMatcherTransformers: [
|
|
...baseTransformers,
|
|
skipNonAlphabeticTransformer(),
|
|
],
|
|
});
|
|
const collapseMatcher = new RegExpMatcher({
|
|
...buildDataset(bannedWords, true),
|
|
blacklistMatcherTransformers: [
|
|
...baseTransformers,
|
|
collapseDuplicatesTransformer(),
|
|
skipNonAlphabeticTransformer(),
|
|
],
|
|
});
|
|
return {
|
|
hasMatch: (input: string) =>
|
|
input.toLowerCase().includes("kkk") ||
|
|
substringMatcher.hasMatch(input) ||
|
|
collapseMatcher.hasMatch(input),
|
|
getAllMatches: (input: string, sorted?: boolean) => [
|
|
...substringMatcher.getAllMatches(input, sorted),
|
|
...collapseMatcher.getAllMatches(input, sorted),
|
|
],
|
|
} as unknown as RegExpMatcher;
|
|
}
|
|
|
|
/**
|
|
* Sanitizes and censors profane usernames and clan tags separately.
|
|
* Profane username is overwritten, profane clan tag is removed.
|
|
*
|
|
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
|
|
* - full name including clan tag was overwritten in the past, if any part of name was bad
|
|
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
|
|
*
|
|
* Examples:
|
|
* - username="GoodName", clanTag=null -> { username: "GoodName", clanTag: null }
|
|
* - username="BadName", clanTag=null -> { username: "Censored", clanTag: null }
|
|
* - username="GoodName", clanTag="CLaN" -> { username: "GoodName", clanTag: "CLAN" }
|
|
* - username="GoodName", clanTag="BAD" -> { username: "GoodName", clanTag: null }
|
|
* - username="BadName", clanTag="BAD" -> { username: "Censored", clanTag: null }
|
|
*/
|
|
|
|
function censorWithMatcher(
|
|
username: string,
|
|
clanTag: string | null,
|
|
matcher: RegExpMatcher,
|
|
): { username: string; clanTag: string | null } {
|
|
const usernameIsProfane = matcher.hasMatch(username);
|
|
const clanTagIsProfane = clanTag
|
|
? matcher.hasMatch(clanTag) || clanTag.toLowerCase() === "ss"
|
|
: false;
|
|
// Catch slurs split across clan tag and username (e.g. clanTag="HIT", username="LER")
|
|
// by looking for a match that spans the clan/name boundary.
|
|
const combinedSlurAcrossBoundary = clanTag
|
|
? matcher.getAllMatches(clanTag + username).some(
|
|
(match) =>
|
|
// Match must start in the clan and extend into the name — otherwise
|
|
// it's already handled by the clan-only or name-only checks above.
|
|
match.startIndex < clanTag.length && match.endIndex >= clanTag.length,
|
|
)
|
|
: false;
|
|
|
|
const censoredName =
|
|
usernameIsProfane || combinedSlurAcrossBoundary
|
|
? shadowNames[simpleHash(username) % shadowNames.length]
|
|
: username;
|
|
|
|
const censoredClanTag =
|
|
clanTag && !clanTagIsProfane && !combinedSlurAcrossBoundary
|
|
? clanTag.toUpperCase()
|
|
: null;
|
|
|
|
return { username: censoredName, clanTag: censoredClanTag };
|
|
}
|
|
|
|
type CosmeticResult =
|
|
| { type: "allowed"; cosmetics: PlayerCosmetics }
|
|
| { type: "forbidden"; reason: string };
|
|
|
|
export interface PrivilegeChecker {
|
|
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
|
|
censor(
|
|
username: string,
|
|
clanTag: string | null,
|
|
): { username: string; clanTag: string | null };
|
|
}
|
|
|
|
export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
|
private matcher: RegExpMatcher;
|
|
|
|
constructor(
|
|
private cosmetics: Cosmetics,
|
|
private b64urlDecode: (base64: string) => Uint8Array,
|
|
bannedWords: string[],
|
|
) {
|
|
this.matcher = createMatcher(bannedWords);
|
|
}
|
|
|
|
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
|
|
const cosmetics: PlayerCosmetics = {};
|
|
if (refs.patternName) {
|
|
try {
|
|
cosmetics.pattern = this.isPatternAllowed(
|
|
flares,
|
|
refs.patternName,
|
|
refs.patternColorPaletteName ?? null,
|
|
);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { type: "forbidden", reason: "invalid pattern: " + message };
|
|
}
|
|
}
|
|
if (refs.color) {
|
|
try {
|
|
cosmetics.color = this.isColorAllowed(flares, refs.color);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { type: "forbidden", reason: "invalid color: " + message };
|
|
}
|
|
}
|
|
if (refs.flag) {
|
|
try {
|
|
cosmetics.flag = this.isFlagAllowed(flares, refs.flag);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return { type: "forbidden", reason: "invalid flag: " + message };
|
|
}
|
|
}
|
|
|
|
return { type: "allowed", cosmetics };
|
|
}
|
|
|
|
isPatternAllowed(
|
|
flares: readonly string[],
|
|
name: string,
|
|
colorPaletteName: string | null,
|
|
): PlayerPattern {
|
|
// Look for the pattern in the cosmetics.json config
|
|
const found = this.cosmetics.patterns[name];
|
|
if (!found) throw new Error(`Pattern ${name} not found`);
|
|
|
|
try {
|
|
decodePatternData(found.pattern, this.b64urlDecode);
|
|
} catch (e) {
|
|
throw new Error(`Invalid pattern ${name}`);
|
|
}
|
|
|
|
const colorPalette = this.cosmetics.colorPalettes?.[colorPaletteName ?? ""];
|
|
|
|
if (flares.includes("pattern:*")) {
|
|
return {
|
|
name: found.name,
|
|
patternData: found.pattern,
|
|
colorPalette,
|
|
} satisfies PlayerPattern;
|
|
}
|
|
|
|
const flareName =
|
|
`pattern:${found.name}` +
|
|
(colorPaletteName ? `:${colorPaletteName}` : "");
|
|
|
|
if (flares.includes(flareName)) {
|
|
// Player has a flare for this pattern
|
|
return {
|
|
name: found.name,
|
|
patternData: found.pattern,
|
|
colorPalette,
|
|
} satisfies PlayerPattern;
|
|
} else {
|
|
throw new Error(`No flares for pattern ${name}`);
|
|
}
|
|
}
|
|
|
|
isFlagAllowed(flares: string[], flagRef: string): string {
|
|
if (flagRef.startsWith("flag:")) {
|
|
const key = flagRef.slice("flag:".length);
|
|
const found = this.cosmetics.flags[key];
|
|
if (!found) throw new Error(`Flag ${key} not found`);
|
|
|
|
if (flares.includes("flag:*") || flares.includes(`flag:${found.name}`)) {
|
|
return found.url;
|
|
}
|
|
|
|
throw new Error(`No flares for flag ${key}`);
|
|
} else if (flagRef.startsWith("country:")) {
|
|
const code = flagRef.slice("country:".length);
|
|
if (!countryCodes.includes(code)) {
|
|
throw new Error(`invalid country code`);
|
|
}
|
|
return `/flags/${code}.svg`;
|
|
} else {
|
|
throw new Error(`invalid flag prefix`);
|
|
}
|
|
}
|
|
|
|
isColorAllowed(flares: string[], color: string): PlayerColor {
|
|
const allowedColors = flares
|
|
.filter((flare) => flare.startsWith("color:"))
|
|
.map((flare) => flare.split(":")[1]);
|
|
if (!allowedColors.includes(color)) {
|
|
throw new Error(`Color ${color} not allowed`);
|
|
}
|
|
return { color };
|
|
}
|
|
|
|
censor(
|
|
username: string,
|
|
clanTag: string | null,
|
|
): { username: string; clanTag: string | null } {
|
|
return censorWithMatcher(username, clanTag, this.matcher);
|
|
}
|
|
}
|
|
|
|
// Words the englishDataset misses or only catches as standalone tokens.
|
|
// These are always enforced even when the remote banned-words list is unavailable.
|
|
const baselineBannedWords = ["nigger", "nigga", "chink", "spic", "kike"];
|
|
|
|
const defaultMatcher = createMatcher(baselineBannedWords);
|
|
|
|
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
|
|
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
|
|
return { type: "allowed", cosmetics: {} };
|
|
}
|
|
|
|
censor(
|
|
username: string,
|
|
clanTag: string | null,
|
|
): { username: string; clanTag: string | null } {
|
|
return censorWithMatcher(username, clanTag, defaultMatcher);
|
|
}
|
|
}
|