stable string, used for the log, the per-rule opt-out and the tests * label => human text for the panel * severity => 'critical' | 'high' | 'medium' * category => grouping for the panel * test => callable( array $request ): bool * * @return array> */ public static function rules(): array { /** * Filters the firewall rule set. * * Declared now so Phase 1.2 adds rules without changing this contract, and so a * site can drop a rule that misfires on its content without editing the plugin. * * @since 1.10.471 * @param array $rules Rule definitions keyed by id. */ $rules = (array) apply_filters( 'csdt_firewall_rules', self::default_rules() ); $disabled = get_option( self::OPTION_DISABLED_RULES, array() ); if ( ! is_array( $disabled ) || empty( $disabled ) ) { return $rules; } foreach ( $disabled as $id ) { unset( $rules[ (string) $id ] ); } return $rules; } /** * Whether a matched rule may refuse a request and feed auto-block. * * Every rule may, except one marked 'opt_in' that the administrator has not switched to * enforce: that one reports only. See OPTION_ENFORCED_OPT_IN. * * @param string $rule_id Rule id. * @return bool */ public static function rule_enforces( string $rule_id ): bool { $rules = self::default_rules(); if ( empty( $rules[ $rule_id ]['opt_in'] ) ) { return true; } $on = get_option( self::OPTION_ENFORCED_OPT_IN, array() ); return is_array( $on ) && in_array( $rule_id, array_map( 'strval', $on ), true ); } /** * The matched rules that may refuse the request. * * @param array $matched Matched rule ids. * @return array */ public static function enforcing_matches( array $matched ): array { return array_values( array_filter( $matched, static function ( $id ) { return self::rule_enforces( (string) $id ); } ) ); } // ── Rule set ───────────────────────────────────────────────────────── // // Split by CONFIDENCE, not by severity, because that is what decides whether a rule // can be trusted on a real site. The split is recorded on each rule as 'section', // used only to group the panel's table, never by evaluate(), so this is presentation // structure, not a second enforcement path: // // STRUCTURAL rules look at the request shape, the path, the user agent. There is // no legitimate reason for a visitor to ask for /.env or to fetch a .php file out // of the uploads folder, so these are high confidence and apply everywhere. // // CONTENT rules look inside parameter values for payload patterns. These are // inherently weaker, because a human may legitimately type the payload. This site // is a security blog: an anonymous visitor searching for "sql injection" or // "php://filter", or leaving a comment containing a script element, is a reader, not an // attacker. So content rules SKIP the parameters where prose is expected. // // BODY rules look at the raw request body rather than any named $_GET/$_POST key, // today that is only an XML entity declaration, which arrives on a POST that // WordPress core parses itself (xmlrpc.php reads php://input directly) rather than // via $_POST. A content rule can never see this payload no matter how it is // written, because there is no parameter for it to be a value of. // // That split is why the benign corpus in the tests is drawn from this site's own // traffic rather than from a generic list. /** * Parameters where a human legitimately types prose, code or a search term. * * Content rules do not inspect these. Getting this list wrong in the strict * direction means a security blog cannot be searched; wrong in the loose direction * means a payload delivered in ?s= is missed. The second is the better failure: the * search parameter is not where SQL injection lands, because WordPress prepares it. */ const CONTENT_PARAMS = array( 's', 'q', 'search', 'comment', 'comment_content', 'content', 'post_content', 'excerpt', 'description', 'message', 'body', 'title', ); /** * Nested prose leaves: a key ENDING in one of these is prose whatever its parent. * * Narrower than CONTENT_PARAMS on purpose. `attrs[title]` is a caption, `meta[q]` is * nothing in particular, and the old rule exempted any key whose last segment was on * the prose list, so `?meta[query]=` and `?x[s]=` walked past every content rule. * Only the names that mean "a paragraph a human wrote" keep that privilege. * * `prompt` is an AI prompt a site owner wrote, e.g. the SEO plugin's * `cs_seo_ai_options[prompt]` textarea. Without it, crlf_injection refused that form's * save with a 403 for any user the firewall inspects, because a prompt has line breaks. */ const CONTENT_LEAVES = array( 'comment', 'content', 'message', 'body', 'description', 'prompt' ); /** * Form fields that record WHO submitted an entry, as a user ID. * * Read by forged_submitter. A field here is one a form plugin fills with the current * user's ID and a guest's form leaves at 0, so a positive value on a request with no * login cookie is a forgery. Exact names only: a generic `user_id` is also how * directory and "contact this author" forms name the person being CONTACTED, and an * anonymous visitor sends that legitimately. */ const SUBMITTER_ID_KEYS = array( 'frm_user_id' ); /** * Field names that mean "the WordPress role of the account being created or changed". * * Read by privileged_role. Leaf names, so `fields[role]` and `user[user_role]` count. */ const ROLE_KEYS = array( 'role', 'user_role', 'wp_role', 'roles', 'new_role', 'default_role' ); /** * Field names that name the ACCOUNT on a reset or one-time-code request. * * Read by reset_identity_mismatch. Leaf names, so `data[username]` counts. */ const RESET_USER_KEYS = array( 'user_login', 'username', 'user_name', 'login', 'user', 'log' ); /** Field names that carry the address a reset link or code is sent to. Leaf names. */ const RESET_EMAIL_KEYS = array( 'email', 'user_email', 'email_address', 'mail', 'useremail' ); /** Bytes read from the start of each uploaded file by upload_exec_content. */ const UPLOAD_HEAD_BYTES = 512; /** Most uploaded files whose first bytes are read on one request. */ const UPLOAD_HEAD_FILES = 32; /** Most base64 characters decoded from one data: URI, so a large one costs a bounded decode. */ const DATA_URI_B64_CHARS = 1024; /** * Deepest nesting flatten() walks one level at a time before folding the rest into one value. * * Seven segments: a batch sub-request's `requests[0][body][tax_query][0][terms][0]` is * exactly this deep. Below it, the subtree is not dropped (that was a hiding place: a * payload eight levels down was never inspected at all) but joined into one value under * the deepest key. */ const FLATTEN_DEPTH = 6; /** Most leaves folded into the overflow value of one too-deep subtree. */ const FLATTEN_OVERFLOW_LEAVES = 256; /** * Request headers whose values the content rules see. * * An allowlist, not every header. These are the ones a PHP application reads back and * stores, logs or echoes: the forwarding headers a proxy-trusting plugin uses as the * client address, the URL-override headers some frameworks honour, and the two Accept * headers analytics plugins record. Keyed `header:` so the prose exemption can * never apply to one, no header carries a paragraph a reader typed. */ const INSPECTED_HEADERS = array( 'referer' => 'HTTP_REFERER', 'x-forwarded-for' => 'HTTP_X_FORWARDED_FOR', 'x-forwarded-host' => 'HTTP_X_FORWARDED_HOST', 'x-original-url' => 'HTTP_X_ORIGINAL_URL', 'x-rewrite-url' => 'HTTP_X_REWRITE_URL', 'client-ip' => 'HTTP_CLIENT_IP', 'x-real-ip' => 'HTTP_X_REAL_IP', 'accept-language' => 'HTTP_ACCEPT_LANGUAGE', 'accept' => 'HTTP_ACCEPT', ); /** Extensions that must never be requested from inside the uploads folder. */ const UPLOAD_EXEC_EXT = array( 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'pht', 'phar', 'phps' ); /** * The URL path of this site's uploads folder, lowercased, with a trailing slash. * * From wp_upload_dir(), so a relocated content or uploads folder is matched where it * really is. A multisite subsite's "/sites/N" suffix is dropped, so every site's uploads * on the network stay covered. '' when it cannot be determined, which matches nothing. * * @return string */ public static function uploads_url_path(): string { static $path = null; if ( null === $path ) { $path = ''; if ( function_exists( 'wp_upload_dir' ) ) { $dir = wp_upload_dir( null, false ); $url = is_array( $dir ) ? (string) ( $dir['baseurl'] ?? '' ) : ''; $part = (string) wp_parse_url( $url, PHP_URL_PATH ); $part = (string) preg_replace( '#/sites/\d+/?$#', '', rtrim( $part, '/' ) ); $path = '' !== $part ? strtolower( $part ) . '/' : ''; } } return $path; } /** * Whether a parameter is one where prose is expected. * * Exempt when the FULL flattened key is on the prose list, when it is a prose key's * REST sub-field (`content[raw]`, `title[rendered]`, the shape wp/v2 accepts), or when * it ends in one of CONTENT_LEAVES. The previous rule matched the last segment against * the whole list, which made `?meta[query]=` and `?attrs[title]=` invisible to every * content rule; a payload does not become prose by being nested under a prose name. */ public static function is_content_param( string $key ): bool { $key = strtolower( $key ); if ( in_array( $key, self::CONTENT_PARAMS, true ) ) { return true; } if ( false === strpos( $key, '[' ) ) { return false; } // A REST batch sub-request's body is a request, not a paragraph. Its fields keep // their own exemptions (`requests[0][body][content]` is still a post body), but a // body sent as one string is exactly where a wp2shell-style payload rides past. if ( preg_match( '/^requests\[\d{1,4}\]\[(?:body|args)\]$/', $key ) ) { return false; } if ( preg_match( '/^([a-z_]{1,32})\[(?:raw|rendered)\]$/', $key, $m ) && in_array( $m[1], self::CONTENT_PARAMS, true ) ) { return true; } return (bool) preg_match( '/\[(?:' . implode( '|', self::CONTENT_LEAVES ) . ')\]$/', $key ); } /** * Values worth inspecting with a content rule: everything except prose fields. * * Returns the NORMALISED copy of each value (see normalise_value()), which is what the * pattern rules must match against: comments stripped, entities decoded, whitespace * collapsed. Pass $raw for the rule that needs the bytes as sent, crlf_injection looks * for a character that normalisation folds away. * * @param array $request Normalised request. * @param bool $raw True for the decoded-but-not-normalised value. * @return array */ public static function inspectable_values( array $request, bool $raw = false ): array { $out = array(); foreach ( (array) ( $request['params'] ?? array() ) as $p ) { $key = (string) ( $p['key'] ?? '' ); if ( self::is_content_param( $key ) ) { continue; } $val = self::value_of( $p, $raw ); if ( '' !== $val ) { $out[] = $val; } } return $out; } /** * Every value, prose included, for the few rules whose pattern no prose ever contains. * * @param array $request Normalised request. * @return array Normalised values. */ public static function all_values( array $request ): array { $out = array(); foreach ( (array) ( $request['params'] ?? array() ) as $p ) { $val = self::value_of( $p, false ); if ( '' !== $val ) { $out[] = $val; } } return $out; } /** * One parameter's value, normalised or raw, tolerant of a hand-built param with no 'norm'. * * @param array $p One flattened parameter. * @param bool $raw True for the decoded value, false for the normalised one. */ private static function value_of( array $p, bool $raw ): string { if ( $raw || ! isset( $p['norm'] ) ) { return (string) ( $p['value'] ?? '' ); } return (string) $p['norm']; } /** * The normalised form of a value: what the content rules match against. * * Every proven bypass of the pattern rules was an ENCODING of a payload the rule already * recognised, not a new payload: a MySQL versioned comment wrapping UNION is UNION to * MySQL, an entity-encoded script element is a script element once a template echoes it, * `javascript` followed by an entity-encoded colon is `javascript:` to a browser, fifty * spaces are one to a parser. Matching every rule against a copy that has been folded the * way the TARGET folds it means each rule is written once, against the plain form, rather * than once per spelling. * * Applied in order, and the order matters: * 1. HTML entities, including numeric and hex forms, after percent-decoding has * already happened in decode_twice(). * 2. SQL block comments. The slash-star-bang form is MySQL's "execute this anyway" * comment, so its CONTENTS are kept; a plain block comment becomes one space, * because to MySQL that is what it is (UNION, empty comment, SELECT parses as two * words). * 3. Runs of whitespace and `+` become one space, so a bound like `{0,40}` in a rule * cannot be walked past by padding. * * Comment removal is a linear strpos() walk, not a regex (see strip_sql_comments()), * so a megabyte of unclosed comment openers costs one scan and an early return; * tests/firewall-bypass-test.php times exactly that input. * * Never lower-cased here: object_injection relies on case, and the other rules fold it * themselves. * * @since 1.10.845 * @param string $value Decoded value. * @return string Normalised copy. */ public static function normalise_value( string $value ): string { if ( '' === $value ) { return ''; } if ( false !== strpos( $value, '&' ) ) { $value = html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } if ( false !== strpos( $value, '/*' ) && false !== strpos( $value, '*/' ) ) { $value = self::strip_sql_comments( $value ); } return (string) preg_replace( '/[\s+]+/', ' ', $value ); } /** * Removes SQL block comments the way MySQL reads them, in linear time. * * Not a regex, and the reason is measured rather than argued: a bounded pattern (a * single-alternation group repeated at most 512 times between the opener and the * closer) is linear per opener, but a megabyte of opener-x-opener-x holds 350,000 * openers, each scanning and backtracking its 512-byte window, and that took 650 ms * on the machine that wrote this. Here every closer position is found once * with strpos(), every opener once, and one forward-only pointer pairs them, so the * same input costs two scans and an early return. * * Semantics follow MySQL: a comment runs from an opener to the FIRST closer after it, * there is no nesting, and the `/*!` form executes its contents. A comment longer than * MAX_SQL_COMMENT bytes is left alone; a payload does not need a longer one, and the * bound keeps the replacement from swallowing an entire value on a stray opener. * * @since 1.10.845 * @param string $value Value containing at least one opener and one closer. * @return string Value with comments replaced by a space, versioned contents kept. */ public static function strip_sql_comments( string $value ): string { $closers = array(); $off = 0; while ( false !== ( $p = strpos( $value, '*/', $off ) ) ) { $closers[] = $p; $off = $p + 2; } $n_closers = count( $closers ); $ci = 0; $out = ''; $copied = 0; $off = 0; while ( false !== ( $open = strpos( $value, '/*', $off ) ) ) { while ( $ci < $n_closers && $closers[ $ci ] < $open + 2 ) { ++$ci; } if ( $ci >= $n_closers ) { break; // No closer after this opener, so no comment starts here or later. } $close = $closers[ $ci ]; if ( $close - $open > self::MAX_SQL_COMMENT ) { $off = $open + 2; continue; } $out .= substr( $value, $copied, $open - $copied ) . ' '; $inner = substr( $value, $open + 2, $close - $open - 2 ); if ( '' !== $inner && '!' === $inner[0] ) { $out .= (string) preg_replace( '/^!\d{0,6}/', '', $inner ) . ' '; } $copied = $close + 2; $off = $copied; } return $out . substr( $value, $copied ); } /** Longest SQL block comment normalise_value() will remove, in bytes. */ const MAX_SQL_COMMENT = 512; /** * The rules this plugin ships. * * Registered through the `csdt_firewall_rules` filter like any other rule, so the * built-ins have no privileged status and a site can drop any of them. * * @return array> */ public static function default_rules(): array { // The firewall evaluates on plugins_loaded, before init, and a translation call that // early makes WordPress 6.7+ log _load_textdomain_just_in_time on every request. The // English label stands in until translations may be loaded; the admin screen, which // renders after init, sees the translated one. $i18n = function_exists( 'did_action' ) && did_action( 'init' ) > 0; // function_exists: the rule tests load this file without WordPress. return array( // ── Structural: the request shape itself ───────────────────── 'traversal_path' => array( 'label' => ( $i18n ? __( 'Directory traversal in the request path', 'cloudscale-cyber-devtools' ) : 'Directory traversal in the request path' ), 'severity' => 'critical', 'category' => 'traversal', 'section' => 'structural', 'test' => static function ( array $r ): bool { // The path is already decoded twice, so ../ covers %2e%2e%2f and // %252e%252e%252f. Backslash form included for Windows targets. // // 'path' is query-free (see normalise_request). php_in_uploads and // artifact_probe additionally strtok() the '?' off themselves, which is // now redundant but kept: they were written that way because path DID // carry the query string, and this rule was the one that did not defend // itself. That asymmetry is what let /?s=../../etc/passwd match. $path = (string) ( $r['path'] ?? '' ); return (bool) preg_match( '#\.\.[/\\\\]#', $path ); }, ), 'php_in_uploads' => array( 'label' => ( $i18n ? __( 'Executable requested from the uploads folder', 'cloudscale-cyber-devtools' ) : 'Executable requested from the uploads folder' ), 'severity' => 'critical', 'category' => 'uploads', 'section' => 'structural', 'test' => static function ( array $r ): bool { $path = strtolower( (string) ( $r['path'] ?? '' ) ); $uploads = self::uploads_url_path(); $at = '' === $uploads ? false : strpos( $path, $uploads ); if ( false === $at ) { return false; } // ANYWHERE after the uploads folder, not the final extension. Reading // pathinfo()'s extension saw ".jpg" on shell.php.jpg, which Apache's // multi-extension handling and a misconfigured nginx location both run // as PHP, and saw "" on shell.php/. (PATH_INFO appended). The null-byte // case, shell.php\0.jpg, is the same trick one layer down and is now // covered by the boundary class rather than a strtok(). return (bool) preg_match( '/\.(?:' . implode( '|', self::UPLOAD_EXEC_EXT ) . ')(?:[^a-z0-9]|$)/', substr( $path, $at ) ); }, ), 'upload_exec_file' => array( 'label' => ( $i18n ? __( 'Uploaded file named as executable code', 'cloudscale-cyber-devtools' ) : 'Uploaded file named as executable code' ), 'explain' => ( $i18n ? __( 'A file upload whose name carries .php, .phtml or .phar anywhere, including shell.php.jpg, is a web shell being dropped, never a photo.', 'cloudscale-cyber-devtools' ) : 'A file upload whose name carries .php, .phtml or .phar anywhere, including shell.php.jpg, is a web shell being dropped, never a photo.' ), 'severity' => 'critical', 'category' => 'uploads', 'section' => 'structural', 'test' => static function ( array $r ): bool { // Every filename in $_FILES, however nested (name[] arrays and // multi-field forms). Matched anywhere in the name, so the double // extension that beats an "ends with .php" check is caught, and .htaccess // too, because uploading one is how the jpg NEXT to it becomes runnable. $re = '/\.(?:' . implode( '|', self::UPLOAD_EXEC_EXT ) . ')(?:[^a-z0-9]|$)|(?:^|\/)\.htaccess$/i'; foreach ( (array) ( $r['files'] ?? array() ) as $name ) { if ( preg_match( $re, (string) $name ) ) { return true; } } return false; }, ), 'upload_exec_content' => array( 'label' => ( $i18n ? __( 'Anonymous upload whose content is PHP code', 'cloudscale-cyber-devtools' ) : 'Anonymous upload whose content is PHP code' ), 'explain' => ( $i18n ? __( 'A logged-out upload, or a base64 data: URI in a form field, whose first bytes are PHP. Elementor Pro (CVE-2026-32475), Super Forms (CVE-2026-14894) and Wholesale Lead Capture (CVE-2026-27540) were exploited this way with innocent file names.', 'cloudscale-cyber-devtools' ) : 'A logged-out upload, or a base64 data: URI in a form field, whose first bytes are PHP. Elementor Pro (CVE-2026-32475), Super Forms (CVE-2026-14894) and Wholesale Lead Capture (CVE-2026-27540) were exploited this way with innocent file names.' ), 'severity' => 'critical', 'category' => 'uploads', 'section' => 'structural', 'opt_in' => true, 'test' => static function ( array $r ): bool { // The CONTENT, where upload_exec_file reads the NAME. Every one of the // three CVEs above sent a name the plugin accepted (an image extension, an // empty first file that moved the validator off the real one, a data: URI // with no name at all) and a body that was PHP. A name is the attacker's // choice; for the file to run, its first bytes are not. // // Anonymous only. A signed-in author is exempt from the whole engine // already, and a signed-in subscriber attaching a code sample to a support // ticket is the one honest way a PHP file reaches a form. if ( self::has_login_cookie_param( $r ) ) { return false; } foreach ( (array) ( $r['file_heads'] ?? array() ) as $head ) { if ( self::looks_like_php_payload( (string) $head ) ) { return true; } } foreach ( (array) ( $r['params'] ?? array() ) as $p ) { if ( 0 === strpos( (string) ( $p['key'] ?? '' ), 'header:' ) ) { continue; } // RAW value: normalisation turns `+`, a base64 character, into a space. $v = (string) ( $p['value'] ?? '' ); if ( false !== stripos( $v, 'base64,' ) && self::data_uri_carries_php( $v ) ) { return true; } } return false; }, ), 'rest_batch_anonymous' => array( 'label' => ( $i18n ? __( 'Anonymous REST batch request', 'cloudscale-cyber-devtools' ) : 'Anonymous REST batch request' ), 'explain' => ( $i18n ? __( 'A logged-out call to /wp-json/batch/v1. Only the block editor uses batch, signed in. wp2shell (CVE-2026-63030 with CVE-2026-60137, on the CISA KEV list) hid SQL injection inside batched sub-requests.', 'cloudscale-cyber-devtools' ) : 'A logged-out call to /wp-json/batch/v1. Only the block editor uses batch, signed in. wp2shell (CVE-2026-63030 with CVE-2026-60137, on the CISA KEV list) hid SQL injection inside batched sub-requests.' ), 'severity' => 'high', 'category' => 'rest', 'section' => 'structural', 'opt_in' => true, 'test' => static function ( array $r ): bool { // The endpoint, not the payload. batch/v1 replays each sub-request through // the REST server, so a filter or an injection that a direct call would meet // once arrives here wrapped in JSON the vulnerable route never expected. Core // uses it only from the editor (widgets, menus), always signed in, so a // request with no login cookie has no reason to be here. sql_injection still // reads the sub-requests themselves (see is_content_param() and flatten()), // so the payload is caught even where this rule is switched off. // // Presence of the cookie, the same test as CSDT_Request::has_login_cookie(). // A forged cookie walks past this rule, and is then inspected by every other // rule, because is_exempt() validates it and finds nobody. if ( self::has_login_cookie_param( $r ) ) { return false; } return 1 === preg_match( '#^/batch/v1/?$#i', self::rest_route_of( $r ) ); }, ), 'artifact_probe' => array( 'label' => ( $i18n ? __( 'Probing for a configuration or backup file', 'cloudscale-cyber-devtools' ) : 'Probing for a configuration or backup file' ), 'severity' => 'high', 'category' => 'probe', 'section' => 'structural', 'test' => static function ( array $r ): bool { $path = strtolower( (string) strtok( (string) ( $r['path'] ?? '' ), "?\0" ) ); // Two patterns, not one. The single end-anchored pattern this replaced // required a directory probe to be the LAST thing in the path, so /.git/ // matched and /.git/config, the request an attacker actually sends, // did not. Found by the Phase 1.3 corpus. // // Directories: matched anywhere, but the name must END there, so a post // at /blog/.git-tips/ is not a match. if ( preg_match( '#(?:^|/)\.(?:git|svn|hg|bzr|aws|ssh|vscode|idea)(?:/|$)#', $path ) ) { return true; } // Files: anchored on the whole path, so /2026/08/dotenv-tips/ is not a // match while /.env and /wp-config.php.bak are. // // wp-config.php~ is an editor's backup and needs no dot after php; // wp-config.txt is what a "rename it so it stops executing" migration // leaves behind. Both serve the database password as plain text. if ( preg_match( '#(?:^|/)(?:\.env(?:\.[a-z]+)?' . '|wp-config\.php\.[a-z~]+|wp-config\.php~|wp-config\.(?:bak|txt|orig)|configuration\.php\.bak' . '|[a-z0-9._-]+\.(?:sql|sql\.gz|sqlite|bak|old|orig|swp|tar|tar\.gz|tgz|rar|7z)' . '|composer\.(?:json|lock)|package-lock\.json|yarn\.lock' . '|\.htpasswd|id_rsa|id_dsa|id_ecdsa|id_ed25519|\.ds_store)$#', $path ) ) { return true; } // An archive AT THE ROOT is a site backup left where the scanner expects // it (/backup.zip, /site.tar.gz). Only the root: a plugin serving a zip // out of uploads is ordinary. if ( preg_match( '#^/[a-z0-9._-]+\.(?:zip|tar\.gz|tgz)$#', $path ) ) { return true; } // The installer. An installed site never serves either of these to a // visitor, and a request for them is a check for a site that was never // finished installing, which is a site anyone can take over. return (bool) preg_match( '#(?:^|/)wp-admin/(?:install|setup-config)\.php$#', $path ); }, ), 'scanner_ua' => array( 'label' => ( $i18n ? __( 'Known scanning tool user agent', 'cloudscale-cyber-devtools' ) : 'Known scanning tool user agent' ), 'severity' => 'medium', 'category' => 'scanner', 'section' => 'structural', 'test' => static function ( array $r ): bool { $ua = strtolower( (string) ( $r['ua'] ?? '' ) ); if ( '' === $ua ) { return false; // An absent UA is rude, not hostile. } // Deliberately narrow: tools that announce themselves. Fingerprinting // by behaviour belongs in rate limiting, not here, and a broad list // would eventually catch a legitimate crawler. return (bool) preg_match( '/\b(?:sqlmap|nikto|nessus|acunetix|nmap|masscan|zgrab|wpscan|dirbuster|gobuster' . '|feroxbuster|hydra|metasploit|havij|joomscan|whatweb|arachni|w3af|openvas)\b/', $ua ); }, ), 'crlf_injection' => array( 'label' => ( $i18n ? __( 'Line break in a URL path or non-prose parameter', 'cloudscale-cyber-devtools' ) : 'Line break in a URL path or non-prose parameter' ), 'severity' => 'high', 'category' => 'evasion', 'section' => 'structural', 'test' => static function ( array $r ): bool { /* * A carriage return or line feed is the separator between HTTP headers. Where a value * reaches a header, a redirect target, an address in a generated mail, a cookie, one * of these ends the current header and starts a new one the attacker chose. That is * response splitting, and its cheaper cousin is smuggling a header into an email. * * HONEST ABOUT THE VALUE. PHP's header() has refused embedded newlines since 5.1.2 and * wp_redirect() sanitises its target, so this is not the last line of defence against a * modern stack, it is cheap, it has no false-positive tail once prose is excluded, and * it names a request nobody sends by accident. Treat it as evidence rather than armour. * * THE CHARACTER, NOT ITS SPELLING. $r['path'] arrives through decode_twice(), and * parameter values come from $_GET/$_POST which PHP has already decoded once, so by the * time a rule sees either, %0d%0a IS a real control character. Matching the literal text * "%0d" as well would flag a reader searching for the string, which is a near-miss case in * the corpus. * * A DOUBLE-ENCODED PAYLOAD IS NOT AN EVASION, which is worth stating because it looks like * one. %250d%250a survives PHP's single decode as the literal text %0d%0a, so it is not a * newline when it reaches the application either, and it cannot split a header. There is * nothing to catch. The path is decoded twice for a different reason: web servers and * WordPress rewrite rules do decode paths more than once, so there the second pass is * reachable. */ if ( preg_match( '/[\r\n]/', (string) ( $r['path'] ?? '' ) ) ) { return true; } /* * PROSE IS EXCLUDED, which is the whole difference between this rule and null_byte. * That rule inspects every parameter because no prose contains a raw null byte. Prose * contains newlines constantly, every comment, every post body, every textarea, so * inspecting them here would refuse ordinary writing. inspectable_values() is the * existing gate for exactly this distinction. */ // RAW values, not normalised ones: normalisation folds every newline // into a space, which is the character this rule exists to find. foreach ( self::inspectable_values( $r, true ) as $v ) { if ( preg_match( '/[\r\n]/', $v ) ) { return true; } } // A newline in a parameter NAME is never prose and never accidental. foreach ( (array) ( $r['params'] ?? array() ) as $p ) { if ( preg_match( '/[\r\n]/', (string) ( $p['key'] ?? '' ) ) ) { return true; } } return false; }, ), 'null_byte' => array( 'label' => ( $i18n ? __( 'Null byte in the request', 'cloudscale-cyber-devtools' ) : 'Null byte in the request' ), 'severity' => 'high', 'category' => 'evasion', 'section' => 'structural', 'test' => static function ( array $r ): bool { // A null byte is never legitimate in a URL or a form field. It exists in // a request for exactly one reason: to truncate a string in the C layer // underneath so that a check reading the whole value and the code opening // the file disagree about what the value is. // // This is the one rule that also inspects CONTENT_PARAMS. The exemption // exists because prose legitimately contains attack strings on a security // blog, but no prose contains a raw null byte, and a search box is as // good a place to hide one as any other field. if ( false !== strpos( (string) ( $r['path'] ?? '' ), "\0" ) ) { return true; } foreach ( (array) ( $r['params'] ?? array() ) as $p ) { if ( false !== strpos( (string) ( $p['value'] ?? '' ), "\0" ) ) { return true; } // The key too: a null byte in a parameter NAME is the same trick. if ( false !== strpos( (string) ( $p['key'] ?? '' ), "\0" ) ) { return true; } } return false; }, ), // ── Content: inside parameter values, prose fields excluded ── 'php_wrapper' => array( 'label' => ( $i18n ? __( 'PHP stream wrapper in a parameter', 'cloudscale-cyber-devtools' ) : 'PHP stream wrapper in a parameter' ), 'severity' => 'critical', 'category' => 'inclusion', 'section' => 'content', 'test' => static function ( array $r ): bool { foreach ( self::inspectable_values( $r ) as $v ) { if ( preg_match( '#\b(?:php|data|expect|phar|zip|glob|ogg)://#i', $v ) ) { return true; } } return false; }, ), 'remote_inclusion' => array( 'label' => ( $i18n ? __( 'Remote URL or local file path passed in a parameter', 'cloudscale-cyber-devtools' ) : 'Remote URL or local file path passed in a parameter' ), 'severity' => 'high', 'category' => 'inclusion', 'section' => 'content', 'test' => static function ( array $r ): bool { foreach ( (array) ( $r['params'] ?? array() ) as $p ) { $key = strtolower( (string) ( $p['key'] ?? '' ) ); if ( self::is_content_param( $key ) ) { continue; } $v = self::value_of( $p, false ); if ( '' === $v ) { continue; } // Traversal and absolute system paths are tested in EVERY non-prose // value. The earlier version looked only at keys named like a file // (file, path, template, ...), so `?id=../../etc/passwd` was invisible // to it, and nothing else looked either: traversal_path covers the URL // path, not the query. if ( preg_match( '#\.\.[/\\\\]|(?:^|[^a-z0-9])/(?:etc/(?:passwd|shadow|hosts)|proc/self)\b|^/(?:etc|proc|var)/|^[a-z]:[/\\\\]#i', $v ) ) { return true; } // A remote URL, still only where a file or template is named. A // remote URL in redirect_to or a return URL is ordinary WordPress. if ( preg_match( '/(?:^|_)(?:file|path|template|page|include|require|doc|folder|dir|load)$/', $key ) && preg_match( '#^(?:https?|ftps?)://#i', $v ) ) { return true; } } return false; }, ), 'sql_injection' => array( 'label' => ( $i18n ? __( 'SQL injection pattern in a parameter', 'cloudscale-cyber-devtools' ) : 'SQL injection pattern in a parameter' ), 'severity' => 'critical', 'category' => 'sqli', 'section' => 'content', 'test' => static function ( array $r ): bool { // Values arrive NORMALISED: block comments gone, entities decoded, // whitespace and + collapsed to one space. So every pattern here is // written against the plain form. The versioned-comment and padding // bypasses were never new payloads, only new spellings, and the fix for a // spelling belongs in normalise_value(), not in a wider pattern. foreach ( self::inspectable_values( $r ) as $v ) { $low = strtolower( $v ); // UNION [ALL|DISTINCT] SELECT. Not a bare "union" or "select", those // appear in ordinary English and in ordinary SQL discussion. What may // sit between is punctuation only (a # comment, a parenthesis), never // a word, and never more than 40 characters now that whitespace is // collapsed, so fifty spaces cannot walk past the bound. if ( preg_match( '/\bunion\b[^a-z]{0,40}(?:(?:all|distinct)\b[^a-z]{0,40})?select\b/', $low ) ) { return true; } // Numeric tautologies: or 1=1, or 1 like 1, or '1'='1', including // comment-terminated. The digits are bounded and the optional quote is // a backreference, so there is no lazy prefix competing with the // capture for the same characters, which is what made the previous // version quadratic on a long run of digits. if ( preg_match( '/\b(?:or|and)\s+([\'"]?)(\d{1,20})\1\s*(?:=|\blike\b)\s*\1\2(?![\d.])/', $low ) ) { return true; } // Boolean tautologies: `' or true--`, `" and false#`. The quote before // or the comment after is required, "true or false" on its own is a // value a form can legitimately post. if ( preg_match( '/[\'"]\s*(?:or|and)\s+(?:true|false)\b|\b(?:or|and)\s+(?:true|false)\s*(?:--|#|;)/', $low ) ) { return true; } // Quoted-string tautologies: or 'a'='a'. The numeric pattern cannot // reach these, its class holds digits only, so `admin' or 'a'='a` // sailed through. Found by the Phase 1.3 corpus. // // The backreference is what keeps this narrow: both sides must be the // SAME value, so an ordinary comparison of two different strings is // not a match. if ( preg_match( '/\b(?:or|and)\b\s*([\'"])([a-z0-9_ ]{1,32})\1?\s*=\s*\1?\2\1?/', $low ) ) { return true; } // Stacked statement followed by a destructive verb. if ( preg_match( '/;\s*(?:drop|delete|update|insert|alter|truncate|create)\b/', $low ) ) { return true; } // Time-based probing. if ( preg_match( '/\b(?:sleep|benchmark|pg_sleep|waitfor\s+delay)\s*\(/', $low ) ) { return true; } // Reading the schema, and the error-based readers: extractvalue() and // updatexml() exist in a query parameter for one reason, which is to // leak a value through a MySQL error message. if ( preg_match( '/\binformation_schema\b|\bload_file\s*\(|\binto\s+(?:out|dump)file\b|\b(?:extractvalue|updatexml)\s*\(/', $low ) ) { return true; } // A subquery, and SELECT ... FROM a WordPress or system table. A bare // "select ... from" is English ("select an item from the list"); a // parenthesised one, or one naming wp_ or the catalogue, is not. if ( preg_match( '/\(\s*select\b|\bselect\b[^;]{0,60}\bfrom\s+(?:`?wp_|information_schema|mysql\.)/', $low ) ) { return true; } // ORDER BY as a column-count probe, terminated by a comment or the // end of the value. "order by date" is a sort option; "order by 9-- -" // is counting columns for the UNION that follows. if ( preg_match( '/\border\s+by\s+\d{1,4}\s*(?:--|#|;|$)/', $low ) ) { return true; } // A long hex literal: 0x77705f7573657273 is "wp_users" spelled so no // quote is needed. Eight hex digits or more, but not the two lengths a // legitimate blockchain address (40) or transaction hash (64) has, which // is the one place a hex literal reaches a form field honestly. if ( preg_match( '/\b0x(?![0-9a-f]{40}\b|[0-9a-f]{64}\b)[0-9a-f]{8,}/', $low ) ) { return true; } // A SQL string function applied to something: ascii(char(65)), // substring((select ...), concat(0x7e, ...), version(). The argument // must look like SQL too, a nested call, a subquery, a hex literal, a // number, a quote or nothing at all, so a function NAME in prose is not // enough. if ( preg_match( '/\b(?:ascii|ord|char|substring|substr|mid|hex|unhex|concat|group_concat|version|database|schema|user|current_user|system_user)' . '\s*\(\s*(?:\)|\(|[a-z_]{2,20}\s*\(|select\b|0x[0-9a-f]|\d|[\'"])/', $low ) ) { return true; } } return false; }, ), 'xss_reflected' => array( 'label' => ( $i18n ? __( 'Script injection pattern in a parameter', 'cloudscale-cyber-devtools' ) : 'Script injection pattern in a parameter' ), 'severity' => 'high', 'category' => 'xss', 'section' => 'content', 'test' => static function ( array $r ): bool { // Values arrive NORMALISED, so an entity-encoded script tag is a plain one here, and // javascript: is javascript:, the same folding a browser does before // it decides what to execute. foreach ( self::inspectable_values( $r ) as $v ) { $low = strtolower( $v ); if ( preg_match( '/<\s*script\b|<\s*\/\s*script\s*>|<\s*iframe\b|<\s*object\b|<\s*embed\b/', $low ) ) { return true; } // ANY event handler, not a list of six. The HTML spec adds handlers // faster than a list can follow (onpointerover, onbeforetoggle, // onfocusin were all outside the old list), and the = is what // distinguishes a handler from prose mentioning "onerror". Preceded by // the start, whitespace, a quote, a slash or an angle bracket, which // is where an attribute sits, and a handful of English words that // happen to start with "on" are excluded by name. if ( preg_match( '/(?:^|[\s"\'\/<>])on(?!line\b|boarding\b|site\b|screen\b|going\b)[a-z]{3,20}\s*=/', $low ) ) { return true; } // javascript: with the whitespace a browser ignores inside the word, // java\nscript: executes, and after normalisation that is java script:. if ( preg_match( '/java\s*script\s*:|vb\s*script\s*:/', $low ) ) { return true; } // A data: URI carrying a document a browser would execute, as opposed // to an inline image, which a block editor legitimately posts. if ( preg_match( '#data\s*:\s*(?:text/html|text/javascript|application/(?:x-)?javascript|image/svg\+xml)#', $low ) ) { return true; } } return false; }, ), 'code_execution' => array( 'label' => ( $i18n ? __( 'Code execution pattern in a parameter', 'cloudscale-cyber-devtools' ) : 'Code execution pattern in a parameter' ), 'severity' => 'critical', 'category' => 'rce', 'section' => 'content', 'test' => static function ( array $r ): bool { foreach ( self::inspectable_values( $r ) as $v ) { $low = strtolower( $v ); // A call, not a mention: the opening parenthesis is required. if ( preg_match( '/\b(?:eval|assert|system|passthru|shell_exec|proc_open|popen|pcntl_exec)\s*\(/', $low ) ) { return true; } // base64_decode wrapped around something, the usual dropper shape. if ( preg_match( '/\bbase64_decode\s*\(/', $low ) ) { return true; } if ( self::looks_like_command_injection( $low ) ) { return true; } } // The JNDI lookup is sprayed into EVERY field of every form on the // internet, prose fields included, because the logger that expands it // does not care which field it came from. No paragraph a reader writes // contains ${jndi: or ${env:, so this one shape is checked in prose too. foreach ( self::all_values( $r ) as $v ) { if ( preg_match( '/\$\{\s*(?:jndi|env|sys|lower|upper|date|ctx|main|java|k8s|spring|bundle|log4j|marker|hostname|docker|web|base64)\s*:|\$\{[^}]{0,40}\$\{/i', $v ) ) { return true; } } return false; }, ), 'object_injection' => array( 'label' => ( $i18n ? __( 'Serialized PHP object in a parameter', 'cloudscale-cyber-devtools' ) : 'Serialized PHP object in a parameter' ), 'severity' => 'critical', 'category' => 'deserialization', 'section' => 'content', 'test' => static function ( array $r ): bool { // Prose fields INCLUDED, the one content rule besides the JNDI check // that is. unserialize() does not care which field the string came from, // and the full shape below, with its length-prefixed class name and // property count, is not something a reader types into a comment box. foreach ( self::all_values( $r ) as $v ) { // O::""::{ is what serialize() emits for an object, // C:... the same for one with a custom (un)serialize handler. This is // the shape a PHP Object Injection payload always takes regardless of // which gadget chain it is aimed at, so one pattern covers the class of // bug rather than any one CVE. // // Deliberately NOT lower-cased first, unlike the other content rules. // Real serialize() output is never lowercase; folding case here would // only make the pattern match more incidental prose, not more attacks. if ( preg_match( '/\b[OC]:\d+:"[^"]{1,200}":\d+:\{/', $v ) ) { return true; } } return false; }, ), 'forged_submitter' => array( 'label' => ( $i18n ? __( 'Anonymous form submission claiming to be a signed-in user', 'cloudscale-cyber-devtools' ) : 'Anonymous form submission claiming to be a signed-in user' ), 'explain' => ( $i18n ? __( 'A form plugin field that records who submitted the entry carries a real user ID, but the request has no login cookie. Formidable Forms up to 6.33.1 (CVE-2026-18331) trusted that field, so a visitor who set it to an administrator\'s ID had their stored script run in the admin\'s browser.', 'cloudscale-cyber-devtools' ) : 'A form plugin field that records who submitted the entry carries a real user ID, but the request has no login cookie. Formidable Forms up to 6.33.1 (CVE-2026-18331) trusted that field, so a visitor who set it to an administrator\'s ID had their stored script run in the admin\'s browser.' ), 'severity' => 'high', 'category' => 'forgery', 'section' => 'content', 'test' => static function ( array $r ): bool { // The precondition of the attack, not its payload. The payload in // CVE-2026-18331 survives wp_kses_post() and is decoded by the plugin's // own admin JavaScript, so its spelling is the attacker's choice; the // forged ID is not, because without it the entry is sanitised. Matching // the ID therefore covers every encoding of the payload at once. // // A guest's form carries 0 or nothing in this field. A positive ID with // no login cookie on the request is a claim nobody signed in could make. $ids = array(); foreach ( (array) ( $r['params'] ?? array() ) as $p ) { $key = strtolower( (string) ( $p['key'] ?? '' ) ); if ( 0 === strpos( $key, 'cookie:wordpress_logged_in_' ) ) { return false; // Signed in: the plugin checks the ID against the session. } $leaf = (string) preg_replace( '/^.*\[([^\[\]]*)\]$/', '$1', $key ); if ( in_array( $leaf, self::SUBMITTER_ID_KEYS, true ) ) { $ids[] = trim( (string) ( $p['value'] ?? '' ) ); } } foreach ( $ids as $id ) { if ( 1 === preg_match( '/^0*[1-9]\d{0,19}$/', $id ) ) { return true; } } return false; }, ), 'privileged_role' => array( 'label' => ( $i18n ? __( 'Anonymous request asking for the administrator role', 'cloudscale-cyber-devtools' ) : 'Anonymous request asking for the administrator role' ), 'explain' => ( $i18n ? __( 'A role field set to administrator on a request with no login cookie. That is how registration-form plugins that map a public field to the new account\'s role get turned into "create me an admin" (Bricksforge CVE-2026-14956, Advanced Form Integration CVE-2026-11794). No visitor signing up legitimately asks to be an administrator.', 'cloudscale-cyber-devtools' ) : 'A role field set to administrator on a request with no login cookie. That is how registration-form plugins that map a public field to the new account\'s role get turned into "create me an admin" (Bricksforge CVE-2026-14956, Advanced Form Integration CVE-2026-11794). No visitor signing up legitimately asks to be an administrator.' ), 'severity' => 'critical', 'category' => 'forgery', 'section' => 'content', 'test' => static function ( array $r ): bool { // Same shape as forged_submitter: the precondition, not the plugin. Every // plugin in this class differs in field names and endpoints, and all of them // need the word `administrator` to arrive in a field that means role. // // The value must be exactly the role slug. A job-application form with a // free-text "Role" field answered "Administrator, finance team" is a person // describing their job; the lowercase slug alone is a WordPress role. $hit = false; foreach ( (array) ( $r['params'] ?? array() ) as $p ) { $key = strtolower( (string) ( $p['key'] ?? '' ) ); if ( 0 === strpos( $key, 'cookie:wordpress_logged_in_' ) ) { return false; // Signed in: WordPress checks promote_users itself. } if ( 0 === strpos( $key, 'cookie:' ) || 0 === strpos( $key, 'header:' ) ) { continue; } // The last named segment: `roles[0]` is a list under `roles`, so its // numeric index is skipped. $parts = preg_split( '/[\[\]]+/', $key, -1, PREG_SPLIT_NO_EMPTY ); while ( ! empty( $parts ) && ctype_digit( (string) end( $parts ) ) ) { array_pop( $parts ); } $leaf = empty( $parts ) ? '' : (string) end( $parts ); if ( in_array( $leaf, self::ROLE_KEYS, true ) && 'administrator' === trim( (string) ( $p['value'] ?? '' ) ) ) { $hit = true; } } return $hit; }, ), 'reset_identity_mismatch' => array( 'label' => ( $i18n ? __( 'Anonymous password reset or OTP naming one user and another email', 'cloudscale-cyber-devtools' ) : 'Anonymous password reset or OTP naming one user and another email' ), 'explain' => ( $i18n ? __( 'A plugin\'s forgot-password or verify-code endpoint was sent a real username with an email that is not that user\'s, the Kirki takeover (CVE-2026-8206). Core\'s own lost-password form takes one field and is not affected.', 'cloudscale-cyber-devtools' ) : 'A plugin\'s forgot-password or verify-code endpoint was sent a real username with an email that is not that user\'s, the Kirki takeover (CVE-2026-8206). Core\'s own lost-password form takes one field and is not affected.' ), 'severity' => 'critical', 'category' => 'forgery', 'section' => 'content', 'opt_in' => true, 'test' => static function ( array $r ): bool { // The precondition again, like forged_submitter and privileged_role. A // plugin that takes BOTH a user and an email on its reset or code endpoint // and sends the link or code to the email is handing the account to whoever // typed the email. Every such plugin names its route differently; all of // them need a user and an email that disagree. // // Never says which half was wrong: a match is the firewall's ordinary // "Access denied.", the same as every other rule, so this cannot be used to // test which usernames exist. if ( self::has_login_cookie_param( $r ) ) { return false; } $path = strtolower( (string) ( $r['path'] ?? '' ) ); if ( false !== strpos( $path, 'wp-login.php' ) ) { return false; // Core's flow: user_login only, mailed to the stored address. } $targets = array( self::rest_route_of( $r ) ); if ( '/admin-ajax.php' === substr( $path, -15 ) ) { foreach ( (array) ( $r['params'] ?? array() ) as $p ) { if ( 'action' === (string) ( $p['key'] ?? '' ) ) { $targets[] = (string) ( $p['value'] ?? '' ); } } } $hit = false; foreach ( $targets as $target ) { $target = strtolower( $target ); if ( '' === $target || 0 === strpos( $target, '/wp/v2/' ) ) { continue; } if ( preg_match( '/(?:forgot|lost|reset|recover)[_-]?pass(?:word)?|pass(?:word)?[_-]?(?:reset|forgot|lost|recovery)|(?:^|[\/_-])otp(?:$|[\/_-])|(?:verify|send|check)[_-]?(?:code|otp)|verification[_-]?code/', $target ) ) { $hit = true; break; } } if ( ! $hit ) { return false; } $user = ''; $email = ''; foreach ( (array) ( $r['params'] ?? array() ) as $p ) { $key = strtolower( (string) ( $p['key'] ?? '' ) ); if ( 0 === strpos( $key, 'cookie:' ) || 0 === strpos( $key, 'header:' ) ) { continue; } $parts = preg_split( '/[\[\]]+/', $key, -1, PREG_SPLIT_NO_EMPTY ); $leaf = empty( $parts ) ? '' : (string) end( $parts ); $val = trim( (string) ( $p['value'] ?? '' ) ); if ( '' === $val ) { continue; } if ( '' === $user && in_array( $leaf, self::RESET_USER_KEYS, true ) ) { $user = $val; } elseif ( '' === $email && in_array( $leaf, self::RESET_EMAIL_KEYS, true ) ) { $email = $val; } } if ( '' === $user || '' === $email || ! function_exists( 'get_user_by' ) ) { return false; } // Pluggable, and loaded before plugins_loaded fires, so it is there when the // engine runs. Reached only by a request already on a reset route with both // fields, so ordinary traffic never pays for the lookup. $account = get_user_by( 'login', $user ); if ( ! $account && false !== strpos( $user, '@' ) ) { $account = get_user_by( 'email', $user ); } if ( ! is_object( $account ) || ! isset( $account->user_email ) ) { return false; // No such account: nothing to take over. } return strtolower( trim( (string) $account->user_email ) ) !== strtolower( $email ); }, ), 'proto_pollution' => array( 'label' => ( $i18n ? __( 'Prototype pollution key in a parameter name or JSON body', 'cloudscale-cyber-devtools' ) : 'Prototype pollution key in a parameter name or JSON body' ), 'explain' => ( $i18n ? __( 'A parameter or JSON key named __proto__ or constructor[prototype] exists only to alter every object in a JavaScript backend; no form field is called that.', 'cloudscale-cyber-devtools' ) : 'A parameter or JSON key named __proto__ or constructor[prototype] exists only to alter every object in a JavaScript backend; no form field is called that.' ), 'severity' => 'high', 'category' => 'pollution', 'section' => 'content', 'test' => static function ( array $r ): bool { foreach ( (array) ( $r['params'] ?? array() ) as $p ) { // KEYS, every one of them, prose exemption included: the exemption // is about values a human types, and nobody types a parameter name. // ?__proto__[isAdmin]=1 flattens to the key __proto__[isAdmin]; // ?constructor[prototype][x]=1 to constructor[prototype][x]. $key = strtolower( (string) ( $p['key'] ?? '' ) ); if ( preg_match( '/__proto__|(?:^|\[)constructor\]?\[prototype\]/', $key ) ) { return true; } if ( self::is_content_param( $key ) ) { continue; } // The same keys spelled inside a JSON string that a parameter carries. $v = self::value_of( $p, false ); if ( '' !== $v && preg_match( '/"__proto__"\s*:|"constructor"\s*:\s*\{\s*"prototype"\s*:/i', $v ) ) { return true; } } return false; }, ), // ── Body: the raw request body, outside $_GET/$_POST ───────────── 'xmlrpc_amplify' => array( 'label' => ( $i18n ? __( 'Batched XML-RPC call in the request body', 'cloudscale-cyber-devtools' ) : 'Batched XML-RPC call in the request body' ), 'severity' => 'high', 'category' => 'xmlrpc', 'section' => 'body', 'test' => static function ( array $r ): bool { $body = (string) ( $r['body'] ?? '' ); if ( '' === $body ) { return false; } /* * WHY THIS RULE EXISTS, and why brute-force counting cannot replace it. * * system.multicall carries an ARRAY of calls in one HTTP request. An attacker * puts several hundred wp.getUsersBlogs calls in a single POST, each with a * different password, and every per-request defence on the site counts ONE * request: the lockout threshold, the auto-blocker's rate window, the activity * log's failed-login rows. They are all measuring the wrong unit. That is not a * theoretical bypass, it is the standard WordPress credential-stuffing method * and the reason multicall is disabled outright by most hardening guides. * * pingback.ping IS DELIBERATELY NOT MATCHED, and the reason is worth recording. * It has the same abuse shape, the site fetches a URL the caller chooses, which * makes it an SSRF probe and a DDoS reflector, but unlike multicall it is also * ORDINARY WORDPRESS FUNCTIONALITY: an inbound pingback is how another site tells * you it linked to you. The rules corpus carries "a genuine pingback via XML-RPC" * as legitimate traffic and failed this rule the moment it was added, which is * exactly right. Its abuse is about VOLUME, not shape, so it belongs to the * auto-blocker's rate window and not to a per-request pattern. * * system.multicall has no such everyday use, which is why hardening guides * disable it outright and why it is safe to match on shape alone. * * BODY-ONLY, so it costs nothing on ordinary traffic. raw_body() already * returns '' unless the content type is XML or the path is xmlrpc.php, the * same gate xxe_entity relies on, so this adds no work to a normal page view. * * MATCHED ON THE methodName ELEMENT, not anywhere in the body. A blog post * discussing "system.multicall" would otherwise trip this the moment it was * saved through XML-RPC, which is exactly the false positive that makes people * switch a firewall off. The method name has to be where XML-RPC actually puts * it. */ // Whitespace allowed inside the tags as well as around the name: an XML // parser accepts and , so a rule that // required the literal seven-character tag was bypassed by one newline. return (bool) preg_match( '#\s*system\.multicall\s*<\s*/\s*methodName\s*>#i', $body ); }, ), 'xxe_entity' => array( 'label' => ( $i18n ? __( 'XML external entity declaration in the request body', 'cloudscale-cyber-devtools' ) : 'XML external entity declaration in the request body' ), 'severity' => 'critical', 'category' => 'xxe', 'section' => 'body', 'test' => static function ( array $r ): bool { $body = (string) ( $r['body'] ?? '' ); if ( '' === $body ) { return false; } // An ]{0,300}\b(?:SYSTEM|PUBLIC)\b/i', $body ); }, ), ); } /** * Whether the request carries a WordPress login cookie, read from its cookie params. * * The pure twin of CSDT_Request::has_login_cookie() (used by has_auth_cookie()): the same * test, presence of a `wordpress_logged_in_` NAME, read from the normalised request so a * rule stays a pure function of it. Presence only; is_exempt() is what validates. * * @param array $r Normalised request. */ public static function has_login_cookie_param( array $r ): bool { foreach ( (array) ( $r['params'] ?? array() ) as $p ) { if ( 0 === strpos( strtolower( (string) ( $p['key'] ?? '' ) ), 'cookie:wordpress_logged_in_' ) ) { return true; } } return false; } /** * The REST route a request is for, '' when it is not a REST request. * * Both spellings core accepts: the pretty `/wp-json/` path, and `?rest_route=` in * the query or the form body (WP::parse_request() reads query vars from both). Returned * with its leading slash, as core matches it. * * @param array $r Normalised request. */ public static function rest_route_of( array $r ): string { foreach ( (array) ( $r['params'] ?? array() ) as $p ) { if ( 'rest_route' === (string) ( $p['key'] ?? '' ) ) { return '/' . ltrim( (string) ( $p['value'] ?? '' ), '/' ); } } $path = (string) ( $r['path'] ?? '' ); $prefixes = array( 'wp-json' ); if ( function_exists( 'rest_get_url_prefix' ) ) { $prefixes[] = trim( (string) rest_get_url_prefix(), '/' ); } foreach ( array_unique( $prefixes ) as $prefix ) { if ( '' === $prefix ) { continue; } $at = stripos( $path, '/' . $prefix . '/' ); if ( false !== $at ) { return substr( $path, $at + strlen( $prefix ) + 1 ); } } return ''; } /** * Whether the first bytes of a file are PHP that would run if the file were included. * * Three shapes, all at the very start (after a byte-order mark or whitespace), because * that is where a dropped shell puts them and where no image, PDF or document does: * `string,'value'=>string]), 'ua', 'method'. * @param array|null $exceptions Exception list, see OPTION_EXCEPTIONS; null reads the * stored one. Passed by the tests so they stay pure. * @return array Matched rule ids, in rule order. */ public static function evaluate( array $request, ?array $exceptions = null ): array { $matched = array(); $lifted = self::exceptions_for_path( (string) ( $request['path'] ?? '' ), null === $exceptions ? self::exceptions() : $exceptions ); foreach ( self::rules() as $id => $rule ) { $test = $rule['test'] ?? null; if ( ! is_callable( $test ) ) { continue; } $view = self::request_without_excepted( $request, (string) $id, $lifted ); if ( null === $view ) { continue; // The whole rule is lifted on this path. } try { if ( true === $test( $view ) ) { $matched[] = (string) $id; } } catch ( \Throwable $e ) { // Recorded rather than swallowed: a rule that always throws is a rule // that never matches, which would otherwise look like a clean result. if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ . " rule={$id}" ); } continue; } } return $matched; } /** * Runs the content rules over one piece of prose, with the prose exemption lifted. * * For callers that have already decided the text is prose and still want to know what * it carries: the comment guard marks a matching comment spam and counts it as an * attack, without refusing the request that carried it. Content rules only; the * structural and body rules describe a request, and there is no request here. * * @since 1.10.954 * @param string $text Decoded text. * @return array Matched rule ids, in rule order. */ public static function evaluate_text( string $text ): array { if ( '' === trim( $text ) ) { return array(); } $request = array( 'path' => '', 'params' => array( array( // A key no prose list names, so every content rule inspects it. 'key' => 'inspected_text', 'value' => $text, 'norm' => self::normalise_value( $text ), ), ), 'ua' => '', 'method' => 'POST', 'body' => '', 'files' => array(), ); $content = array_filter( self::rules(), static function ( $rule ) { return 'content' === ( $rule['section'] ?? '' ); } ); $matched = array(); foreach ( $content as $id => $rule ) { // Callers act on what this returns (the comment guard spams and blocks), so a // report-only opt-in rule is left out until the administrator enforces it. if ( ! self::rule_enforces( (string) $id ) ) { continue; } $test = $rule['test'] ?? null; if ( ! is_callable( $test ) ) { continue; } try { if ( true === $test( $request ) ) { $matched[] = (string) $id; } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ . " rule={$id}" ); } } } return $matched; } // ── Rule exceptions ────────────────────────────────────────────────── /** * The stored exceptions, revalidated on read. * * Revalidated rather than trusted: the option can be written by anything with database * access, and an entry with an empty path would otherwise lift a rule site-wide, which * is exactly the blunt instrument exceptions exist to replace. * * @since 1.10.1053 * @return array */ public static function exceptions(): array { $stored = get_option( self::OPTION_EXCEPTIONS, array() ); if ( ! is_array( $stored ) || empty( $stored ) ) { return array(); } $out = array(); foreach ( $stored as $e ) { if ( ! is_array( $e ) ) { continue; } $entry = self::validate_exception( (string) ( $e['rule'] ?? '' ), (string) ( $e['path'] ?? '' ), (string) ( $e['param'] ?? '' ) ); if ( is_array( $entry ) ) { $out[] = $entry; } if ( count( $out ) >= self::MAX_EXCEPTIONS ) { break; } } return $out; } /** * Validates one exception, returning it normalised or an error string. * * The refusals are the safety of the feature: * - the path must start with "/" and may end in one "*" for a prefix; * - "/" and "/*" are refused, since an exception that covers every path is a rule * switched off, and that is what the On box is for; * - a rule id must exist, or be "*" for every rule, or "vpatch" for virtual patches. * * @since 1.10.1053 * @param string $rule Rule id or "*". * @param string $path Path, optionally ending in "*". * @param string $param Parameter name, '' for the whole request. * @return array{rule:string,path:string,param:string}|string */ public static function validate_exception( string $rule, string $path, string $param ) { $rule = strtolower( trim( $rule ) ); $path = trim( $path ); $param = strtolower( trim( $param ) ); // "vpatch" lifts virtual patches (CSDT_Virtual_Patch), which log under that prefix. if ( '*' !== $rule && 'vpatch' !== $rule && ! array_key_exists( $rule, self::default_rules() ) ) { /* translators: %s: rule id as typed. */ return sprintf( __( '"%s" is not a rule on this site.', 'cloudscale-cyber-devtools' ), $rule ); } if ( 1 !== preg_match( '#^/[A-Za-z0-9._~!$&\'()+,;=:@%/\-]{0,250}\*?$#', $path ) ) { /* translators: %s: path as typed. */ return sprintf( __( '"%s" is not a path. Start it with / and use * only at the end.', 'cloudscale-cyber-devtools' ), $path ); } if ( in_array( rtrim( $path, '*' ), array( '/', '' ), true ) ) { return __( 'An exception for every path is the same as switching the rule off. Untick it in the table instead.', 'cloudscale-cyber-devtools' ); } if ( '' !== $param && 1 !== preg_match( '/^[a-z0-9_\-.:\[\]]{1,100}$/', $param ) ) { /* translators: %s: parameter name as typed. */ return sprintf( __( '"%s" is not a parameter name.', 'cloudscale-cyber-devtools' ), $param ); } return array( 'rule' => $rule, 'path' => $path, 'param' => $param, ); } /** * Parses the panel's exceptions box: one exception per line, "rule path [parameter]". * * Lines starting with "#" and blank lines are ignored. Any bad line refuses the whole * save and names the line, so a typo never silently becomes "no exception" while the * administrator believes their false positive is handled. * * @since 1.10.1053 * @param string $text Box contents. * @return array{kept:array,errors:array} */ public static function parse_exceptions( string $text ): array { $kept = array(); $errors = array(); $seen = array(); foreach ( preg_split( '/\r\n|\r|\n/', $text ) as $n => $line ) { $line = trim( (string) $line ); if ( '' === $line || '#' === $line[0] ) { continue; } $parts = preg_split( '/\s+/', $line ); if ( count( $parts ) < 2 || count( $parts ) > 3 ) { /* translators: %d: line number. */ $errors[] = sprintf( __( 'Line %d: write a rule, a path and optionally one parameter.', 'cloudscale-cyber-devtools' ), $n + 1 ); continue; } $entry = self::validate_exception( $parts[0], $parts[1], $parts[2] ?? '' ); if ( ! is_array( $entry ) ) { /* translators: 1: line number, 2: reason. */ $errors[] = sprintf( __( 'Line %1$d: %2$s', 'cloudscale-cyber-devtools' ), $n + 1, $entry ); continue; } $key = implode( ' ', $entry ); if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $kept[] = $entry; } if ( count( $kept ) > self::MAX_EXCEPTIONS ) { /* translators: %d: maximum number of exceptions. */ $errors[] = sprintf( __( 'At most %d exceptions.', 'cloudscale-cyber-devtools' ), self::MAX_EXCEPTIONS ); } return array( 'kept' => $kept, 'errors' => $errors, ); } /** The exceptions as the panel's box shows them, one per line. */ public static function exceptions_text(): string { $lines = array(); foreach ( self::exceptions() as $e ) { $lines[] = trim( $e['rule'] . ' ' . $e['path'] . ' ' . $e['param'] ); } return implode( "\n", $lines ); } /** * The exceptions that apply to one request path, grouped by rule. * * A path carrying a dot segment or a doubled slash gets NO exceptions. The router * resolves `/wp-json/hook/../../wp-admin/x` somewhere other than its prefix suggests, * so honouring a prefix match there would let an attacker borrow a webhook's exception * for any URL on the site. * * @since 1.10.1053 * @param string $path Decoded request path. * @param array $exceptions Validated exceptions. * @return array|true> rule id or "*" => true for the whole * rule, or the list of lifted parameters. */ public static function exceptions_for_path( string $path, array $exceptions ): array { if ( empty( $exceptions ) || '' === $path || preg_match( '#(?:^|/)\.{1,2}(?:/|$)|//#', $path ) ) { return array(); } $out = array(); foreach ( $exceptions as $e ) { $want = (string) ( $e['path'] ?? '' ); if ( '' === $want ) { continue; } $hit = '*' === substr( $want, -1 ) ? 0 === strpos( $path, substr( $want, 0, -1 ) ) : rtrim( $path, '/' ) === rtrim( $want, '/' ); if ( ! $hit ) { continue; } $rule = (string) ( $e['rule'] ?? '' ); $param = (string) ( $e['param'] ?? '' ); if ( '' === $param ) { $out[ $rule ] = true; } elseif ( ! isset( $out[ $rule ] ) || true !== $out[ $rule ] ) { $out[ $rule ][] = $param; } } return $out; } /** * The request as one rule sees it after its exceptions, or null when the rule is lifted. * * A lifted parameter is removed with its nested keys (`field` also lifts `field[0]`), so * the exception covers the shape a form actually sends. Nothing else is touched: the * path, the body and every other parameter are still inspected by the same rule. * * @since 1.10.1053 * @param array $request Normalised request. * @param string $rule_id Rule about to run. * @param array $lifted From exceptions_for_path(). * @return array|null */ public static function request_without_excepted( array $request, string $rule_id, array $lifted ): ?array { if ( empty( $lifted ) ) { return $request; } $params = array(); foreach ( array( '*', $rule_id ) as $key ) { if ( ! isset( $lifted[ $key ] ) ) { continue; } if ( true === $lifted[ $key ] ) { return null; } $params = array_merge( $params, (array) $lifted[ $key ] ); } if ( empty( $params ) ) { return $request; } $request['params'] = array_values( array_filter( (array) ( $request['params'] ?? array() ), static function ( $p ) use ( $params ) { $key = strtolower( (string) ( $p['key'] ?? '' ) ); foreach ( $params as $name ) { if ( $key === $name || 0 === strpos( $key, $name . '[' ) ) { return false; } } return true; } ) ); return $request; } // ── Exemptions ─────────────────────────────────────────────────────── /** * Whether this request must not be inspected. * * Ordered by cost. The capability check is last because it validates a cookie and * may hit the database, and it is skipped entirely when no cookie is present, so * ordinary anonymous traffic, which is all of it that matters, pays nothing. */ public static function is_exempt(): bool { // Neither carries a meaningful request to inspect, and refusing cron would break // every scheduled task. if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { return true; } // A REST or AJAX call from the site to itself, and the loopback that drives cron. if ( defined( 'REST_REQUEST' ) && REST_REQUEST && self::is_own_request() ) { return true; } if ( ! self::has_auth_cookie() ) { return false; // Anonymous: inspect. The common path, and the cheap one. } if ( ! class_exists( 'CSDT_Login' ) ) { return false; } return CSDT_Login::request_user_can( self::skip_capability() ); } /** * Whether a logged-in cookie is present at all. * * Only the presence of the cookie NAME is read, never its value, so there is no * untrusted input to sanitise here and no signature to verify, that is * request_user_can()'s job. This exists purely to avoid paying for cookie * validation on anonymous traffic. */ public static function has_auth_cookie(): bool { return CSDT_Request::has_login_cookie(); } /** Whether this request originated from the site itself. */ private static function is_own_request(): bool { $ip = class_exists( 'CSDT_DevTools' ) ? CSDT_DevTools::get_client_ip() : ''; if ( '' === $ip ) { return true; // No resolvable peer: not something to inspect. } if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return true; // Private or loopback. } return class_exists( 'CSDT_Login' ) && in_array( $ip, CSDT_Login::own_public_ips(), true ); } // ── Enforcement ────────────────────────────────────────────────────── /** * Hooked to `plugins_loaded` priority 3. * * After the IP blocklist (1) and country rules (2): those are cheaper and more * specific, so an address already blocked never pays for pattern matching. * * With no rules loaded this returns after one autoloaded option read, which is why * 1.1 can ship ahead of the rules without costing anything. */ public static function enforce(): void { try { if ( self::$evaluated ) { return; } if ( ! self::is_enabled() ) { return; // One autoloaded read on the common path. } if ( self::is_exempt() ) { return; } $rules = self::rules(); if ( empty( $rules ) ) { // Nothing to run. Recorded so the liveness check can tell "enabled with // no rules" apart from "enabled and matching nothing", the first is a // misconfiguration, the second is a quiet day. update_option( 'csdt_firewall_no_rules', time(), false ); return; } delete_option( 'csdt_firewall_no_rules' ); self::$evaluated = true; // Measured, not estimated. The panel shows this figure so that a rule added // later which happens to be expensive is visible rather than felt as "the site // got slower". hrtime() is monotonic and costs a syscall-free read; the two // calls together are far cheaper than the work between them. $started = hrtime( true ); $matched = self::evaluate( self::normalise_request() ); self::record_cost( (int) ( ( hrtime( true ) - $started ) / 1000 ) ); if ( empty( $matched ) ) { return; } self::record_matches( $matched ); if ( self::is_blocking() && array() !== self::enforcing_matches( $matched ) ) { // Set here as well: core's XML-RPC die handler never sets a status, so xmlrpc.php would answer 200. status_header( 403 ); wp_die( esc_html__( 'Access denied.', 'cloudscale-cyber-devtools' ), '', array( 'response' => 403 ) ); } } catch ( \Throwable $e ) { // Fails OPEN, always. A firewall that 500s the site is worse than one that // misses a request, and this engine is not the last line of defence. if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Records what matched, in Log mode as well as Block mode. * * Log mode exists to show a site what WOULD have been refused, using its own traffic. * A Log mode that records nothing is the exact fault this workstream has now found * five times: a control reporting success while doing nothing. So the default flipping * to Log and this recorder land in the same commit. * * Uses CSDT_Storage rather than a new table. Its counters are atomic * (INSERT ... ON DUPLICATE KEY UPDATE) and already bucketed per minute, which is * exactly the dedupe Phase 2b's richer log needs so that logging cannot itself become * the amplification vector under attack. 2b adds the truncated path; this adds counts. * * Rule id only. Never the query string or the POST body, a matched payload routinely * carries credentials lifted from a compromised source, and storing it would turn a * security log into a liability. * * @since 1.10.472 * @param array $matched Matched rule ids. */ private static function record_matches( array $matched ): void { // Its OWN try/catch, not enforce()'s. Recording runs before the block so that a // refused request is recorded, and that ordering meant a throw in here reached // enforce()'s catch and the request was never refused at all, a logging fault // silently disabling the control. Caught by the engine test's own assertion that // "a missing event table does not stop block mode refusing". try { self::write_records( $matched ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Writes the counter and log rows for a set of matches. * * @since 1.10.486 * @param array $matched Matched rule ids. */ private static function write_records( array $matched ): void { if ( empty( $matched ) || ! class_exists( 'CSDT_Storage' ) || ! CSDT_Storage::is_available() ) { return; } $ip = class_exists( 'CSDT_DevTools' ) ? (string) CSDT_DevTools::get_client_ip() : ''; if ( '' === $ip ) { return; } $now = time(); $blocking = self::is_blocking(); $enforced = self::enforcing_matches( $matched ); foreach ( $matched as $rule_id ) { $action = ( $blocking && in_array( $rule_id, $enforced, true ) ) ? 'blocked' : 'logged'; // Two records, deliberately, because they answer different questions and are // read by different things. The counter feeds rate limiting and escalation // (Phase 1.5) and is keyed by address; the log row is what a human reads when // deciding whether this site's traffic can survive Block mode, and carries the // rule, the action and a truncated path. Both are atomic and both dedupe per // minute, so a scan costs a bounded number of writes rather than one per // request. if ( in_array( $rule_id, $enforced, true ) ) { // A report-only rule is never counted toward escalation. CSDT_Storage::record_event( $ip, self::SIGNAL_PREFIX . $rule_id, $now ); } // is_callable, not method_exists: the latter returns true for a private method, // so the guard passes and the next line fatals. That exact mistake has already // shipped here once, on send_threat_alert(). if ( is_callable( array( 'CSDT_Storage', 'record_match' ) ) ) { CSDT_Storage::record_match( $ip, $rule_id, $action, array(), $now ); } } if ( array() !== $enforced ) { self::escalate( $ip ); } // One cheap marker so the panel and the liveness check can tell "matched nothing // yet" from "never ran". Throttled to once a minute: under a scan this path runs // on every request, and an unthrottled option write would be a self-inflicted // load spike on exactly the requests already costing the most. $last = (int) get_option( self::OPTION_LAST_MATCH, 0 ); if ( $now - $last >= MINUTE_IN_SECONDS ) { update_option( self::OPTION_LAST_MATCH, $now, false ); } } /** * Builds the normalised request the rules see. * * Shape is fixed here so Phase 1.2 adds rules without changing this contract. * Decoding happens TWICE, because a single decode leaves `%252e%252e%252f` looking * innocent while the target still resolves it to `../`. * * Nothing here is trusted or persisted; it is read-only input to pure predicates, * which is why the superglobals are taken raw rather than sanitised, sanitising * would destroy the very payload the rules exist to recognise. * * `path` is the path ONLY. REQUEST_URI carries the query string too, and passing the * whole thing through as the path was a real false positive found by probing the live * site: `/?s=../../etc/passwd` matched `traversal_path`. The structural rules apply to * every request, so anything reachable through `path` silently bypasses the * CONTENT_PARAMS exemption, meaning a reader of this security blog searching for a * traversal string would have been refused once the mode was promoted to Block. Query * values are already inspected as parameters, subject to that exemption. * * The parameter list is wider than $_GET and $_POST, because those are not the only * places a PHP application reads input from and an attacker sends the payload wherever * the vulnerable code reads: * COOKIE every cookie value, keyed cookie: * HEADER the INSPECTED_HEADERS allowlist, keyed header:; the Referer has its * prose query parameters removed first, a click-through from this site's own * search results carries ?s=... and that is a reader, not an attacker * JSON a JSON body, decoded and flattened like a form, so `{"content":"..."}` * keeps the prose exemption on `content` and `{"__proto__":{...}}` does not * plus 'files', the filenames in $_FILES, for the upload rule, and 'file_heads', the first * bytes of each uploaded file, for upload_exec_content. * * @return array{path:string,params:array>,ua:string,method:string,body:string,files:array,file_heads:array} */ public static function normalise_request(): array { // Request metadata via CSDT_Request::server(): scalar-checked, UTF-8 validated and // length-capped, never stripped. $unslash is false because this runs on // plugins_loaded, before wp_magic_quotes(), so $_SERVER is not yet slashed and an // unslash here would delete a literal backslash that is itself part of a payload. $raw_path = CSDT_Request::server( 'REQUEST_URI', self::MAX_URI_BYTES, false ); $ua = CSDT_Request::server( 'HTTP_USER_AGENT', self::MAX_HEADER_BYTES, false ); $method = CSDT_Request::server( 'REQUEST_METHOD', 16, false ); $ctype = CSDT_Request::server( 'CONTENT_TYPE', 256, false ); if ( '' === $method ) { $method = 'GET'; } $raw = self::raw_request(); $get = $raw['get']; $post = $raw['post']; $cookies = $raw['cookies']; $files = $raw['files']; $headers = array(); foreach ( self::INSPECTED_HEADERS as $name => $server_key ) { if ( isset( $_SERVER[ $server_key ] ) ) { $value = CSDT_Request::server( $server_key, self::MAX_HEADER_BYTES, false ); if ( 'referer' === $name ) { $value = self::referer_for_inspection( $value ); } $headers[ 'header:' . $name ] = $value; } } $params = array_merge( self::flatten( $get, 'GET' ), self::flatten( $post, 'POST' ), self::flatten( self::prefix_keys( $cookies, 'cookie:' ), 'COOKIE' ), self::flatten( $headers, 'HEADER' ) ); $body = self::raw_body(); if ( '' !== $body ) { $lead = ltrim( substr( $body, 0, 64 ), "\xEF\xBB\xBF \t\r\n" ); $is_json = false !== stripos( $ctype, 'json' ) || ( '' !== $lead && ( '{' === $lead[0] || '[' === $lead[0] ) ); $is_xml = false !== stripos( $ctype, 'xml' ) || false !== stripos( $raw_path, 'xmlrpc.php' ) || ( '' !== $lead && '<' === $lead[0] ); if ( $is_json && ! $is_xml ) { // json_decode's depth cap is the same six levels flatten() honours; a // body that fails to parse, truncated at MAX_BODY_BYTES or simply not // JSON, is inspected as one opaque value rather than dropped, so a // payload cannot hide behind 64 KB of padding. $decoded = json_decode( $body, true, 8 ); $params = array_merge( $params, is_array( $decoded ) ? self::flatten( $decoded, 'JSON' ) : self::flatten( array( 'json:body' => $body ), 'JSON' ) ); $body = ''; } elseif ( ! $is_xml ) { $body = ''; // Neither shape the body rules know; nothing for them to read. } } return array( 'path' => self::decode_twice( self::path_only( $raw_path ) ), 'params' => $params, 'ua' => $ua, 'method' => strtoupper( $method ), 'body' => $body, 'files' => self::file_names( $files ), // Read only when $_FILES has entries, so a request without an upload opens nothing. 'file_heads' => empty( $files ) ? array() : self::file_heads( $files ), ); } /** * THE ONE PLACE the firewall reads the request: $_GET, $_POST, $_COOKIE and $_FILES, as sent. * * Nothing else happens here. The four arrays are returned untouched to normalise_request(), * which flattens them into the parameter list the rules match against. * * No nonce: this inspects every request to the site, and attack traffic never carries * one, so a nonce check would mean inspecting nothing. No sanitiser: sanitising strips the * tags, encoded bytes, control characters and double extensions the rules exist to * recognise, so it would not make the request safe, it would make the rules blind. * Nothing read here is stored, echoed or put in a query; the only outcome is allow or 403. * * @return array{get:array,post:array,cookies:array,files:array} */ private static function raw_request(): array { // wporg-risk-accepted-begin: WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended, CS_REJECT_31, CS_REJECT_23 -- This is the firewall's request inspector. It must see the request exactly as the client sent it: sanitising strips the tags, encoded bytes and control characters the rules match, and attack traffic never carries a nonce. Nothing read here is stored, echoed or queried; the only outcome is allow or a 403. // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- read-only inspection of the raw request; sanitising would remove the payload these rules exist to detect, and nothing here is stored or echoed return array( 'get' => is_array( $_GET ) ? $_GET : array(), 'post' => is_array( $_POST ) ? $_POST : array(), 'cookies' => isset( $_COOKIE ) && is_array( $_COOKIE ) ? $_COOKIE : array(), 'files' => isset( $_FILES ) && is_array( $_FILES ) ? $_FILES : array(), ); // phpcs:enable // wporg-risk-accepted-end } /** * The raw query and form arrays, for CSDT_Virtual_Patch, which reads one parameter of a * request the same way this firewall does and for the same reason: as sent, unsanitised, * because sanitising would remove what its conditions look for. Nothing read through here * is stored, echoed or queried; the only outcome is allow or a 403. * * @since 1.10.1055 * @return array{get:array,post:array} */ public static function request_input(): array { $raw = self::raw_request(); return array( 'get' => $raw['get'], 'post' => $raw['post'], ); } /** * The request body as raw_body() reads it ('' when there is no unparsed body), for * CSDT_Virtual_Patch's REST rules, which read top-level JSON keys. * * @since 1.10.1055 * @return string */ public static function request_body(): string { return self::raw_body(); } /** * The first UPLOAD_HEAD_BYTES of every uploaded file, however the form nested them. * * Every tmp_name leaf, not the first one: the Elementor Pro trick (CVE-2026-32475) sent * an empty first file so the plugin validated that one and saved the next. Only a path * PHP itself received in this request is read (is_uploaded_file()), so nothing in * $_FILES can point this at another file on disk. Capped at UPLOAD_HEAD_FILES files. * * @param array $files $_FILES. * @return array */ public static function file_heads( array $files ): array { $out = array(); foreach ( $files as $field ) { if ( ! is_array( $field ) || ! array_key_exists( 'tmp_name', $field ) ) { continue; } foreach ( self::string_leaves( $field['tmp_name'], 0 ) as $tmp ) { if ( count( $out ) >= self::UPLOAD_HEAD_FILES ) { return $out; } if ( ! self::$trust_tmp_names && ! ( function_exists( 'is_uploaded_file' ) && is_uploaded_file( $tmp ) ) ) { continue; } if ( ! is_file( $tmp ) || ! is_readable( $tmp ) ) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- the first 512 bytes of a local file PHP just received in this request, read to inspect it; never a URL, and WP_Filesystem has no bounded read $head = file_get_contents( $tmp, false, null, 0, self::UPLOAD_HEAD_BYTES ); if ( is_string( $head ) && '' !== $head ) { $out[] = $head; } } } return $out; } /** * Prefixes every top-level key, so cookie names can never collide with form fields * and can never be mistaken for a prose parameter. * * @param array $data Key/value pairs. * @param string $prefix Prefix to apply. * @return array */ private static function prefix_keys( array $data, string $prefix ): array { $out = array(); foreach ( $data as $key => $value ) { $out[ $prefix . $key ] = $value; } return $out; } /** * A Referer with its prose query parameters removed. * * The referer of a click from this site's own search results is * /?s=, and on a security blog that is an attack * string as often as not. The path and the non-prose parameters are kept, because a * referer is also where a payload lands when an analytics plugin stores it raw. * * @param string $referer Raw header value. * @return string Referer with prose parameters dropped. */ public static function referer_for_inspection( string $referer ): string { $cut = strcspn( $referer, '?' ); if ( $cut >= strlen( $referer ) ) { return $referer; } $kept = array(); foreach ( explode( '&', substr( $referer, $cut + 1 ) ) as $pair ) { $eq = strpos( $pair, '=' ); $name = false === $eq ? $pair : substr( $pair, 0, $eq ); $name = rawurldecode( $name ); $name = preg_replace( '/\[.*$/', '', $name ); if ( self::is_content_param( (string) $name ) ) { continue; } $kept[] = $pair; } $head = substr( $referer, 0, $cut ); return empty( $kept ) ? $head : $head . '?' . implode( '&', $kept ); } /** * Every filename in $_FILES, however the form nested them. * * A single field is ['name' => 'x.jpg']; a multi-file field is * ['name' => ['x.jpg', 'y.jpg']]; a nested form field is deeper still. Only the * 'name' member is read, and only strings are returned. Depth is capped as flatten()'s * is, and for the same reason. * * @param array $files $_FILES, or a subtree of it. * @return array */ public static function file_names( array $files ): array { $out = array(); foreach ( $files as $field ) { if ( is_array( $field ) && array_key_exists( 'name', $field ) ) { $out = array_merge( $out, self::string_leaves( $field['name'], 0 ) ); } } return $out; } /** * Collects the string leaves of a value, depth-capped. * * @param mixed $node String, array or anything else. * @param int $depth Current depth. * @return array */ private static function string_leaves( $node, int $depth ): array { if ( is_string( $node ) ) { return '' === $node ? array() : array( $node ); } if ( ! is_array( $node ) || $depth > 6 ) { return array(); } $out = array(); foreach ( $node as $child ) { $out = array_merge( $out, self::string_leaves( $child, $depth + 1 ) ); } return $out; } /** * The raw request body, capped, when the request could carry XML or JSON. * * Gated, so this never reads php://input on the form-encoded or multipart request * that is nearly all write traffic; PHP has already parsed those into $_POST and * $_FILES. It is read when the content type says XML, when the path is xmlrpc.php * (real XML-RPC clients do not always send a content type), and for a POST, PUT or * PATCH whose content type is anything else, JSON, text/plain or absent, because * that is a body PHP has NOT parsed and which the parameter rules would otherwise * never see. WordPress's own xmlrpc.php and REST server parse php://input themselves * rather than through $_POST, which is exactly why a content rule over * inspectable_values() could never see these payloads however it was written. * * php://input is re-readable since PHP 5.6, so reading it here takes nothing from the * code that reads it afterwards. * * @since 1.10.666 * @return string Up to MAX_BODY_BYTES of the body, or '' when not worth reading. */ private static function raw_body(): string { // Same rationale as normalise_request(): plugins_loaded runs before wp_magic_quotes(), // so $unslash is false, and these feed stripos() only. $ctype = CSDT_Request::server( 'CONTENT_TYPE', 256, false ); $path = CSDT_Request::server( 'REQUEST_URI', self::MAX_URI_BYTES, false ); $method = strtoupper( CSDT_Request::server( 'REQUEST_METHOD', 16, false ) ); if ( '' === $method ) { $method = 'GET'; } $xml_ish = false !== stripos( $ctype, 'xml' ) || false !== stripos( $path, 'xmlrpc.php' ); $unparsed = in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true ) && false === stripos( $ctype, 'form-urlencoded' ) && false === stripos( $ctype, 'multipart/' ); if ( ! $xml_ish && ! $unparsed ) { return ''; // The common path: a few string searches, no stream ever opened. } if ( null !== self::$raw_body_override ) { return (string) self::$raw_body_override; // Tests only, see the property's docblock. } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- reading the request body, not a file; @ suppresses a notice if the stream is already closed, and a false return is handled on the next line either way $stream = @fopen( 'php://input', 'rb' ); if ( ! $stream ) { return ''; } $body = stream_get_contents( $stream, self::MAX_BODY_BYTES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing a raw input stream, not a file; WP_Filesystem has no equivalent for php://input fclose( $stream ); return is_string( $body ) ? $body : ''; } /** * Accumulates the cost of one inspection, and flushes it at most once a minute. * * The throttle is the whole design. Writing an option per request would make the * measurement cost several times more than the thing being measured, and under a scan, * exactly when the number matters, it would be a self-inflicted load spike on the most * expensive traffic on the site. So samples accumulate in a static and are written back * on the first request after the flush interval. * * Consequence, stated because it would otherwise look like a bug: the very last few * samples before a page load ends are lost, since there is no request afterwards to * flush them. For a rolling average over thousands of samples that does not matter, and * the alternative costs more than it buys. * * Keeps count, total and max. The max is the one that matters, an average hides the * pathological request, and a pathological request is what a regression looks like * first. * * @since 1.10.488 * @param int $micros Microseconds this inspection took. * @param int|null $now Unix time, for tests. */ public static function record_cost( int $micros, ?int $now = null ): void { if ( $micros < 0 ) { return; } $now = null === $now ? time() : $now; self::$perf_pending['n'] = ( self::$perf_pending['n'] ?? 0 ) + 1; self::$perf_pending['us'] = ( self::$perf_pending['us'] ?? 0 ) + $micros; self::$perf_pending['max'] = max( (int) ( self::$perf_pending['max'] ?? 0 ), $micros ); $stored = get_option( self::OPTION_PERF, array() ); $stored = is_array( $stored ) ? $stored : array(); $since = (int) ( $stored['flushed'] ?? 0 ); if ( $now - $since < self::PERF_FLUSH_SECONDS ) { return; } $samples = (int) ( $stored['n'] ?? 0 ) + (int) self::$perf_pending['n']; $total = (int) ( $stored['us'] ?? 0 ) + (int) self::$perf_pending['us']; $max = max( (int) ( $stored['max'] ?? 0 ), (int) self::$perf_pending['max'] ); // Halved once the sample count gets large, so the average tracks the CURRENT code // rather than averaging in a version from weeks ago. A figure that can never move is // a figure nobody can use to spot a regression. if ( $samples > 10000 ) { $samples = (int) ( $samples / 2 ); $total = (int) ( $total / 2 ); } update_option( self::OPTION_PERF, array( 'n' => $samples, 'us' => $total, 'max' => $max, 'flushed' => $now, ), false ); self::$perf_pending = array(); } /** * The recorded cost of inspection, for the panel. * * @since 1.10.488 * @return array{samples:int,avg_us:float,max_us:int}|null Null when nothing measured yet. */ public static function cost(): ?array { $stored = get_option( self::OPTION_PERF, array() ); if ( ! is_array( $stored ) || empty( $stored['n'] ) ) { return null; } $n = (int) $stored['n']; return array( 'samples' => $n, 'avg_us' => $n > 0 ? round( (int) ( $stored['us'] ?? 0 ) / $n, 1 ) : 0.0, 'max_us' => (int) ( $stored['max'] ?? 0 ), ); } /** * Feeds a repeat offender into the existing escalation ladder. * * ONE call per request, not one per matched rule. A single request that trips three * rules is one hostile request, and counting it three times would make the threshold * mean something different depending on how many rules happened to overlap. * * BLOCK MODE ONLY, and this is the important part. Log mode's whole promise, stated on * the panel in those words, is that nothing is refused. Escalating to a ban while in * Log mode would refuse someone on the strength of a mode that says it refuses nobody, * which is the product contradicting itself. An audit of this plugin's help text found * that exact fault twice, so it is asserted in tests rather than trusted to a comment. * * It delegates to CSDT_Auto_Block::record() rather than blocking anything itself, so * there is one ban policy, one allowlist, one escalating TTL and one audit trail * covering both the login blocker and the firewall. Inventing a second ban policy here * is precisely the mistake that produced four writers of csdt_ip_blocklist and 79 * immortal blocks. * * @since 1.10.488 * @param string $ip Client address. */ private static function escalate( string $ip ): void { if ( ! self::is_blocking() ) { return; } if ( ! is_callable( array( 'CSDT_Auto_Block', 'record' ) ) ) { return; } CSDT_Auto_Block::record( 'firewall', $ip ); } /** * Counts a 404 against the missing-page threshold. * * Hooked on `template_redirect`, which is where WordPress has decided the request is a * 404 and before anything is sent. A burst of 404s is the oldest scanner signature * there is, and it is the one signal here that needs no pattern at all. * * Same two guards as above and for the same reasons: Block mode only, and the shared * ladder rather than a private one. The threshold is deliberately generous * (DEFAULT_404_THRESHOLD) because a 404 has innocent causes a rule match does not, a * stale bookmark, a broken inbound link, an image a theme asks for and never shipped. * * @since 1.10.488 */ public static function count_404(): void { try { if ( ! function_exists( 'is_404' ) || ! is_404() ) { return; } if ( ! self::is_blocking() || self::is_exempt() ) { return; } if ( ! class_exists( 'CSDT_DevTools' ) || ! is_callable( array( 'CSDT_Auto_Block', 'record' ) ) ) { return; } $ip = (string) CSDT_DevTools::get_client_ip(); if ( '' === $ip ) { return; } CSDT_Auto_Block::record( 'fw_404', $ip ); } catch ( \Throwable $e ) { // A counter must never be able to break a page, least of all the 404 page. if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Strips the query string and fragment from a request URI. * * Split on the RAW string, before decoding. Doing it after would let an encoded `%3F` * in a path segment split the path early, and would let a `?` inside a parameter value * hide the rest of that value from the parameter rules. * * parse_url() is not used: it returns false on a URI it considers malformed, and a * malformed URI is precisely the case that must still be inspected rather than skipped. * * @since 1.10.483 * @param string $uri Raw REQUEST_URI. * @return string Path component, still encoded. */ public static function path_only( string $uri ): string { $cut = strcspn( $uri, '?#' ); return substr( $uri, 0, $cut ); } /** * Decodes percent-encoding twice. * * Once is not enough: `%252e%252e%252f` survives a single decode as `%2e%2e%2f` and * only becomes `../` on the second, which is the standard way a traversal payload * walks past a naive filter. */ public static function decode_twice( string $value ): string { return self::decode_overlong( self::decode_unicode_escapes( rawurldecode( rawurldecode( $value ) ) ) ); } /** * Folds IIS-style %uXXXX escapes to the UTF-8 character they name. * * rawurldecode() does not know this form, so `%u002e%u002e%u002f` survived both * decodes as literal text and no rule reading for `../` ever saw it. Applied after * percent-decoding so a double-encoded `%25u002e` is folded too. * * @since 1.10.845 * @param string $value Percent-decoded value. * @return string Value with %uXXXX folded. */ public static function decode_unicode_escapes( string $value ): string { if ( false === stripos( $value, '%u' ) ) { return $value; } return (string) preg_replace_callback( '/%u([0-9a-fA-F]{4})/', static function ( array $m ): string { $cp = (int) hexdec( $m[1] ); if ( $cp < 0x80 ) { return chr( $cp ); } if ( $cp < 0x800 ) { return chr( 0xC0 | ( $cp >> 6 ) ) . chr( 0x80 | ( $cp & 0x3F ) ); } return chr( 0xE0 | ( $cp >> 12 ) ) . chr( 0x80 | ( ( $cp >> 6 ) & 0x3F ) ) . chr( 0x80 | ( $cp & 0x3F ) ); }, $value ); } /** * Folds overlong UTF-8 encodings of ASCII back to the character they encode. * * `%c0%ae%c0%af` is `../` to a target that decodes it, and bytes a rule reading for * `../` never sees. Found by the Phase 1.3 corpus: traversal_path was bypassed by it * completely. * * This only ever touches sequences that are INVALID UTF-8 and therefore never appear in * legitimate content: `\xC0` and `\xC1` cannot begin a valid sequence at all, and * `\xE0` followed by `\x80`-`\x9F` is always an overlong form. A three-byte form is * folded only when it encodes ASCII, so a non-ASCII overlong is left alone rather than * wrapped by chr(). Ordinary accented text, `\xC3\xA1` for á, is untouched. * * @since 1.10.484 * @param string $value Decoded value. * @return string Value with overlong ASCII folded. */ public static function decode_overlong( string $value ): string { if ( '' === $value || ! preg_match( '/[\xC0\xC1\xE0]/', $value ) ) { return $value; // The common path pays one anchored scan and stops. } $value = (string) preg_replace_callback( '/[\xC0\xC1][\x80-\xBF]/', static function ( array $m ): string { // A C0/C1 lead can only ever encode 0x00-0x7F, so no range guard is needed. return chr( ( ( ord( $m[0][0] ) & 0x1F ) << 6 ) | ( ord( $m[0][1] ) & 0x3F ) ); }, $value ); return (string) preg_replace_callback( '/\xE0[\x80-\x9F][\x80-\xBF]/', static function ( array $m ): string { $cp = ( ( ord( $m[0][1] ) & 0x3F ) << 6 ) | ( ord( $m[0][2] ) & 0x3F ); return $cp < 0x80 ? chr( $cp ) : $m[0]; }, $value ); } /** * Flattens a parameter array into a list of key/value pairs. * * Nested arrays are walked, so a rule never has to recurse and cannot miss a payload * hidden one level down, which is exactly where a payload gets hidden. Depth is * capped: a deeply nested request must not be able to make this expensive. * * @param array $data Parameters. * @param string $source 'GET' or 'POST', kept for the log. * @param string $prefix Key path built during recursion. * @param int $depth Current depth. * @return array> */ public static function flatten( array $data, string $source, string $prefix = '', int $depth = 0 ): array { $out = array(); foreach ( $data as $key => $value ) { $path = '' === $prefix ? (string) $key : $prefix . '[' . $key . ']'; if ( is_array( $value ) && $depth >= self::FLATTEN_DEPTH ) { // Too deep to walk key by key. Folded into ONE value rather than dropped: // until this, a payload one level below the cap was never inspected by any // rule, and a batch request spends two of the levels before its sub-request // starts. Joined with a space, never a newline, so crlf_injection does not // read the joins as a header break. $value = implode( ' ', self::overflow_leaves( $value ) ); } if ( is_array( $value ) ) { $out = array_merge( $out, self::flatten( $value, $source, $path, $depth + 1 ) ); continue; } if ( is_scalar( $value ) || null === $value ) { // Two copies of each value. 'value' is decoded, and is what null_byte and // crlf_injection read, because those look for a byte normalisation would // fold away. 'norm' is what every pattern rule reads; see normalise_value(). $decoded = self::decode_twice( (string) $value ); $out[] = array( 'key' => $path, 'value' => $decoded, 'norm' => self::normalise_value( $decoded ), 'source' => $source, ); } } return $out; } /** * The scalar leaves of a too-deep subtree, iteratively and capped. * * A stack, not recursion, so nesting as deep as PHP's own max_input_nesting_level costs * no call depth; capped at FLATTEN_OVERFLOW_LEAVES leaves, so the joined value is bounded * by what PHP or json_decode() already accepted. * * @param array $node Subtree. * @return array */ private static function overflow_leaves( array $node ): array { $out = array(); $stack = array( $node ); $kept = 0; while ( ! empty( $stack ) && $kept < self::FLATTEN_OVERFLOW_LEAVES ) { $cur = array_pop( $stack ); foreach ( $cur as $child ) { if ( is_array( $child ) ) { $stack[] = $child; } elseif ( is_scalar( $child ) && '' !== (string) $child ) { $out[] = (string) $child; ++$kept; if ( $kept >= self::FLATTEN_OVERFLOW_LEAVES ) { break; } } } } return $out; } // ── Panel ──────────────────────────────────────────────────────────── /** The three modes, in the order they are offered. */ public static function mode_labels(): array { return array( self::MODE_OFF => __( 'Off: inspect nothing', 'cloudscale-cyber-devtools' ), self::MODE_LOG => __( 'Log only: record what would be refused, refuse nothing', 'cloudscale-cyber-devtools' ), self::MODE_BLOCK => __( 'Block: refuse matching requests', 'cloudscale-cyber-devtools' ), ); } /** * The sections the rules table is grouped into, in display order. * * Purely a presentation grouping over the 'section' each rule already carries; * evaluate() still runs every rule in default_rules() order regardless of this. Added * because every rule already carried a 'category' (traversal, sqli, xss, ...) that * nothing read, the panel listed all ten rules in definition order with no structure * at all, which is its own small version of the "declared but never used" fault this * plugin's build gates exist to catch elsewhere. * * @since 1.10.666 * @return array section slug => label. */ public static function section_labels(): array { return array( 'structural' => __( 'Request shape: inspected everywhere, no prose exemption', 'cloudscale-cyber-devtools' ), 'content' => __( 'Parameter content: prose fields excluded', 'cloudscale-cyber-devtools' ), 'body' => __( 'Request body: XML-RPC and other non-form payloads', 'cloudscale-cyber-devtools' ), ); } /** * Groups a rule set by section, for the panel table. * * A rule added through the csdt_firewall_rules filter with no 'section' key, a * site's own custom rule, lands in a final 'Other' group rather than disappearing * from the table or crashing the loop; refusing to render a rule is a worse failure * than rendering it under a generic heading. * * @since 1.10.666 * @param array> $rules As returned by rules() or default_rules(). * @return array>}> Keyed by * section slug, in section_labels() order followed by any unrecognised sections. */ public static function grouped_by_section( array $rules ): array { $labels = self::section_labels(); $groups = array(); foreach ( $rules as $id => $rule ) { $section = (string) ( $rule['section'] ?? '' ); if ( '' === $section || ! isset( $labels[ $section ] ) ) { $section = 'other'; } $groups[ $section ]['rules'][ $id ] = $rule; } $ordered = array(); foreach ( array_keys( $labels ) as $slug ) { if ( isset( $groups[ $slug ] ) ) { $ordered[ $slug ] = $groups[ $slug ]; $ordered[ $slug ]['label'] = $labels[ $slug ]; unset( $groups[ $slug ] ); } } if ( isset( $groups['other'] ) ) { $ordered['other'] = $groups['other']; $ordered['other']['label'] = __( 'Other', 'cloudscale-cyber-devtools' ); } return $ordered; } /** * Whether a mode change is safe to save, and why not if it is not. * * Pure, so the guard can be tested without a request, a database or a login. This is * the same shape as CSDT_Geo_Block::validate(): a change that would lock the * administrator out is REFUSED rather than warned about, because a warning next to a * button that still works is a warning nobody reads. * * The specific risk being guarded: this site publishes about security, so its own * traffic contains attack strings. If a rule has been matching the administrator's own * address in Log mode, promoting to Block would start refusing that administrator. The * log is exactly the evidence needed to say so, which is why Log mode exists before * Block does. * * @since 1.10.487 * @param string $mode Requested mode. * @param array $disabled Rule ids being switched off in the same save. * @param array $own_hits Rule ids that have matched the admin's own address. * @return string Empty when the change is safe, otherwise the reason to show. */ public static function validate_mode( string $mode, array $disabled, array $own_hits ): string { if ( ! array_key_exists( $mode, self::mode_labels() ) ) { // Refused, not coerced to a default. Silently storing something other than // what was asked for is how a control ends up not doing what its screen says. return __( 'That is not one of the three modes. Nothing was saved.', 'cloudscale-cyber-devtools' ); } if ( self::MODE_BLOCK !== $mode ) { return ''; // Off and Log cannot refuse anyone, so nothing to guard. } // A rule switched off in the same save cannot refuse anybody, so it is not a risk. $live = array_values( array_diff( $own_hits, $disabled ) ); if ( empty( $live ) ) { return ''; } return sprintf( /* translators: %s: comma-separated list of firewall rule ids. */ __( 'Not switched to Block. These rules have already matched your own address while in Log mode, so Block would start refusing you: %s. Switch those rules off in the list below, or fix what is triggering them, then try again.', 'cloudscale-cyber-devtools' ), implode( ', ', $live ) ); } /** * Keeps only rule ids that exist, and reports the ones that do not. * * An unknown id is refused rather than stored. Storing it would put a permanent entry * in the disabled list that disables nothing, and the panel would show a rule as * enabled while the option said otherwise. * * @since 1.10.487 * @param array $requested Requested rule ids. * @param array $known Ids that exist. * @return array{kept:array,unknown:array} */ public static function filter_rule_ids( array $requested, array $known ): array { $kept = array(); $unknown = array(); foreach ( $requested as $id ) { // Trim BEFORE the empty check: ' ' is not '', so a whitespace-only entry was // reported to the user as an unrecognised rule named " ", which is a refusal // nobody can act on. $id = trim( (string) $id ); if ( '' === $id ) { continue; } if ( in_array( $id, $known, true ) ) { $kept[] = $id; } else { $unknown[] = $id; } } return array( 'kept' => array_values( array_unique( $kept ) ), 'unknown' => array_values( array_unique( $unknown ) ), ); } /** * Rule ids that have matched the current administrator's own address. * * Read for the Block-mode guard above. Returns an empty list when the log is * unavailable, which is the fail-OPEN direction: refusing to let someone switch the * firewall on because the log is broken would be the wrong trade. * * @since 1.10.487 * @return array */ public static function own_matched_rules(): array { if ( ! class_exists( 'CSDT_Storage' ) || ! class_exists( 'CSDT_DevTools' ) ) { return array(); } $ip = (string) CSDT_DevTools::get_client_ip(); if ( '' === $ip ) { return array(); } $rows = CSDT_Storage::recent_matches( 500 ); if ( ! is_array( $rows ) ) { return array(); } $out = array(); foreach ( $rows as $row ) { if ( (string) ( $row['ip'] ?? '' ) === $ip ) { $out[] = (string) ( $row['rule_id'] ?? '' ); } } return array_values( array_unique( array_filter( $out ) ) ); } /** * Saves the mode and the per-rule switches. * * @since 1.10.487 */ public static function ajax_save(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $mode = sanitize_text_field( wp_unslash( $_POST['mode'] ?? '' ) ); $raw = sanitize_text_field( wp_unslash( $_POST['disabled'] ?? '' ) ); $wanted = array_filter( array_map( 'trim', explode( ',', $raw ) ) ); $known = array_keys( self::default_rules() ); $filtered = self::filter_rule_ids( $wanted, $known ); $own = self::own_matched_rules(); $raw_on = sanitize_text_field( wp_unslash( $_POST['enforced'] ?? '' ) ); $opt_in = array_keys( array_filter( self::default_rules(), static function ( $r ) { return ! empty( $r['opt_in'] ); } ) ); $enforce = array_values( array_intersect( $opt_in, array_filter( array_map( 'trim', explode( ',', $raw_on ) ) ) ) ); // Absent means "this save does not touch exceptions", so an older panel or a // caller that never knew about them cannot wipe the list by omission. $has_exc = isset( $_POST['exceptions'] ); $except = self::parse_exceptions( sanitize_textarea_field( wp_unslash( $_POST['exceptions'] ?? '' ) ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); return; } // Outside the try, deliberately. A refusal here is the guard working as designed, // and letting the catch-all rewrite a precise explanation into "Internal error" is // a mistake this codebase has already made once. if ( ! empty( $filtered['unknown'] ) ) { wp_send_json_error( sprintf( /* translators: %s: comma-separated list of unrecognised rule ids. */ __( 'Nothing was saved. These are not rules on this site: %s.', 'cloudscale-cyber-devtools' ), implode( ', ', $filtered['unknown'] ) ) ); return; } if ( $has_exc && ! empty( $except['errors'] ) ) { wp_send_json_error( __( 'Nothing was saved. Fix the exceptions first:', 'cloudscale-cyber-devtools' ) . ' ' . implode( ' ', $except['errors'] ) ); return; } $refusal = self::validate_mode( $mode, $filtered['kept'], $own ); if ( '' !== $refusal ) { wp_send_json_error( $refusal ); return; } update_option( self::OPTION_MODE, $mode, true ); update_option( self::OPTION_DISABLED_RULES, $filtered['kept'], false ); update_option( self::OPTION_ENFORCED_OPT_IN, $enforce, false ); if ( $has_exc ) { // Autoloaded: read on every inspected request, so it must not cost a query. update_option( self::OPTION_EXCEPTIONS, $except['kept'], true ); } wp_send_json_success( array( 'msg' => sprintf( /* translators: 1: mode name, 2: number of rules active. */ __( 'Saved. Mode is %1$s with %2$d rules active.', 'cloudscale-cyber-devtools' ), $mode, count( $known ) - count( $filtered['kept'] ) ), 'mode' => $mode, 'rules' => count( $known ) - count( $filtered['kept'] ), ) ); } /** * Renders the Request Firewall panel. * * States the two things that decide whether this feature is usable, in the panel * rather than only in the Explain modal: that an authenticated editor is not inspected * at all, and that Log mode refuses nobody. * * @since 1.10.487 */ public static function render_panel(): void { $mode = self::mode(); $rules = self::default_rules(); $disabled = (array) get_option( self::OPTION_DISABLED_RULES, array() ); $counts = class_exists( 'CSDT_Storage' ) ? CSDT_Storage::match_counts() : null; /* * 100, not 25. Asked 2026-08-18: "show the last 100 rows (with a More button at the bottom)". * Twenty-five covered barely two days on this site, which is short of the seven the panel's own * header advertises, so the table could not answer the question it was labelled with. * * All 100 are rendered; CSDT_Firewall::RECENT_VISIBLE of them are shown and the rest are * revealed by the More button with no request, because the rows are already on the page and a * round trip to show markup you already sent is waste. */ $recent = class_exists( 'CSDT_Storage' ) ? CSDT_Storage::recent_matches( 100 ) : null; $last = (int) get_option( self::OPTION_LAST_MATCH, 0 ); // NULL and empty mean different things and must read differently on screen. Null is // "the log is broken and this panel can tell you nothing"; empty is "a quiet week". // Showing an empty table for both is how a broken control looks healthy. $log_broken = ( null === $counts || null === $recent ); ?>
🧱 'Start in Log only, and stay there for a week', 'rec' => 'Read this first', 'html' => 'Log only records what would have been refused and refuses nothing. That is the shipped default, and it is not timidity: these rules have never been tested against your traffic, and a filter that refuses your own readers is worse than no filter at all.

