Articles
cf-BadWords (AKA Verbal Morality Statute Enforcement, CFML Edition)
Profanity detection and filtering CFC for ColdFusion 2016+
Blurred words are profane — hover to reveal on desktop, or tap / focus to reveal on touch & keyboard.
I rewatched Demolition Man (1993) recently because my dotNet brother called me regarding a mandela effect that he was experiencing. In the movie we both watched, Pizza Hut was the winner of the "Franchise Wars", but we both recall that it was Taco Bell back when we originally watched it.
While the original US release used Taco Bell, the international cut dubbed in Pizza Hut because Taco Bell wasn't well-known overseas. (Director Marco Brambilla has confirmed it.)
The part that actually stuck with me was the "Verbal Morality Statute" scenes. Every time Stallone cursed, a wall-mounted speaker would beep and dispense a citation slip from a printer: "John Spartan, you are fined one credit for a violation of the verbal morality statute." It happened constantly. The whole gag worked because the enforcement AI had zero sense of context. (I created an interactive HTML page so you could experience what happens in the movie.)
This reminded me that I had a profanity filter sitting in my archive from 2003. It was a custom tag, <cf_BadWords source="..." wordlist="...">, that took a string and replaced inner letters of bad words with asterisks. It worked the way you'd expect a 2003 script to work. Substring matches. A hardcoded comma-list. No Unicode handling. The Scunthorpe problem in full effect: cockpit got censored to c*****t, assassin got mangled, etc.
I figured 23 years was long enough. So I rewrote it.
What changed
AnyAscii does the heavy lifting. It's a Java library that transliterates basically every Unicode codepoint to its closest ASCII-7 equivalent. ππ¦ππ (Mathematical Double-Struck) becomes fuck. Π°ss (Cyrillic Π°) becomes ass. π«πΊπ¨π° (regional-indicator emoji) becomes fuck. Fullwidth, Circled, Squared, Parenthesized, Fraktur, Script all collapse down to plain text. The matcher only ever sees lowercase ASCII, so the dictionary stays small and the regex stays simple.
Punycode gets unwrapped. If you scan a string containing xn--80ak6aa92e.com, the engine auto-decodes it via java.net.IDN.toUnicode() so the homoglyph attack inside isn't hidden by the ACE encoding. The unwrap itself is also surfaced as a scan result with source: "punycode", so callers can flag IDN usage even when nothing inside is profane.
Six languages out of the box. en, es, fr, de, pt, it. The French file includes Québécois sacres (tabarnak, câlice, ostie, crisse, sacrament). Each entry has a severity (mild/moderate/severe/slur) and a category bitmask (sexual, insult, discriminatory, inappropriate, blasphemy, bodily, violence, substance), so callers can build policies like "block severe + slur, allow mild".
An allowlist that actually solves Scunthorpe. Whole-word matching by default, with a curated list of words that contain bad substrings but aren't bad themselves: cockpit, Scunthorpe, Penistone, assassin, shiitake, class, bass, analyst, Massachusetts. The regex pass re-checks its matches against the allowlist before reporting, so \bcock\b style patterns can't sneak through cockpit. There are 24 strings in the false-positive regression corpus and they all stay clean.
Rated-G replacement mode. Instead of asterisks, substitute() swaps matched words for random rated-G words of the same length from a pool indexed by length. "fuck off" becomes "love off" or "hugs off" depending on the dice roll. "this is fucking bullshit" becomes "this is muffins snowfall". It's silly and I like it.
Grawlix masking. Pass the string grawlix as the mask and every letter in a matched word becomes a random pick from !@$%&*, with no consecutive repeats. Same length as the original, so line width is preserved. bw.censor("you asshole", "grawlix") → you !@$%&*!. Classic comic-strip censoring, which is what I always pictured when I originally wrote this thing in 2003.
Leet and symbol bypasses get unwound. Before the dictionary runs, the normalizer rewrites the old-school substitutions people still use to slip past filters. @ becomes a, $ becomes s, ! and 1 become i, 0 becomes o, 3 becomes e, ¢ and ( become c, # becomes h, 7 and + become t. A * inside a token is a wildcard that matches any letter, so f*ck, sh*t, and c*nt all resolve against the dictionary with no new rules. Three-or-more single letters separated by single spaces (f u c k) collapse back into one token. And ph at the start of a wildcard token gets a second-chance fold to f, so ph*ck finds its way home. The fold excludes ., |, /, \, -, and _ because those live in perfectly innocent text. Flip decodeLeet = false if you want strict matching on pre-cleaned input. The 14-string Scunthorpe regression corpus (cockpit, Penistone, Massachusetts, John Hancock, etc.) stays at zero hits with the leet pass enabled — that's the hard gate for shipping it.
Browser-based admin. /admin/ has a CRUD for editing the JSON dictionaries through forms with severity radios and category checkboxes. There's a length-grouped editor for the replacement pool that runs every entry through scan() on save so you can't accidentally seed it with a profane word. And a live scanner that cfdumps the output of every layer side by side, which has been the most useful thing for tuning false-positives.
The demo
examples/demolitionMan.cfm takes a sentence and prints the Verbal Morality Statute citation: "You are fined N credits for violation of the verbal morality statute." Followed by the censored version, the rated-G version, and a table of every flagged word. Basically the printer slip from the movie, as a CFML browser page.
Source Code
GitHub: https://github.com/JamoCA/cf-badWords
MIT licensed. 259 tests, all green, plain .cfm runner with no TestBox dependency. Single-file BadWords.cfc, drop-in for CF2016+, Lucee CFML or BoxLang. PRs welcome, especially better seed dictionaries from native speakers of the non-English languages. My passes on those were conservative and would benefit from corrections.
If you have a use for a 2003 custom-tag profanity filter rewritten as a 2026 CFC because of a Sylvester Stallone movie, this is the one.