'83iN1BHmLaF7A0qCQHx0/Ptwgxq9cUq1xuLRYM25Dyc=', ); /** Payload format this code understands. Anything else is refused whole. */ const FORMAT = 1; /** More rules than this is refused whole, not truncated. */ const MAX_RULES = 2000; /** Response ceiling. 2000 rules of a few hundred bytes each, base64 encoded, with headroom. */ const MAX_RESPONSE_BYTES = 4194304; /** Rule id prefix under which a match is recorded in the shared firewall log. */ const RULE_ID = 'vpatch'; /** The closed list of conditions. */ const CONDITIONS = array( 'any', 'present', 'not_int', 'not_slug', 'has_html', 'has_sql', 'has_traversal', 'has_url', 'has_php_ext' ); /** * Each condition's pattern, byte for byte the proxy verifier's VPATCH_CONDITION_PATTERNS. * tests/fixtures/vpatch-conditions.json pins both copies, so a rule the proxy approved * matches here exactly as it matched there. input: raw, ltrim or trim before matching. * match_when: some_value_matches, or some_value_fails (the value is not of the safe shape). */ const CONDITION_PATTERNS = array( 'any' => array( 'pattern' => null, 'input' => 'none', 'match_when' => 'always' ), 'present' => array( 'pattern' => null, 'input' => 'none', 'match_when' => 'present' ), 'not_int' => array( 'pattern' => '/^[0-9]{1,20}\z/', 'input' => 'raw', 'match_when' => 'some_value_fails' ), 'not_slug' => array( 'pattern' => '/^[A-Za-z0-9_-]{0,200}\z/', 'input' => 'raw', 'match_when' => 'some_value_fails' ), 'has_html' => array( 'pattern' => '/[<>]|javascript:|\bon\w+\s*=/i', 'input' => 'raw', 'match_when' => 'some_value_matches' ), 'has_sql' => array( 'pattern' => '/[\'";]|--|\/\*|union[\s(]+(?:(?:all|distinct)[\s(]+)?select|sleep\s*\(|benchmark\s*\(/i', 'input' => 'raw', 'match_when' => 'some_value_matches' ), 'has_traversal' => array( 'pattern' => '/\.\.\/|\.\.\\\\|\x00|(?:php|phar|file|expect):\/\/|data:/i', 'input' => 'raw', 'match_when' => 'some_value_matches' ), 'has_url' => array( 'pattern' => '/^(?:\/\/|[a-z][a-z0-9+.-]*:\/\/)/i', 'input' => 'ltrim', 'match_when' => 'some_value_matches' ), 'has_php_ext' => array( 'pattern' => '/\.(?:php[3-8]?|phtml|phar|pht|htaccess)\z/i', 'input' => 'trim', 'match_when' => 'some_value_matches' ), ); /** Methods a rule may name. */ const METHODS = array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ); /** The four severities. */ const SEVERITIES = array( 'low', 'medium', 'high', 'critical' ); /** Fields a rule carries, exactly. A rule with any other field is dropped. */ const RULE_FIELDS = array( 'id', 'cve', 'title', 'severity', 'component', 'surface', 'methods', 'anonymous_only', 'param', 'condition', 'approved_at' ); /** * REST namespaces and ajax actions that belong to WordPress itself. A rule is for one * component's surface, so one naming core's is dropped, whatever it says it fixes. */ const CORE_REST_NAMESPACES = array( 'wp', 'oembed', 'wp-site-health', 'wp-block-editor', 'batch', 'wp-abilities' ); const CORE_AJAX_ACTIONS = array( 'heartbeat' ); /** Scalar leaves read from one parameter, at most. */ const MAX_LEAVES = 256; /** Nesting walked inside one parameter. */ const MAX_DEPTH = 8; /** * Test-only replacement for PUBLIC_KEYS. Null in production, always: a class constant * cannot be swapped from a test, and the signature tests need a throwaway keypair. * * @var array|null */ public static $public_keys_override = null; /** * Test-only request body, standing in for php://input. Null in production. * * @var string|null */ public static $body_override = null; // ── Mode ───────────────────────────────────────────────────────────── /** The three modes, in the order they are offered. */ public static function mode_labels(): array { return array( self::MODE_OFF => __( 'Off: fetch nothing, check nothing', 'cloudscale-cyber-devtools' ), self::MODE_LOG => __( 'Log only: record what a patch would refuse, refuse nothing', 'cloudscale-cyber-devtools' ), self::MODE_BLOCK => __( 'Block: refuse requests a patch matches', 'cloudscale-cyber-devtools' ), ); } /** Current mode, validated against the constants (this runs before init). */ public static function mode(): string { $mode = (string) get_option( self::OPTION_MODE, self::DEFAULT_MODE ); return in_array( $mode, array( self::MODE_OFF, self::MODE_LOG, self::MODE_BLOCK ), true ) ? $mode : self::MODE_OFF; } /** Whether anything is fetched or checked at all. */ public static function is_enabled(): bool { return self::MODE_OFF !== self::mode(); } /** The verified feed as stored, or an empty array before the first successful refresh. */ public static function feed(): array { $feed = get_option( self::OPTION_FEED, array() ); return is_array( $feed ) ? $feed : array(); } /** The applicable rules as stored. */ public static function applicable_rules(): array { $rules = get_option( self::OPTION_APPLICABLE, array() ); return is_array( $rules ) ? $rules : array(); } /** The public keys in force. */ public static function public_keys(): array { return is_array( self::$public_keys_override ) ? self::$public_keys_override : self::PUBLIC_KEYS; } // ── Envelope ───────────────────────────────────────────────────────── /** * Verifies and decodes a feed envelope. * * Returns the payload with its rules validated, or a string naming why the whole feed is * refused. Pure apart from reading the public keys, so every refusal is testable offline. * * @param mixed $envelope Decoded JSON response: array{key_id,payload,sig}. * @param int $min_serial Serial already stored; a lower one is a rollback. * @return array{serial:int,generated_at:string,key_id:string,rules:array,dropped:int,received:int}|string */ public static function verify_envelope( $envelope, int $min_serial = 0 ) { if ( ! is_array( $envelope ) ) { return 'the response was not a feed envelope'; } $key_id = $envelope['key_id'] ?? null; $b64 = $envelope['payload'] ?? null; $sig64 = $envelope['sig'] ?? null; if ( ! is_string( $key_id ) || ! is_string( $b64 ) || ! is_string( $sig64 ) ) { return 'the envelope is missing key_id, payload or sig'; } $keys = self::public_keys(); if ( ! isset( $keys[ $key_id ] ) ) { return 'the feed is signed with a key this plugin does not know (' . substr( preg_replace( '/[^A-Za-z0-9_-]/', '', $key_id ), 0, 20 ) . ')'; } $pk = base64_decode( (string) $keys[ $key_id ], true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding a public key compiled into this file, not obfuscated code $sig = base64_decode( $sig64, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- the feed's detached Ed25519 signature is transported as base64 $payload = base64_decode( $b64, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- the signed payload is transported as base64 so its exact bytes survive JSON if ( false === $pk || 32 !== strlen( $pk ) ) { return 'the embedded public key is malformed'; } if ( false === $sig || 64 !== strlen( $sig ) || false === $payload || '' === $payload ) { return 'the payload or signature does not decode'; } if ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) { return 'this server cannot verify Ed25519 signatures'; } try { $valid = sodium_crypto_sign_verify_detached( $sig, $payload, $pk ); } catch ( \Throwable $e ) { $valid = false; } if ( true !== $valid ) { return 'the signature does not match the payload'; } $data = json_decode( $payload, true, 32 ); if ( ! is_array( $data ) ) { return 'the signed payload is not JSON'; } if ( ! isset( $data['format'] ) || self::FORMAT !== $data['format'] ) { return 'the feed format is not one this plugin understands'; } if ( ! isset( $data['serial'] ) || ! is_int( $data['serial'] ) || $data['serial'] < 0 ) { return 'the feed has no serial'; } if ( $data['serial'] < $min_serial ) { return sprintf( 'the feed serial (%d) is older than the one already stored (%d)', $data['serial'], $min_serial ); } if ( ! isset( $data['rules'] ) || ! is_array( $data['rules'] ) || ! self::is_list( $data['rules'] ) ) { return 'the feed has no rule list'; } if ( count( $data['rules'] ) > self::MAX_RULES ) { return sprintf( 'the feed carries %d rules, more than the %d allowed', count( $data['rules'] ), self::MAX_RULES ); } $rules = array(); $seen = array(); foreach ( $data['rules'] as $raw ) { $rule = self::validate_rule( $raw ); if ( null === $rule || isset( $seen[ $rule['id'] ] ) ) { continue; } $seen[ $rule['id'] ] = true; $rules[] = $rule; } $generated = isset( $data['generated_at'] ) && is_string( $data['generated_at'] ) && self::is_iso_utc( $data['generated_at'] ) ? $data['generated_at'] : ''; return array( 'serial' => $data['serial'], 'generated_at' => $generated, 'key_id' => $key_id, 'rules' => $rules, 'received' => count( $data['rules'] ), 'dropped' => count( $data['rules'] ) - count( $rules ), ); } /** * One rule, validated against the contract, or null when it fails anything. * * Strict on purpose: an unknown field, a wrong type, a surface outside the component's own * directory, a core namespace, or a condition that does not fit its parameter all drop the * rule. Nothing is trimmed, lowercased or defaulted into shape. * * @param mixed $r Rule as decoded. * @return array|null */ public static function validate_rule( $r ): ?array { if ( ! is_array( $r ) || count( $r ) !== count( self::RULE_FIELDS ) ) { return null; } foreach ( self::RULE_FIELDS as $f ) { if ( ! array_key_exists( $f, $r ) ) { return null; } } $id = $r['id']; $cve = $r['cve']; $title = $r['title']; if ( ! is_string( $id ) || 1 !== preg_match( '/^vp-[A-Za-z0-9-]{3,60}$/D', $id ) ) { return null; } if ( ! is_string( $cve ) || ( '' !== $cve && 1 !== preg_match( '/^CVE-\d{4}-\d{4,7}$/D', $cve ) ) ) { return null; } if ( ! is_string( $title ) || '' === $title || 1 !== preg_match( '//u', $title ) ) { return null; } $len = function_exists( 'mb_strlen' ) ? mb_strlen( $title, 'UTF-8' ) : strlen( $title ); if ( $len > 140 || 1 === preg_match( '/[<>\x00-\x1F\x7F]/', $title ) ) { return null; } if ( ! is_string( $r['severity'] ) || ! in_array( $r['severity'], self::SEVERITIES, true ) ) { return null; } $c = $r['component']; if ( ! is_array( $c ) || 4 !== count( $c ) || ! isset( $c['type'], $c['slug'], $c['affected_from'], $c['fixed_in'] ) ) { return null; } if ( ! is_string( $c['type'] ) || ! in_array( $c['type'], array( 'plugin', 'theme' ), true ) ) { return null; } if ( ! is_string( $c['slug'] ) || 1 !== preg_match( '/^[a-z0-9][a-z0-9-]{0,99}$/D', $c['slug'] ) ) { return null; } if ( ! self::is_version( $c['affected_from'] ) || ! self::is_version( $c['fixed_in'] ) ) { return null; } if ( version_compare( $c['affected_from'], $c['fixed_in'], '>=' ) ) { return null; // A range nothing can be inside. } $surface = self::validate_surface( $r['surface'], $c['type'], $c['slug'] ); if ( null === $surface ) { return null; } $methods = $r['methods']; if ( ! is_array( $methods ) || ! self::is_list( $methods ) ) { return null; } foreach ( $methods as $m ) { if ( ! is_string( $m ) || ! in_array( $m, self::METHODS, true ) ) { return null; } } if ( count( array_unique( $methods ) ) !== count( $methods ) ) { return null; } if ( ! is_bool( $r['anonymous_only'] ) ) { return null; } $param = $r['param']; $condition = $r['condition']; if ( ! is_string( $param ) || ( '' !== $param && 1 !== preg_match( '/^[A-Za-z0-9_\-\[\]]{1,64}$/D', $param ) ) ) { return null; } if ( ! is_string( $condition ) || ! in_array( $condition, self::CONDITIONS, true ) ) { return null; } // `any` is about the surface alone, every other condition is about one parameter. if ( ( 'any' === $condition ) !== ( '' === $param ) ) { return null; } if ( ! is_string( $r['approved_at'] ) || ! self::is_iso_utc( $r['approved_at'] ) ) { return null; } return array( 'id' => $id, 'cve' => $cve, 'title' => $title, 'severity' => $r['severity'], 'component' => array( 'type' => $c['type'], 'slug' => $c['slug'], 'affected_from' => $c['affected_from'], 'fixed_in' => $c['fixed_in'], ), 'surface' => $surface, 'methods' => array_values( $methods ), 'anonymous_only' => $r['anonymous_only'], 'param' => $param, 'condition' => $condition, 'approved_at' => $r['approved_at'], ); } /** * A surface, validated, or null. * * @param mixed $s Surface as decoded. * @param string $type Component type. * @param string $slug Component slug. * @return array|null */ private static function validate_surface( $s, string $type, string $slug ): ?array { if ( ! is_array( $s ) || 2 !== count( $s ) || ! isset( $s['kind'] ) || ! is_string( $s['kind'] ) ) { return null; } if ( 'ajax' === $s['kind'] ) { $a = $s['action'] ?? null; if ( ! is_string( $a ) || 1 !== preg_match( '/^[A-Za-z0-9_.-]{1,100}$/D', $a ) || in_array( $a, self::CORE_AJAX_ACTIONS, true ) ) { return null; } return array( 'kind' => 'ajax', 'action' => $a ); } if ( 'rest' === $s['kind'] ) { $route = $s['route'] ?? null; if ( ! is_string( $route ) || 1 !== preg_match( '#^/[A-Za-z0-9_./-]{1,200}$#D', $route ) || self::has_bad_segment( $route ) ) { return null; } $ns = strtolower( explode( '/', trim( $route, '/' ) )[0] ); if ( '' === $ns || in_array( $ns, self::CORE_REST_NAMESPACES, true ) ) { return null; } return array( 'kind' => 'rest', 'route' => $route ); } if ( 'path' === $s['kind'] ) { $path = $s['path'] ?? null; if ( ! is_string( $path ) || 1 !== preg_match( '#^/[A-Za-z0-9_./-]{1,250}$#D', $path ) || self::has_bad_segment( $path ) ) { return null; } $root = '/wp-content/' . ( 'plugin' === $type ? 'plugins' : 'themes' ) . '/' . $slug . '/'; if ( 0 !== strpos( $path, $root ) || strlen( $path ) === strlen( $root ) ) { return null; } return array( 'kind' => 'path', 'path' => $path ); } return null; } /** Whether a path carries `..`, a `.` segment or an empty segment, none of which a request path keeps. */ private static function has_bad_segment( string $path ): bool { return false !== strpos( $path, '..' ) || false !== strpos( $path, '//' ) || 1 === preg_match( '#/\.(?:/|$)#', $path ); } /** A version string: starts with a digit, then digits, letters and . + _ -, at most 40 characters. */ private static function is_version( $v ): bool { return is_string( $v ) && 1 === preg_match( '/^[0-9][0-9A-Za-z.+_-]{0,39}$/D', $v ); } /** ISO 8601 in UTC, the shape the proxy writes: 2026-10-21T10:00:00Z, optional fraction. */ private static function is_iso_utc( string $v ): bool { return 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/D', $v ); } /** Whether an array is a list (keys 0..n-1 in order). array_is_list() is PHP 8.1. */ private static function is_list( array $a ): bool { return array_keys( $a ) === range( 0, count( $a ) - 1 ) || array() === $a; } // ── Applicability ──────────────────────────────────────────────────── /** * The rules whose component is installed here at a version inside [affected_from, fixed_in). * * Installed, not only active: a rule on a `path` surface concerns a file the web server * serves whether or not WordPress has the plugin switched on. * * @param array $rules Validated rules. * @param array $installed array{plugin:array,theme:array} slug => version. * @return array> */ public static function applicable( array $rules, array $installed ): array { $out = array(); foreach ( $rules as $rule ) { $c = $rule['component'] ?? array(); $type = (string) ( $c['type'] ?? '' ); $slug = (string) ( $c['slug'] ?? '' ); $version = (string) ( $installed[ $type ][ $slug ] ?? '' ); if ( '' === $version ) { continue; // Not installed, or no version to judge by. } if ( version_compare( $version, (string) $c['affected_from'], '>=' ) && version_compare( $version, (string) $c['fixed_in'], '<' ) ) { $rule['installed_version'] = $version; $out[] = $rule; } } return $out; } /** * Plugins and themes installed here, slug => version. * * @return array{plugin:array,theme:array} */ public static function installed_components(): array { $out = array( 'plugin' => array(), 'theme' => array(), ); if ( ! function_exists( 'get_plugins' ) && class_exists( 'CSDT_Paths' ) && is_readable( CSDT_Paths::root_file( 'wp-admin/includes/plugin.php' ) ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( function_exists( 'get_plugins' ) ) { foreach ( (array) get_plugins() as $file => $data ) { $file = (string) $file; $slug = false !== strpos( $file, '/' ) ? explode( '/', $file )[0] : basename( $file, '.php' ); $ver = (string) ( $data['Version'] ?? '' ); if ( '' !== $slug && '' !== $ver && ! isset( $out['plugin'][ $slug ] ) ) { $out['plugin'][ $slug ] = $ver; } } } if ( function_exists( 'wp_get_themes' ) ) { foreach ( (array) wp_get_themes() as $stylesheet => $theme ) { $ver = is_object( $theme ) && method_exists( $theme, 'get' ) ? (string) $theme->get( 'Version' ) : ''; if ( '' !== $ver ) { $out['theme'][ (string) $stylesheet ] = $ver; } } } return $out; } /** * Recomputes the applicable subset from the stored feed. Hooked, with accepted_args 0, to * the events that change what is installed, and run after every refresh. * * @return void */ public static function recompute(): void { try { $feed = self::feed(); $rules = isset( $feed['rules'] ) && is_array( $feed['rules'] ) ? $feed['rules'] : array(); $apply = empty( $rules ) ? array() : self::applicable( $rules, self::installed_components() ); if ( $apply !== self::applicable_rules() ) { update_option( self::OPTION_APPLICABLE, $apply, true ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } // ── Refresh ────────────────────────────────────────────────────────── /** * Fetches, verifies and stores the feed. WP-Cron callback and the Refresh button. * * Any failure keeps the previous feed and records why. Never fetches while Off. * * @return bool True when a verified feed was stored. */ public static function refresh(): bool { if ( ! self::is_enabled() ) { return false; } $resp = wp_remote_get( self::ENDPOINT, array( 'timeout' => 20, 'sslverify' => true, 'user-agent' => CSDT_DevTools::USER_AGENT, 'headers' => array( 'Accept' => 'application/json' ), 'limit_response_size' => self::MAX_RESPONSE_BYTES, ) ); $r = CSDT_Http::json( $resp, self::MAX_RESPONSE_BYTES ); if ( 0 === $r['code'] ) { return self::fail( (string) $r['error'] ); } if ( 200 !== $r['code'] ) { /* translators: %d: HTTP status code returned by the feed. */ return self::fail( sprintf( __( 'Feed returned HTTP %d.', 'cloudscale-cyber-devtools' ), (int) $r['code'] ) ); } return self::store( $r['data'] ); } /** * Verifies an envelope and, when it passes, stores it and recomputes what applies. * * Separate from refresh() so the tests can drive every refusal without a network. * * @param mixed $envelope Decoded response. * @param int|null $now Unix time, for tests. * @return bool */ public static function store( $envelope, ?int $now = null ): bool { $old = self::feed(); $result = self::verify_envelope( $envelope, (int) ( $old['serial'] ?? 0 ) ); if ( ! is_array( $result ) ) { /* translators: %s: why the feed was refused, in plain words. */ return self::fail( sprintf( __( 'Feed refused: %s. The previous feed is still in use.', 'cloudscale-cyber-devtools' ), $result ) ); } $result['fetched_at'] = null === $now ? time() : $now; update_option( self::OPTION_FEED, $result, false ); update_option( self::OPTION_LAST_ERROR, '', false ); self::recompute(); return true; } /** Records a refresh failure and returns false. */ private static function fail( string $why ): bool { update_option( self::OPTION_LAST_ERROR, $why, false ); return false; } // ── Matching ───────────────────────────────────────────────────────── /** * Whether one value meets a condition. The patterns of the contract, and the only place * they live in this plugin. Tested against tests/fixtures/vpatch-conditions.json, which the * proxy's verifier is tested against too. * * `any` and `present` are decided before any value is looked at, so for them this answers * true for every value. * * @param string $condition One of CONDITIONS. * @param string $value One scalar leaf of the parameter. * @return bool */ public static function condition_matches( string $condition, string $value ): bool { $spec = self::CONDITION_PATTERNS[ $condition ] ?? null; if ( null === $spec ) { return false; } if ( null === $spec['pattern'] ) { return true; } if ( 'ltrim' === $spec['input'] ) { $value = ltrim( $value ); } elseif ( 'trim' === $spec['input'] ) { $value = trim( $value ); } $hit = 1 === preg_match( $spec['pattern'], $value ); return 'some_value_fails' === $spec['match_when'] ? ! $hit : $hit; } /** * The request, reduced to what a rule can look at. * * @param array $get Raw query parameters. * @param array $post Raw form parameters. * @param string $uri REQUEST_URI. * @param string $method REQUEST_METHOD. * @param bool $logged_in Whether a logged-in cookie is present. * @param string $override X-HTTP-Method-Override header. * @return array */ public static function build_request( array $get, array $post, string $uri, string $method, bool $logged_in, string $override = '' ): array { $path = self::normalise_path( $uri ); $ajax = ''; if ( 1 === preg_match( '#/admin-ajax\.php$#', $path ) ) { $ajax = array(); foreach ( array( $get, $post ) as $src ) { if ( isset( $src['action'] ) && is_string( $src['action'] ) ) { $ajax[] = $src['action']; } } } $routes = array(); foreach ( array( $post, $get ) as $src ) { if ( isset( $src['rest_route'] ) && is_string( $src['rest_route'] ) ) { $routes[] = self::clean_route( $src['rest_route'] ); } } if ( empty( $routes ) ) { $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 = strpos( $path, '/' . $prefix . '/' ); if ( false !== $at ) { $routes[] = self::clean_route( substr( $path, $at + strlen( $prefix ) + 1 ) ); } } } $method = strtoupper( '' === $method ? 'GET' : $method ); $methods = array( $method ); if ( 'HEAD' === $method ) { $methods[] = 'GET'; // WordPress answers HEAD with the GET handler. } if ( ! empty( $routes ) ) { // The REST server lets ?_method= and X-HTTP-Method-Override change the method of a // POST, so a rule limited to DELETE must see a POST that becomes one. foreach ( array( $get['_method'] ?? null, $override ) as $o ) { if ( is_string( $o ) && '' !== $o ) { $methods[] = strtoupper( $o ); } } } return array( 'path' => $path, 'ajax' => is_array( $ajax ) ? $ajax : array(), 'routes' => array_values( array_unique( array_filter( $routes ) ) ), 'methods' => array_values( array_unique( $methods ) ), 'logged_in' => $logged_in, 'get' => $get, 'post' => $post, ); } /** A REST route with one leading slash and no trailing one, as the server compares it. */ private static function clean_route( string $route ): string { $route = '/' . ltrim( $route, '/' ); return '/' === $route ? '' : rtrim( $route, '/' ); } /** * A request path as the web server resolves it: query cut, percent-decoded once, repeated * slashes folded, and `.` and `..` segments resolved. * * @param string $uri REQUEST_URI. * @return string */ public static function normalise_path( string $uri ): string { $path = rawurldecode( substr( $uri, 0, strcspn( $uri, '?#' ) ) ); $path = (string) preg_replace( '#/{2,}#', '/', str_replace( '\\', '/', $path ) ); $out = array(); foreach ( explode( '/', $path ) as $seg ) { if ( '.' === $seg ) { continue; } if ( '..' === $seg ) { array_pop( $out ); continue; } $out[] = $seg; } $joined = implode( '/', $out ); return '' === $joined || '/' !== $joined[0] ? '/' . ltrim( $joined, '/' ) : $joined; } /** * Whether a rule's surface, method and login test fit the request. No parameter is read. * * @param array $rule Validated rule. * @param array $req From build_request(). * @return bool */ public static function surface_matches( array $rule, array $req ): bool { if ( ! empty( $rule['anonymous_only'] ) && ! empty( $req['logged_in'] ) ) { return false; } $methods = (array) ( $rule['methods'] ?? array() ); if ( ! empty( $methods ) && array() === array_intersect( $methods, (array) $req['methods'] ) ) { return false; } $s = (array) ( $rule['surface'] ?? array() ); switch ( $s['kind'] ?? '' ) { case 'ajax': return in_array( (string) $s['action'], (array) $req['ajax'], true ); case 'rest': $want = strtolower( (string) $s['route'] ); foreach ( (array) $req['routes'] as $route ) { $route = strtolower( (string) $route ); if ( $route === $want || 0 === strpos( $route, $want . '/' ) ) { return true; } } return false; case 'path': return (string) $s['path'] === (string) $req['path']; } return false; } /** * Whether a rule matches, surface and parameter both. * * @param array $rule Validated rule. * @param array $req From build_request(). * @param array|null $json Decoded JSON body, for a REST rule; null when there is none. * @return bool */ public static function rule_matches( array $rule, array $req, ?array $json = null ): bool { if ( ! self::surface_matches( $rule, $req ) ) { return false; } $condition = (string) $rule['condition']; if ( 'any' === $condition ) { return true; } $sources = array( (array) $req['get'], (array) $req['post'] ); if ( 'rest' === ( $rule['surface']['kind'] ?? '' ) && is_array( $json ) ) { $sources[] = $json; } list( $present, $values ) = self::param_values( (string) $rule['param'], $sources ); if ( ! $present ) { return false; } if ( 'present' === $condition ) { return true; } foreach ( $values as $v ) { if ( self::condition_matches( $condition, $v ) ) { return true; } } return false; } /** * Whether a parameter is present in any source, and its scalar leaves. * * `name[a][b]` addresses a nested key; an empty `[]` segment means every child. Arrays are * flattened to their scalar leaves, capped at MAX_LEAVES and MAX_DEPTH. true reads as "1", * false and null as "", as PHP would cast them. * * @param string $param Parameter name. * @param array $sources Arrays to read, in order. * @return array{0:bool,1:array} */ public static function param_values( string $param, array $sources ): array { $base = substr( $param, 0, strcspn( $param, '[' ) ); $segs = array(); if ( preg_match_all( '/\[([^\[\]]*)\]/', substr( $param, strlen( $base ) ), $m ) ) { $segs = $m[1]; } $present = false; $values = array(); foreach ( $sources as $src ) { if ( ! is_array( $src ) || '' === $base || ! array_key_exists( $base, $src ) ) { continue; } $nodes = array( $src[ $base ] ); foreach ( $segs as $seg ) { $next = array(); foreach ( $nodes as $node ) { if ( ! is_array( $node ) ) { continue; } if ( '' === $seg ) { foreach ( $node as $child ) { $next[] = $child; } } elseif ( array_key_exists( $seg, $node ) ) { $next[] = $node[ $seg ]; } } $nodes = $next; } if ( empty( $nodes ) ) { continue; } $present = true; foreach ( $nodes as $node ) { self::leaves( $node, 0, $values ); } } return array( $present, $values ); } /** * Appends the scalar leaves of a node. * * @param mixed $node Value. * @param int $depth Current depth. * @param array $out Collected leaves. */ private static function leaves( $node, int $depth, array &$out ): void { if ( count( $out ) >= self::MAX_LEAVES ) { return; } if ( is_array( $node ) ) { if ( $depth >= self::MAX_DEPTH ) { return; } foreach ( $node as $child ) { self::leaves( $child, $depth + 1, $out ); } return; } if ( is_bool( $node ) ) { $out[] = $node ? '1' : ''; } elseif ( null === $node ) { $out[] = ''; } elseif ( is_scalar( $node ) ) { $out[] = (string) $node; } } /** * The rules this request matches, after the firewall's exceptions. * * @param array $rules Applicable rules. * @param array $req From build_request(). * @param array $lifted From CSDT_Firewall::exceptions_for_path(). * @param callable $json Returns the decoded JSON body or null; called at most once, * and only when a REST rule with a parameter reaches it. * @return array> */ public static function matches( array $rules, array $req, array $lifted, callable $json ): array { foreach ( array( '*', self::RULE_ID ) as $key ) { if ( isset( $lifted[ $key ] ) && true === $lifted[ $key ] ) { return array(); } } $lifted_params = array(); foreach ( array( '*', self::RULE_ID ) as $key ) { if ( isset( $lifted[ $key ] ) && is_array( $lifted[ $key ] ) ) { $lifted_params = array_merge( $lifted_params, $lifted[ $key ] ); } } $out = array(); $body = null; $body_got = false; foreach ( $rules as $rule ) { if ( ! is_array( $rule ) || ! self::surface_matches( $rule, $req ) ) { continue; } $param = strtolower( (string) ( $rule['param'] ?? '' ) ); if ( '' !== $param ) { foreach ( $lifted_params as $name ) { $name = (string) $name; if ( $param === $name || 0 === strpos( $param, $name . '[' ) ) { continue 2; } } } if ( 'rest' === ( $rule['surface']['kind'] ?? '' ) && '' !== $param && ! $body_got ) { $body = $json(); $body_got = true; } if ( self::rule_matches( $rule, $req, is_array( $body ) ? $body : null ) ) { $out[] = $rule; } } return $out; } // ── Enforcement ────────────────────────────────────────────────────── /** * Hooked to `plugins_loaded` priority 3, beside the request firewall. * * Two autoloaded reads on every request, and nothing more when no rule applies here. * * @return void */ public static function enforce(): void { try { if ( ! self::is_enabled() ) { return; } $rules = self::applicable_rules(); if ( empty( $rules ) ) { return; } if ( class_exists( 'CSDT_Firewall' ) && CSDT_Firewall::is_exempt() ) { return; } $input = CSDT_Firewall::request_input(); $uri = CSDT_Request::server( 'REQUEST_URI', 8192, false ); $req = self::build_request( $input['get'], $input['post'], $uri, CSDT_Request::server( 'REQUEST_METHOD', 16, false ), CSDT_Request::has_login_cookie(), CSDT_Request::server( 'HTTP_X_HTTP_METHOD_OVERRIDE', 16, false ) ); $candidates = array_values( array_filter( $rules, static function ( $r ) use ( $req ) { return is_array( $r ) && self::surface_matches( $r, $req ); } ) ); if ( empty( $candidates ) ) { return; } $lifted = CSDT_Firewall::exceptions_for_path( CSDT_Firewall::decode_twice( CSDT_Firewall::path_only( $uri ) ), CSDT_Firewall::exceptions() ); $matched = self::matches( $candidates, $req, $lifted, array( __CLASS__, 'json_body' ) ); if ( empty( $matched ) ) { return; } $blocking = self::MODE_BLOCK === self::mode(); foreach ( $matched as $rule ) { self::record( (string) $rule['id'], $blocking ? 'blocked' : 'logged' ); } if ( $blocking ) { // Set here as well: core's XML-RPC die handler never sets a status. status_header( 403 ); wp_die( esc_html__( 'Access denied.', 'cloudscale-cyber-devtools' ), '', array( 'response' => 403 ) ); } } catch ( \Throwable $e ) { // Fails OPEN, like every control on this path. if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * The request body decoded as a JSON object, or null. * * @return array|null */ public static function json_body(): ?array { $body = null !== self::$body_override ? self::$body_override : CSDT_Firewall::request_body(); if ( '' === $body ) { return null; } $data = json_decode( $body, true, 16 ); return is_array( $data ) && ! self::is_list( $data ) ? $data : null; } /** The id under which a rule's match is logged: vpatch:, within the log's 64 characters. */ public static function log_rule_id( string $id ): string { return substr( self::RULE_ID . ':' . $id, 0, 64 ); } /** Records a match in the firewall log, in its own try/catch so a log fault never costs the block. */ private static function record( string $id, string $action ): void { try { $ip = class_exists( 'CSDT_DevTools' ) ? (string) CSDT_DevTools::get_client_ip() : ''; if ( '' !== $ip && class_exists( 'CSDT_Storage' ) ) { CSDT_Storage::record_match( $ip, self::log_rule_id( $id ), $action ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } // ── Save / panel ───────────────────────────────────────────────────── /** Saves the mode. Same nonce and capability shape as the firewall's own save. */ 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 ); } $mode = sanitize_text_field( wp_unslash( $_POST['mode'] ?? '' ) ); if ( ! in_array( $mode, array( self::MODE_OFF, self::MODE_LOG, self::MODE_BLOCK ), true ) ) { wp_send_json_error( __( 'Nothing was saved. Unknown mode.', 'cloudscale-cyber-devtools' ) ); return; } update_option( self::OPTION_MODE, $mode, true ); $msg = __( 'Saved.', 'cloudscale-cyber-devtools' ); if ( self::MODE_OFF === $mode ) { $msg = __( 'Saved. Virtual patching is off and nothing is fetched.', 'cloudscale-cyber-devtools' ); } elseif ( empty( self::feed() ) ) { $msg = __( 'Saved. Click Refresh now to fetch the feed.', 'cloudscale-cyber-devtools' ); } wp_send_json_success( array( 'msg' => $msg ) ); } /** The Refresh now button. */ public static function ajax_refresh(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } if ( ! self::is_enabled() ) { wp_send_json_error( __( 'Virtual patching is off, so nothing is fetched. Choose Log only or Block and save first.', 'cloudscale-cyber-devtools' ) ); return; } if ( self::refresh() ) { $feed = self::feed(); wp_send_json_success( array( 'msg' => sprintf( /* translators: 1: feed serial, 2: rules in the feed, 3: rules that apply to this site. */ __( 'Refreshed: feed %1$d, %2$d rules, %3$d apply here.', 'cloudscale-cyber-devtools' ), (int) ( $feed['serial'] ?? 0 ), count( (array) ( $feed['rules'] ?? array() ) ), count( self::applicable_rules() ) ), ) ); return; } $err = (string) get_option( self::OPTION_LAST_ERROR, '' ); wp_send_json_error( '' !== $err ? $err : __( 'Refresh failed.', 'cloudscale-cyber-devtools' ) ); } /** How a surface reads in the panel. */ private static function surface_label( array $s ): string { switch ( $s['kind'] ?? '' ) { case 'ajax': return 'admin-ajax.php ' . (string) $s['action']; case 'rest': return 'REST ' . (string) $s['route']; case 'path': return (string) $s['path']; } return ''; } /** Renders the Virtual Patching panel on the Firewall tab, before the component shields. */ public static function render_panel(): void { $mode = self::mode(); $feed = self::feed(); $apply = self::applicable_rules(); $total = count( (array) ( $feed['rules'] ?? array() ) ); $fetched = (int) ( $feed['fetched_at'] ?? 0 ); $last_err = (string) get_option( self::OPTION_LAST_ERROR, '' ); $counts = class_exists( 'CSDT_Storage' ) ? CSDT_Storage::match_counts() : null; $hits = 0; foreach ( is_array( $counts ) ? $counts : array() as $rule_id => $stat ) { if ( 0 === strpos( (string) $rule_id, self::RULE_ID . ':' ) ) { $hits += (int) ( $stat['hits'] ?? 0 ); } } ?>
🩹 'What a virtual patch is', 'rec' => 'Read this first', 'html' => 'When a plugin or theme has a published vulnerability, the real fix is to update it. Until you can, a virtual patch refuses the one kind of request the vulnerability needs: for example, a non-number in the id field of one AJAX action. Every other request to that component, and to your site, is untouched.', ), array( 'name' => 'Only for what you have installed', 'rec' => 'How it works', 'html' => 'A signed list of patches is fetched once a day from CloudScale. Each patch names a component and the versions it affects, and only patches for a plugin or theme installed here, at an affected version, are used. When you update to the fixed version the patch stops applying by itself. Nothing about your site is sent to fetch the list.', ), array( 'name' => 'Why you can trust the list', 'rec' => 'Safety', 'html' => 'Each patch is drafted from the difference between the vulnerable release and the fixed one, checked automatically against both, and approved by a person before it is signed. The plugin checks the signature against a key built into it and refuses the whole list if it does not match, if it is older than the one it already has, or if it is not understood. A patch is a description, never code: it names a request and one of a fixed set of checks this plugin carries, so a wrong patch can at worst refuse one kind of request to one component.', ), array( 'name' => 'Start in Log only', 'rec' => 'Recommended', 'html' => 'Log only records what a patch would refuse, as vpatch: followed by the patch id in Recent Matches, and refuses nothing. Block also refuses those requests with a 403. Signed-in editors and administrators, scheduled tasks and the firewall\'s exceptions are never checked: an exception for rule vpatch or * on a path lifts patches there.', ), ) ); ?>

' . esc_html( number_format_i18n( (int) ( $feed['serial'] ?? 0 ) ) ) . '', esc_html( $fetched > 0 ? human_time_diff( $fetched ) : '-' ), '' . esc_html( number_format_i18n( count( $apply ) ) ) . '', esc_html( number_format_i18n( max( 0, $total - count( $apply ) ) ) ), '' . esc_html( null === $counts ? '-' : number_format_i18n( $hits ) ) . '' ); ?>

' . esc_html( (string) ( $c['slug'] ?? '' ) ) . '', esc_html( (string) ( $rule['installed_version'] ?? '' ) ), esc_html( (string) ( $c['fixed_in'] ?? '' ) ) ); ?>