wppack / sanitize-characters-plugin
Sanitize invisible and control characters (zero-width spaces, BOM, bidi controls, C0/C1 controls, soft hyphens) out of posts, ACF fields and search terms, plus a WP-CLI command to clean existing data.
Package info
github.com/wppack-io/sanitize-characters-plugin
Type:wordpress-plugin
pkg:composer/wppack/sanitize-characters-plugin
Requires
- php: ^8.2
- composer/installers: ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.68
- php-stubs/wp-cli-stubs: ^2.12
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.5
- roots/wordpress-core-installer: ^3.0
- roots/wordpress-full: >=6.7 <8.0
- szepeviktor/phpstan-wordpress: ^2.0
- wp-cli/config-command: ^2.3
- wp-cli/core-command: ^2.1
- wp-cli/db-command: ^2.1
- wp-cli/entity-command: ^2.8
- wp-cli/eval-command: ^2.2
- wp-cli/extension-command: ^2.1
- wp-cli/i18n-command: ^2.7
- wp-cli/language-command: ^2.0
- wp-cli/server-command: ^2.0
- wp-cli/wp-cli: ^2.12
- wp-phpunit/wp-phpunit: >=6.7 <8.0
- wp-theme/twentytwentyfive: *
- wppack/translations-installer: ^1.1
- yoast/phpunit-polyfills: ^4.0
README
Sanitizes invisible and control characters out of content on its way into WordPress and out of search terms on their way into a query. No settings screen, no options in the database.
The problem
Word processors and note-taking tools (Google Docs, Notion, …) inject
zero-width characters into text as soft line-break hints; PDFs and terminals
leak control characters and odd separators. Pasted into WordPress they
survive invisibly inside titles, body text and search boxes. Search then
fails on strings that look identical on screen: a title containing
Broadway (with a hidden U+200B) never matches a typed Broadway, and a
pasted search term with a trailing zero-width space matches nothing — in the
admin search, in ACF relationship fields and in the front-end ?s= search
alike. As a bonus, MySQL's utf8mb4 collations treat these characters as
ignorable, which makes the corruption invisible to SQL too.
What gets sanitized
| Characters | Treatment |
|---|---|
| C0 controls except TAB/LF/CR, DEL, C1 controls (U+0000–U+001F, U+007F–U+009F) | removed |
| U+00AD SOFT HYPHEN | removed |
| Zero-width: U+200B, U+2060, U+FEFF (BOM) | removed |
| Bidirectional controls: U+061C, U+200E/U+200F, U+202A–U+202E, U+2066–U+2069 (trojan-source vectors) | removed |
| U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR | rewritten to \n |
Deliberately kept: TAB/LF/CR, U+200C ZWNJ and U+200D ZWJ (they glue emoji sequences — 👨👩👧 — and shape several scripts), U+00A0 NBSP, variation selectors. Sites that rely on bidi control characters for mixed RTL text should not use this plugin.
What it does
On save
wp_insert_post_data— sanitizes post title, content and excerpt on every save path (editor, REST, XML-RPC, WP-CLI).acf/update_value— sanitizes ACF field values (arrays recursively). Registered unconditionally; without ACF it simply never fires.
On search
pre_get_posts— normalises thesquery var of everyWP_Query: admin searches, ACF relationship lookups and the front-end search.
Existing data
wp sanitize-characters— scanswp_posts(title, content, excerpt; revisions excluded) andwp_postmeta, dry-run by default; pass--applyto persist. Posts are updated in place:post_modifiedstays untouched and no revision is created. Serialized meta is unserialized and rebuilt; object values are skipped. Revisions are excluded on purpose — restoring one goes throughwp_insert_post_data, where the save filter sanitizes again.
WP-CLI usage
# List affected rows without changing anything (dry-run is the default).
$ wp sanitize-characters
post 42 [post] post_title,post_content: Example post title
meta 137 (post 42, subtitle)
Success: Would clean 2 posts and 1 meta rows (dry-run; pass --apply to persist).
# Persist the changes.
$ wp sanitize-characters --apply
Success: Cleaned 2 posts and 1 meta rows.
# Verify: a second run finds nothing.
$ wp sanitize-characters
Success: Would clean 0 posts and 0 meta rows (dry-run; pass --apply to persist).
One line is printed per affected row — post <ID> [<type>] <columns>: <title>
for posts, meta <meta_id> (post <ID>, <key>) for post meta. Meta rows whose
value is a serialized object are reported and skipped. wp help sanitize-characters shows the full synopsis. Running it periodically is
harmless: the save-time filters keep new content clean, so the command only
finds rows written while the plugin was inactive.
Background and related work
- WordPress core removes invisible characters from slugs only when building permalinks (Trac #47912, Trac #42951); titles, body text and search terms are untouched — the gap this plugin fills.
- invisible-characters.com — a reference list of the characters involved.
- Insert Special Characters serves the opposite purpose (inserting such characters deliberately).
Requirements
PHP 8.2+, WordPress 6.7+.
License
MIT