Under The Hood 9.8.3
This release is a maintenance and hardening cycle rather than a feature launch. Most of the work happened where site owners rarely look: the data layer that reads your security logs, the code that composes notification emails, and the export paths that hand you a CSV of your activity records. None of it changes how the plugin looks, but all of it changes how reliably and safely the plugin behaves when a site is busy, when a log is large, or when an attacker probes an edge case we had not yet closed.
Below is the implementation-level story for administrators and developers who want to understand what actually changed.
Closing an email recipient injection vector
WP Cerber sends two kinds of transactional email that embed a person’s name: the two-factor authentication PIN message and the Activity Alert notification. Both built the recipient in the familiar RFC 5322 form of Name <email>, and both took that name directly from WordPress profile fields (user_firstname, user_lastname, and display_name) without escaping.
The problem is in how wp_mail() handles a string recipient. It splits the string on a literal comma and does not honor quoted-string boundaries. A user who placed the right characters into their own profile name could therefore inject an extra address into the recipient list of the 2FA PIN email produced by CRB_2FA::send_user_pin() and of the alert email produced through the user_list branch of cerber_get_email(). On a site where anyone can register and set a display name, that is a real path for quietly redirecting a copy of a security message.
We added a dedicated sanitizer, crb_sanitize_mail_display_name(), in cerber-common.php and applied it at both call sites. It strips exactly the characters that matter for this class of injection: the comma, the quote, the backslash, the angle brackets, and control characters. The visible name still renders normally for legitimate users, and the recipient list can no longer be steered by a crafted profile field.
Removing a stored XSS vector in the ownership scanner
The malware scanner reports when the ownership of an installed plugin changes on the WordPress.org repository. To do that it consumes ownership metadata supplied by that repository and, until now, wove some of that metadata into the admin notice as raw HTML, including hand-built anchor tags for owner profile links.
That repository is a trusted source and the likelihood of hostile metadata is low, but external data should never reach the admin interface as unescaped markup. Treating it as trusted is precisely the assumption we prefer not to depend on.
To close the vector properly rather than patch it locally, we introduced a new UI Factory element, formatted_text, constructed through the helper crb_ui_formatted_text(). It renders a plain-text template that carries numbered %N$s placeholders. Literal fragments of the template and scalar arguments are HTML-escaped, while any argument that is itself a UI element is rendered through the active renderer. Each dynamic value is therefore escaped in the context where it is actually emitted, which is the only reliable way to prevent output-context mistakes.
The ownership message in crb_check_ownership() now uses this element, and the owner profile links are built with crb_ui_link() instead of concatenated anchor strings, so profile URLs and display names are escaped as URLs and as text respectively. The translation string is unchanged, so localizations continue to work. The stored admin XSS risk is gone.
Fixing the “Any software error” filter in the Traffic Inspector
In the Traffic Inspector Log, the Advanced Search form lets you combine several conditions. One of them is the “Any software error” checkbox. When that checkbox was combined with other filters, requests that had a recorded PHP error could appear in the results even when they did not match the other conditions you had specified.
The root cause was operator precedence in the way the WHERE clause was assembled. The legacy code concatenated the filter fragments as strings, and the grouped error condition was not parenthesized, so an OR inside it could escape the surrounding AND logic. As part of moving the traffic query onto the query builder, the grouped error condition is now correctly parenthesized, and the combined filter behaves as the form implies. When you narrow a search, the results now honor every condition you set.
Matching search wildcards literally
The advanced traffic-log search previously let a % or _ typed into a search term act as a LIKE metacharacter, because the term was placed into the pattern without escaping. That is an accidental feature at best and an unnecessary load pattern at worst.
Every LIKE term now passes through CRB_Database::escape_like() before the surrounding wildcards are added, so % and _ are matched as the literal characters a user typed. Searches behave predictably, and the query cannot be nudged into scanning more than intended.
Correcting memory-limit handling for exports
Exporting a large Activity or Traffic log is memory-intensive, so WP Cerber raises the available memory before the operation. In some environments a numeric memory-limit value such as 512 was interpreted as bytes rather than megabytes. When that happened, the plugin failed to raise the limit as intended, and an export could stop earlier than expected.
The value is now interpreted with the correct unit, so the memory increase applies as designed and large exports run to completion in the environments that were previously affected.
Rebuilding log exports around streaming
The Activity and Traffic exports used to read the matching rows in chunks, re-running the same SELECT with a growing OFFSET for each chunk. On a large log that degrades into deep-offset scanning, where each chunk costs more than the last, and it holds a growing result in memory.
Both exports now read their rows in a single unbuffered pass through CRB_Database::query_stream(), wrapped in a generator that yields one row at a time. Memory stays flat regardless of how many records match, and the database does the work once instead of once per chunk. Because an unbuffered stream locks the connection while it is being consumed, the row total and the date range are resolved up front with separate buffered COUNT and MIN/MAX queries built from the same filters, before the stream is opened.
Two response headers were added to the shared file-download helper, crb_file_headers(). X-Accel-Buffering: no tells Nginx, when it fronts PHP-FPM, to forward each chunk immediately instead of buffering the whole export before sending it, which improves time-to-first-byte and avoids the proxy holding a large CSV in memory. The header is Nginx-specific and is ignored harmlessly by Apache with mod_php and by other proxies. Cache-Control: no-store prevents the browser or any intermediate proxy from caching a sensitive security-log export.
We also made stream cleanup deterministic. The export consumes the generator inside a try/finally block and releases the sole remaining handle in the finally, so the unbuffered result is freed and the connection is unlocked on every exit path: normal completion, an early stop, or an exception thrown while a row is being written. Previously an early break or an error before full consumption could leave the result open and the connection locked, which would cause the next query in that request to fail.
Reporting the exported date range
The CSV export header used to echo only the active filters. Both the Activity and Traffic exports now add two rows to the header showing the oldest and newest record timestamps covered by the exported data. Because the header is written before the first row, the range comes from a companion MIN/MAX query built from the same filtered query as the export, and it is omitted when no rows match. When you archive an export, the file now records the exact time window it represents.
Making export failures visible instead of silent
The old export path could fail quietly. If the database was unavailable or the row stream could not be opened, the previous code tended to produce an empty CSV with no explanation, which is the worst outcome for someone trying to pull records during an incident.
The read and export paths for both logs were reworked to return a Revalt result that carries either the data payload or a structured error, with distinct codes such as activity_export_query_build_failed, activity_export_db_unavailable, and activity_export_stream_failed. A lower-layer failure is chained into the result so the original root cause is preserved rather than discarded. Setup failures now terminate the export with wp_die() before a single CSV byte is sent, instead of streaming an empty file.
When an export does fail, an administrator holding the manage_options capability sees the chained root cause, for example the underlying database error, appended to the message. Users without that capability do not, so the actionable detail reaches the people who can act on it while internal database specifics stay away from everyone else. The message is escaped through crb_escape_html().
Consolidating the activity and traffic logs into domain classes
A large part of this cycle was structural. The Activity log and the Traffic log both had SQL strings and result handling scattered across dashboard and export code. We moved that logic into the CRB_Activity and CRB_Traffic_Log classes, so query building and row fetching now live behind clear methods such as fetch() and stream_log() rather than traveling through presentation code as raw SQL.
Both classes now build their queries with the DB Warp query builder obtained through warp_get_db() instead of concatenating WHERE, JOIN, and LIMIT fragments by hand. This matters beyond tidiness. Routing every user-supplied filter value through one escaping layer removes the earlier mix of manual escaping, $wpdb->prepare(), and hand-quoted concatenation, which is exactly the kind of inconsistency that hides injection bugs. When a degraded database layer cannot build a query, the code now fails safe with a no-match condition rather than running an unfiltered query.
These refactors are internal and do not change what the screens display, but they are the foundation that made the security and reliability fixes above small, local, and verifiable.
Two latent bugs found while extracting the alert code
Moving the admin alert dispatch out of CRB_Activity::log() into a dedicated CRB_Activity_Alerts class surfaced two pre-existing bugs that were caused by a positional array whose numeric indices had drifted out of alignment.
The first affected the dashboard links inside alert emails. The sparse-key, dense-value pairing shifted values, so a link parameter such as filter_ip could receive the beginning of an IP range instead of the intended address. Mapping the values onto named keys fixed the alignment, and the links in alert emails now point where they should.
The second affected search-string user matching. The code called wp_get_current_user(), which returns a WP_User object even for user ID 0, so the match used the wrong identity. It now looks up the event’s own user with crb_get_userdata() and guards against a missing user. Alerts that match on a user now match the correct one.
A quieter dashboard: operator precedence in the modification check
CRB_Activity::is_modified_since() compared a timestamp using $stamp < $status['data_modified'] ?? PHP_INT_MAX. Operator precedence binds that as (... < ...) ?? PHP_INT_MAX, which made the null-coalescing part dead code and raised an undefined-key notice whenever data_modified was absent. The coalesce is now parenthesized, so a missing modification stamp is treated as “modified,” matching the sibling is_modified() method, and the spurious notice is gone.
Centralizing database schema definitions
The last structural piece is schema maintenance. Install and upgrade code used inline CREATE TABLE SQL for the log tables, which meant the same table could be described in more than one place. Those declarations now come from a single source, CRB_Schema_Definitions, so install and upgrade behave from one canonical definition and CRB_Schema_Manager can detect schema drift in existing installations more consistently. Raw DROP INDEX SQL was likewise replaced with CRB_Schema_Manager::drop_index_if_exists().
We also made the decoding of stored request-field data more forgiving. Nullable legacy values, empty values, invalid JSON, and unsupported serialized payloads are now all handled the same safe way, by resolving to an empty array instead of letting a malformed record disrupt the read.
Why this release matters
There are no new buttons in 9.8.3. What there is instead is a data layer that fails safe instead of failing silent, notification email that cannot be steered by a crafted profile name, an admin notice that escapes external data properly, log exports that run in flat memory and tell you when something goes wrong, and search filters that do exactly what the form says. These are the kinds of changes that keep a security plugin trustworthy over the long term, and we would rather do this work openly than pretend the earlier code was already perfect.
Have any questions?
If you have a question regarding WordPress security or WP Cerber, ask it in the comments below or find answers on the community forum.
Spotted a bug or glitch?
We’d love to fix it! Share your bug discoveries with us here: Bug Report.




