CFML profanity detection and filtering for CFML platforms, backed by AnyAscii so the matcher only ever sees ASCII-7. Handles Unicode pseudo-letters (ππ¦ππ), homoglyph attacks (Π°ΡΡΣΠ΅.com), Punycode (xn-- ACE labels), zero-width-space smuggling, emoji-as-letters, ASCII leetspeak with symbol/digit bypass (fuΒ’k, $hit, @ss, b!tch, c0ck, m0therf*cker), single-char wildcards (f*ck, c*nt), space-smuggled letters (f u c k), and phβf phonetic swaps (ph*ck). Ships a curated allowlist so cockpit, Scunthorpe, assassin, shiitake, Penistone, analyst, bass and friends stay clean.
GitHub: https://github.com/JamoCA/cf-badWords
BadWords.cfc- single-file engine, scriptedlib/anyascii-0.3.3.jar- AnyAscii Java library (ISC)config/- JSON dictionaries for en, es, fr, de, pt, it + areplacements.jsonpool of rated-G substitution wordsadmin/- optional browser-based CRUD for editing the dictionariestests/- 250+ assertions across 12 spec files, plain.cfmrunner, no TestBox/MXUnit dependencyexamples/demolitionMan.cfm- Verbal Morality Statute demo
- Adobe ColdFusion 2016+, Lucee 5+ and BoxLang (in Adobe/Lucee compatibility mode) compatible
- A way to load the bundled jar (see Install)
- Drop the project somewhere your CF server can read.
- Add the
lib/directory tothis.javaSettings.loadPathsin yourApplication.cfc:
variables.appDir = getDirectoryFromPath(getCurrentTemplatePath());
this.javaSettings = {
loadPaths: [ getCanonicalPath(variables.appDir & "./path/to/cf-badWords/lib") ],
reloadOnChange: false
};
this.mappings["/badwords"] = getCanonicalPath(variables.appDir & "./path/to/cf-badWords");- Instantiate via the mapping:
bw = new badwords.BadWords();bw = new badwords.BadWords(); // loads en.json by default
bw.isProfane("you asshole"); // YES
bw.isProfane("aircraft cockpit"); // NO (Scunthorpe-safe)
bw.censor("you asshole"); // "you *******"
bw.censor("you asshole", "x"); // "you xxxxxxx"
bw.censor("you asshole", chr(9608)); // "you βββββββ"
bw.censor("you asshole", "grawlix"); // "you !@$%&*!" (random each call, no consecutive repeats)
bw.substitute("you asshole"); // "you bunnies" (random rated-G of length 7)
hits = bw.scan("hey ππ¦ππer, also visit xn--nxasmq6b.com");
// Returns array of:
// { word, original, startPos, length, severity, categories, source }
// One entry per match. source is "dictionary" | "regex" | "punycode" | "leet".
bw.scan("fuΒ’k off"); // 1 hit via symbol fold (Β’ -> c)
bw.scan("f*ck off"); // 1 hit via wildcard match against "fuck"
bw.scan("f u c k off"); // 1 hit via space-smuggle coalesce
bw.scan("ph*ck off"); // 1 hit via ph -> f phonetic + wildcardPass a comma-list of ISO codes. Invalid codes are silently dropped; if nothing valid remains, en is loaded as fallback:
bw = new badwords.BadWords(languages = "en,fr,de");
bw.isProfane("quelle merde"); // YES (fr)
bw.isProfane("du arschloch"); // YES (de)
bw.isProfane("you asshole"); // YES (en)new badwords.BadWords(
languages = "en", // comma-list; invalid codes go to invalidLanguagesRequested
configPath = "", // dir holding *.json. Default: ./config/ relative to BadWords.cfc
jarPath = "", // path to anyascii-0.3.3.jar. Default: ./lib/ relative to BadWords.cfc
decodePunycode = true, // when true, xn-- ACE labels are decoded AND flagged in scan results
decodeLeet = true // when true, apply ASCII symbol fold + wildcard + space-coalesce + ph->f
);| Method | Purpose |
|---|---|
scan(text) |
The primitive. Returns array of match structs. |
isProfane(text) |
Boolean shortcut over scan(). |
censor(text, mask = "*") |
Mask matched letters. Non-letter chars (digits, colons) preserved. Pass "grawlix" (case-insensitive) as the mask for randomized !@$%&* output with no consecutive repeats. |
substitute(text, fallbackMask = "*", minLength = 3, maxLength = 12) |
Replace matched words with rated-G words of equal length. Falls back to mask when length is out of range or pool is empty. fallbackMask also accepts "grawlix". |
normalize(text) |
Run the full Punycode β AnyAscii β strip β lcase β collapse pipeline. Exposed for debugging and tests. |
tokenize(text) |
Tokenize already-normalized text. Returns array of [ token, startPos, length ]. |
addWord(word, severity, categories) |
Add to the in-memory word dictionary. categories accepts array of labels, comma-list, or integer bitmask. |
addRegex(pattern, severity, categories) |
Add to the in-memory regex list. Bad patterns are silently skipped. |
addAllow(word) |
Add to the in-memory allowlist (overrides dictionary AND regex). |
loadDictionary(languageCode) |
Merge another language pack at runtime. |
loadReplacements(filePath = "") |
Load the rated-G replacement pool. Defaults to configPath/replacements.json. |
getConfig() |
Returns the current state: loaded languages, invalid codes, severity/category constants, word counts. |
severityCode(label) / severityLabel(code) |
Translate between mild/moderate/severe/slur and 1/2/3/4. |
categoryMask(arrayOrListOrInt) / categoryLabels(int) |
Translate between category labels and the bitmask. |
Severity is an ordinal scale:
| Code | Label |
|---|---|
| 1 | mild |
| 2 | moderate |
| 3 | severe |
| 4 | slur |
Categories are a bitmask. Words can belong to multiple:
| Bit | Value | Label |
|---|---|---|
| 0 | 1 | sexual |
| 1 | 2 | insult |
| 2 | 4 | discriminatory |
| 3 | 8 | inappropriate |
| 4 | 16 | blasphemy |
| 5 | 32 | bodily |
| 6 | 64 | violence |
| 7 | 128 | substance |
The punycode category is reserved - the engine emits it when an xn-- ACE label is detected. Don't use it in your own dictionary entries.
{
"meta": {
"language": "en",
"version": "1.0.0",
"source": "...",
"lastUpdated": "2026-04-19"
},
"words": {
"asshole": { "s": 2, "c": 2 },
"motherfucker": { "s": 3, "c": 3 }
},
"regex": {
"\\bf+u+c+k+\\w*\\b": { "s": 2, "c": 1 }
},
"allow": [ "cockpit", "scunthorpe", "assassin" ]
}s is severity int, c is category bitmask. JSON files use \\ for backslashes (standard JSON); CFML source uses single backslashes when calling addRegex() (CFML strings don't escape backslashes).
A browser-based CRUD lives in admin/:
/admin/index.cfm- dashboard listing files, word counts, last edit/admin/editor.cfm?lang=en- Words / Regex / Allowlist tabs/admin/replacements.cfm- length-grouped editor for the rated-G substitution pool, validates entries againstscan()on save/admin/test.cfm- paste text, seenormalize(),scan(),censor(),substitute()results viacfdump
Saves are atomic (write to .tmp, rename) and create a .bak of the previous file.
Security warning: the admin pages ship with NO authentication. Anyone who can reach the URLs can edit your dictionaries and re-flatten your
.jsonfiles. Before deploying anywhere reachable, add HTTP basic auth, IP allowlisting, your existing application auth, or just don't deployadmin/to production at all. It's intended as a developer convenience.
Open /tests/runner.cfm in a browser. The runner discovers every .cfm in tests/specs/, runs them in alphabetical order, and renders an HTML report. HTTP status is 200 on all-green, 500 on any failure (curl-friendly for CI). A machine-readable summary is also written to tests/results/latest.json.
The assertion helper (tests/assert.cfc) provides isEqual, isNotEqual, isTrue, isFalse, includes, excludes, throwsError. Method names dodge CFML reserved-keyword collisions (equals, contains, throws won't compile as scripted method names).
- AnyAscii by Hunter WB (ISC) - the entire normalization layer.
- swearjar-node (MIT) - seed for the English word list, re-encoded to the bitmask format.
- Matt Gifford's swearjar CFC - API inspiration. No code copied.
MIT. See LICENSE.