Leave it in Log only, read the table below for a week, and promote it to Block once you recognise everything in the list as genuinely hostile. The panel will refuse to switch to Block if a rule has already matched your own address, and will name the rule.', ), array( 'name' => 'An authenticated attacker is NOT inspected', 'rec' => 'Honest limits', 'html' => 'Requests carrying a valid login cookie for anyone who can edit posts are not inspected at all. Not filtered leniently, not inspected.

This is a real limitation and it is deliberate. This plugin ships a syntax-highlighting code block, so legitimate post content on this site contains UNION SELECT, <script>, ../ and eval(. Inspecting authenticated requests would fire on every technical post you save. Given the choice between missing a signed-in attacker and breaking publishing, this misses the signed-in attacker.

What covers that gap instead: two-factor authentication, the hidden login page, brute-force blocking and file integrity checks. The firewall is for the unauthenticated internet.', ), array( 'name' => 'Why searches for attack strings are not matched', 'rec' => 'How it works', 'html' => 'Rules that look inside parameter values skip the fields where prose belongs, s, q, comment, title, excerpt and similar.

On a site that writes about security this is not optional. A reader searching for sql injection or php://filter is a reader, not an attacker, and refusing them is how a firewall gets switched off and stays off.

The rules that check the request shape, traversal in the path, a PHP file requested from uploads, a probe for /.env, a null byte, a self-identifying scanner, apply everywhere, because none of those has an innocent form.', ), array( 'name' => 'Repeat offenders, and why only in Block mode', 'rec' => 'How it works', 'html' => 'An address that trips rules over and over is a scanner working through a list, not a visitor. In Block mode those matches are counted, and once an address passes the threshold it is handed to the same auto-blocker that handles failed logins, the same allowlist, the same escalating ban length, the same audit trail. One ban policy, not two.

A burst of 404s is counted the same way and against a much more generous threshold, because a missing page has innocent causes a rule match does not: a stale bookmark, a broken inbound link, an image your theme asks for and never shipped.

None of this happens in Log only mode. Log only says it refuses nothing, and banning an address on the strength of it would make that untrue.', ), array( 'name' => 'A false positive: add an exception, do not switch the rule off', 'rec' => 'How it works', 'html' => 'If a rule matches something legitimate, a payment webhook posting HTML or a form that sends SQL-looking text, unticking the rule removes it from every request on the site. An exception is narrower: it lifts one rule on one path, and can be limited to one parameter, so the rest of that request and the rest of the site are still inspected.

Write one per line: xss_reflected /wp-json/payments/v1/webhook body_html. A path ending in * covers everything below it; * as the rule lifts every rule on that path. A path containing .. or // never gets an exception, because the router resolves those somewhere other than the prefix suggests. The Except button on a Recent Matches row fills in the rule and path for you.', ), array( 'name' => 'What it costs', 'rec' => 'Performance', 'html' => 'The panel shows the measured cost per inspected request, averaged and worst-case, so a rule added later that happens to be expensive is visible rather than felt as "the site got slower".

Most requests never reach the rules at all: the mode is one autoloaded option read, and a request with no query string and no form data has nothing to inspect. Signed-in editors are skipped before any matching happens.', ), array( 'name' => 'What is recorded, and what is never recorded', 'rec' => 'Privacy', 'html' => 'Each row holds the minute, the address, the country if Cloudflare supplied it, the rule, the action, the method and the path.

The query string and the request body are never stored. A matched payload is attacker-controlled by definition and frequently carries a password or token taken from somewhere already compromised, so keeping it would turn this log into a liability that outlives the attack.

Rows are deduplicated per minute with a hit counter, so a scan sending ten thousand requests adds a bounded number of rows rather than ten thousand. Without that, logging would be the amplification vector.', ), array( 'name' => 'What it does not do', 'rec' => 'Honest limits', 'html' => 'It is not a rule feed maintained against new vulnerabilities, and it does not virtual-patch a specific plugin flaw. It matches shapes that are hostile in general.

It also never inspects WP-CLI, scheduled tasks, or your own server calling itself, and it fails open: if anything in the engine errors, the request proceeds. A firewall that white-screens a site is worse than one that misses a request.', ), ) ); ?>
0 ) { printf( /* translators: %s: human-readable time since the last match, e.g. "2 hours". */ esc_html__( 'Last match was %s ago. Read the table below before promoting to Block.', 'cloudscale-cyber-devtools' ), esc_html( human_time_diff( $last ) ) ); } else { esc_html_e( 'Nothing has matched yet. That means no request has tripped a rule, not that the firewall is idle.', 'cloudscale-cyber-devtools' ); } ?>


$rule ) : ?>
>