Under the Hood of WP Cerber 9.9
This release is mostly about the parts of WP Cerber you never notice until something goes wrong. We spent it hardening how settings are stored and recovered, how the Traffic Inspector reads obfuscated JavaScript, and how the plugin behaves on older hosting stacks. We also finished a long-running migration off the WordPress Settings API. Here is what changed under the hood and what it means for the sites you run.
Settings that survive a corrupted database
WP Cerber keeps its configuration in a single stored option, CERBER_CONFIG. Until now, if that value became corrupted and could no longer be unserialized, the plugin passed the broken result straight into array_merge(). On PHP 8 that raised a fatal TypeError at plugin load time. Because the failure happened during load, it took the whole site down, not just the plugin’s admin screens.
crb_get_settings() now validates what crb_unserialize() returns before it uses the value. When the stored data cannot be parsed into an array, the plugin falls back to its default settings instead of crashing. It records the failure as a persistent critical issue through CRB_Issues::add() under the code corrupted_settings. That issue clears itself automatically the next time you save the settings, which runs inside cerber_settings_update().
We were careful to separate two cases that look alike but are not. An empty or otherwise falsy stored value simply means the settings do not exist yet. That value is never unserialized, and the plugin falls back to defaults quietly without raising anything. Reporting it would be a false alarm. The corrupted_settings issue now fires only when a non-empty stored value fails to unserialize into an array, which is the exact condition behind the original production crash.
Two nearby branches in the same function got the same discipline. The Cloudflare add-on compatibility merge now requires an array before it will merge. The CERBER_WP_OPTIONS branch always returns an array when no specific setting is requested.
A self-healing backup on top of the guard
Falling back to defaults keeps a site running, but it also discards the administrator’s configuration. So we added a recovery layer above the guard. A new class, CRB_Settings_Backup, keeps a last-known-valid copy of CERBER_CONFIG in the plugin’s own key-value storage.
The backup is stored as raw JSON together with the ID of the user who wrote it, a timestamp, and the context that produced it. It is refreshed after a successful settings update, after a settings import, after a plugin upgrade, and by the daily maintenance task. Only a valid configuration and a known context code are allowed to replace an existing backup.
When the corruption detector finds an unreadable CERBER_CONFIG, recovery runs on its own. On a successful restore, the plugin registers a dismissible warning that explains what happened and asks you to review and save your settings. That notice resolves once you do. If no usable backup exists, or the restored value cannot be written, the plugin keeps its existing last-resort behavior and falls back to default settings. In that case it registers a critical issue instead of the warning.
Getting recovery right meant respecting WordPress caching. recover() deletes the corrupted CERBER_CONFIG option before it writes the restored value. Without that step, a stale option cache could make update_site_option() treat the value as unchanged and skip the database write. The backup itself is read and written with the object cache bypassed, so only the durable cerber_sets record is trusted. The set of accepted context codes is closed. The writer, sync(), and the payload validator both accept only the four declared codes, so every stored payload can pass the same validation that recovery later performs.
No more fatal errors on hosts without mysqlnd
WP Cerber reads from the database through mysqli. One retrieval path, CRB_Database::fetch_result_set(), used mysqli_result::fetch_all() to pull an entire result set in a single call. That method only exists when PHP’s mysqli extension is built on the mysqlnd driver. On hosts where mysqli is compiled against the older libmysqlclient library, the method is absent, and calling it is a fatal error.
The fix follows a pattern already used elsewhere in cerber-common.php. Before taking the fast path, the code now checks function_exists('mysqli_fetch_all'). When that function is unavailable, it reads the result set row by row with mysqli_result::fetch_array(), for both the MYSQLI_ASSOC and MYSQLI_NUM shapes. Row order, result structure, cleanup, and the existing Revalt return contract are all preserved. The fallback returns the same data as the fast path.
We also gave administrators a way to know when they are on the fallback. A new detector in CRB_Issue_Monitor raises an advisory issue, db_driver_no_mysqlnd, when the mysqli extension is loaded but mysqli_fetch_all() is missing. The notice appears in the System Readiness widget. It is informational. It confirms that the plugin keeps working through the fallback and recommends enabling mysqlnd for better compatibility and performance.
Sharper detection of obfuscated JavaScript
CRB_JS_Detector inspects request fields as part of the Traffic Inspector, which is enabled by default. It looks for JavaScript that has been obfuscated to hide primitives such as eval, script, and XMLHttpRequest. The design goal is high-confidence detection with a low false-positive rate. The detector only decodes input that is unambiguously an encoded string, and it leaves everything else alone. Several changes in this release both fixed a real bypass and widened what the detector can read.
A regression that let fully hex-escaped strings through
The release opened with a bug fix. The hex-escape heuristic normalized each matched string with trim( $m, "\\'\"" ). PHP’s trim() treats its second argument as a set of characters to strip, not as a literal prefix. The backslash in that set removed the leading backslash of the first \xNN escape along with the opening quote. What remained had an odd length of 2N + 1. An odd-length guard then decided the string was not cleanly hex-encoded and skipped decoding entirely. The practical result was that a string built solely from \xNN escapes slipped past inspection, and hex-escaped primitives like eval, script, and XMLHttpRequest went undetected on the default request-field path. The fix restores correct decoding of fully hex-escaped strings.
Wider escape and character-code coverage
We then extended what the detector understands. The escape heuristic used to recognize only \xNN. It now also handles \uNNNN and \u{...} escapes, including strings that mix these formats, while keeping the rule that only fully escaped strings are inspected. Decoding moved into a dedicated helper, cerber_decode_js_escapes(), which decodes ASCII code points and preserves everything else. If an internal PCRE error occurs, that helper returns the original input rather than an empty string, so a decoding failure cannot erase the value being checked.
The character-code heuristic changed too. It previously examined decoded output only for external URLs and IP addresses. It now also looks for execution and DOM primitives. It decodes numbers only from an explicit fromCharCode(...) construction rather than any numeric array it happens to encounter. Both heuristics now share one token-aware primitive pattern with identifier boundaries. That change stops ordinary words such as description and evaluation from matching on the eval or script substrings inside them.
Wrapped integer literals
fromCharCode is defined to apply ToUint16 to each argument. This means an ASCII code plus any multiple of 65536 produces the same character. An earlier version of the heuristic accepted only literals up to six digits, so a seven-digit wrapped value slipped past. For example, 1048677 decodes to code unit 101, the letter e. The detector now accepts unsigned decimal and hexadecimal literals up to Number.MAX_SAFE_INTEGER and reduces each one to its ToUint16 code unit independently of PHP’s integer size. It rejects leading-zero decimals, because they are ambiguous with legacy octal. It bounds the per-digit work so a very long literal on a public request cannot drive unbounded computation.
A follow-up closed a related gap. Returning an empty string on the first unsupported argument dropped the whole decoded call. An attacker could append a single out-of-range token to an otherwise detectable payload and suppress detection. One such token is 9007199254741024, which JavaScript maps to a space through ToUint16. Now a structurally valid but unsupported token is replaced with an underscore sentinel, and the rest of the call is still decoded. The underscore is a word character, so it also blocks a fabricated identifier boundary from forming next to a keyword.
Comment-separated arguments
The last class of bypass used JavaScript comments. The heuristic compacted its input by stripping whitespace, which left comments in place. A comment inserted between numeric arguments broke the numeric-list match, so a call like String.fromCharCode(101,/*x*/118,97,108,40,49,41,59) avoided detection. The detector now strips block and line comments from the fromCharCode argument list before it validates and decodes it, while preserving the contents of quoted strings. The capture also runs in dotall mode, so an argument list that spans several lines is read as a single call.
Retiring the WordPress Settings API
WP Cerber’s settings pages were built on the WordPress Settings API. Forms posted to /wp-admin/options.php, and the plugin leaned on register_setting(), add_settings_section(), add_settings_field(), settings_fields(), and do_settings_sections() to register and render them. That worked, but it tied the plugin’s admin UI to a procedural WordPress subsystem and its conventions. This release completes the move to a plugin-owned form engine, and we did it in stages rather than as a single change.
First we drew a boundary. Every direct call to the Settings API moved into one static class, CRB_Legacy_Settings_Manager. A scan of the codebase found that only five of the ten Settings API functions were used, across six call sites in two files. The other five were never called, so the boundary class deliberately has no methods for them. This step preserved behavior exactly. Option names, option groups, section and field IDs, callbacks, hook timing, and output were all unchanged.
Then we built the replacement. A new class, CRB_Settings_Renderer, renders sections and field rows directly from the declarative configuration returned by cerber_settings_config(). Settings forms now post back to the plugin’s own admin page instead of options.php. Submissions are processed by the existing pipeline on admin_init and then followed by a POST-redirect-GET back to the settings page. The rendered markup is byte-identical to what do_settings_sections() produced before, down to the headings, section blocks, and form-table rows.
Nonce verification changed with it. Instead of check_admin_referer() against a Settings API option group, the plugin now verifies its own cerber_nonce field. That field was already present in every settings form, including forms rendered by older versions. An invalid or expired nonce no longer stops the request with wp_die(). It queues an admin notice and returns you to the form.
Moving off options.php also removed a quiet piece of legacy behavior. The old path wrote a raw copy of each form group into a per-group option named cerber-{group}. Those copies were only ever read by the pre-9.3.4 migration and by uninstall cleanup, never at runtime. They are no longer written.
Cerber.Hub and managed sites
Two contexts kept the old path a while longer, because both depended on the Settings API wire format. One was Cerber.Hub remote rendering, where the protocol between a main site and its managed sites carried the option_page and _wpnonce fields. The other was the managed-site edit screen, which saved through a pre_update_option filter fired by options.php. We migrated each in turn.
The managed-site edit form now renders through CRB_Settings_Renderer and saves through a new function, nexus_save_client_data_form(). That function reproduces the old callback’s data handling exactly, including its group resolution, the strip_tags sanitation of owner details, and the unused-group cleanup. It keeps the same security boundary with an explicit nexus_is_main() and is_super_admin() guard. The Cerber.Hub client then dropped its Settings API emulation as well. After that, CRB_Legacy_Settings_Manager and several now-dead helpers were deleted.
Remote settings forms no longer emit the Settings API hidden fields such as option_page, action=update, _wpnonce, or _wp_http_referer. They now carry the same internal fields as local forms, plus the Nexus seal in the remote context. Nexus transport authentication is unchanged and remains the outer boundary for managed-site requests. nexus_is_valid_request(), nexus_is_granted(), and the cerber_nexus_seal round trip all work as before. A forwarded submission still verifies the plugin nonce before any settings processing runs. We also added an entry-point check. A submitted settings screen that does not resolve to a known screen now fails closed with a WP_Error before processing starts, and the failure message includes the submitted value for diagnostics.
A hard cutover, and one thing to know after upgrading
This was a deliberate hard cutover. We did not keep a transitional dual-format path, and no options.php processing remains. One edge case comes with that decision, and it is worth stating plainly. If a settings form was rendered by the previous version of WP Cerber and you submit it after upgrading, it may fail to save that one time. When that happens, reopen the settings page and submit it again. The freshly rendered form carries the new internal contract and saves normally. The same applies to remote settings forms on managed sites.
A naming cleanup rode along with the migration. The overloaded term group was renamed to settings_screen_id everywhere it identified a settings screen, including the constant, the hidden form field value, the pipeline function signatures, and the configuration keys. The remote error code was renamed from unknown_settings_group to unknown_settings_screen. One compatibility detail matters for add-on authors. The update_settings event payload still carries the old group key as an alias of settings_screen_id, because cerber_add_handler() is public and external handlers may read it.
UI Factory groundwork
Under the admin screens, WP Cerber renders HTML through an internal UI Factory rather than inline markup. This release added two structural building blocks. crb_ui_fragment() produces a mixed, ordered collection of child elements with no wrapping tag. crb_ui_element_set() does the same for a homogeneous collection with an enforced child type. We renamed the fluent CRB_UI_Fragment_Builder to CRB_UI_Content_Builder to avoid confusion with the new fragment node, and we kept a class alias so existing code keeps working.
A few call sites became simpler as a result. crb_ui_message_box() now accepts plain strings and numbers and wraps them in escaped paragraph elements, so callers no longer build those paragraphs by hand. The duplicated fetch-error notice on the dashboard and the traffic log screen moved into one shared helper. The environment diagnostics panel was rebuilt on the new nodes. In each case the rendered HTML is unchanged. This is groundwork for a renderer-neutral admin UI, and it is invisible on the page today.
After you upgrade
A short checklist for administrators:
- If a settings form does not save on your first submit right after the update, reopen the settings page and save it again.
- If WP Cerber ever falls back to default settings or restores them from backup, it will tell you through an admin issue. Review your settings and save them to clear the notice.
- If your host runs PHP without the mysqlnd driver, look for the advisory in the System Readiness widget. The plugin keeps working, and the notice explains the recommended change.
Have any questions?
If you have a question regarding WordPress security or WP Cerber, leave them in the comments section below or get them answered here: G2.COM/WPCerber.