0 && strlen( $value ) > $max_bytes ) { $value = substr( $value, 0, $max_bytes ); $tail = 3; // A UTF-8 character is at most four bytes, so at most three are partial. while ( $tail > 0 && '' !== $value && 1 !== preg_match( '//u', $value ) ) { $value = substr( $value, 0, -1 ); --$tail; } } return $value; } /** * The string with the bytes of any invalid UTF-8 sequence removed; valid text is untouched. * * Invalid UTF-8 loses its non-ASCII bytes, not the whole value. wp_check_invalid_utf8() * alone returns '' for the whole string, and a firewall that sees '' for a URI carrying * one stray high byte has just been walked around; the ASCII payload is what the rules * match. * * @param string $value Any string. * @return string */ private static function utf8_only( string $value ): string { return '' === $value || 1 === preg_match( '//u', $value ) ? $value : (string) preg_replace( '/[\x80-\xFF]/', '', $value ); } /** * A routing argument from the query string or $_REQUEST, reduced to a key, or null. * * THE ONE PLACE a read-only routing KEY is read: the admin tab and wp-login.php's own * `action`. (The shields' case-sensitive values go through routing_text().) Each * value only chooses what to render or which stored rule applies; nothing is written, * echoed or queried with it. They carry no nonce because the links and forms that send * them are core's or plain bookmarkable URLs (core reads `?page=`, `?tab=` and * wp-login.php's `action` the same way), so there is none to verify. * * sanitize_key() reduces the value to a-z0-9_- , which is every legal tab slug and login * action. Null means "absent", distinct from present-but-empty (or a non-scalar, which * sanitize_key() turns into ''). * * @param string $name Argument name. * @param string $source 'get' for $_GET, 'request' for $_REQUEST (admin-ajax and * wp-login.php accept the argument from either). * @return string|null */ public static function routing_key( string $name, string $source = 'get' ): ?string { return 'request' === $source ? self::request_key( $name ) : self::query_key( $name ); } /** * The $_GET half of routing_key(): a read-only routing argument reduced to a key, or null. * * One statement, so the whole read is visible at once: the value is reduced by * sanitize_key() and returned. No nonce, because the bookmarkable admin URL that sends * `?tab=` carries none (core reads `?page=` the same way). * * @param string $name Argument name. * @return string|null */ private static function query_key( string $name ): ?string { return isset( $_GET[ $name ] ) ? sanitize_key( wp_unslash( $_GET[ $name ] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing argument; see the docblock } /** * The $_REQUEST half of routing_key(): wp-login.php's own `action`, reduced to a key, or null. * * One statement, so the whole read is visible at once: the value is reduced by * sanitize_key() and returned. No nonce, because core's login form sends `action` * without one and wp-login.php reads it from $_REQUEST the same way. * * @param string $name Argument name. * @return string|null */ private static function request_key( string $name ): ?string { return isset( $_REQUEST[ $name ] ) ? sanitize_key( wp_unslash( $_REQUEST[ $name ] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing argument; see the docblock } /** * A routing argument kept as text (case and punctuation preserved), or null when absent. * * For the two values the shields compare against names stored exactly as another * plugin registered them: the admin-ajax `action` (core fires `wp_ajax_nopriv_{$action}` * with the raw, case-sensitive value) and core's `?rest_route=`. routing_key() would * lowercase and strip them, and a shield keyed on `myPluginAction` would then be walked * around. sanitize_text_field() removes tags and control characters and keeps the rest. * Compared against a stored allowlist, never stored, echoed or queried. * * @param string $name Argument name. * @param string $source 'get' for $_GET, 'request' for $_REQUEST. * @return string|null */ public static function routing_text( string $name, string $source = 'get' ): ?string { return 'request' === $source ? self::request_text( $name ) : self::query_text( $name ); } /** * The $_GET half of routing_text(): core's `?rest_route=`, case kept, or null. * * One statement: sanitize_text_field() on the reading line, then returned. No nonce, * because core reads `rest_route` from the URL without one. * * @param string $name Argument name. * @return string|null */ private static function query_text( string $name ): ?string { return isset( $_GET[ $name ] ) ? sanitize_text_field( wp_unslash( $_GET[ $name ] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing argument; see the docblock } /** * The $_REQUEST half of routing_text(): the admin-ajax `action`, case kept, or null. * * One statement: sanitize_text_field() on the reading line, then returned. No nonce, * because this runs before any handler is chosen: core itself reads `action` from * $_REQUEST without one to pick the `wp_ajax_*` hook, and the handler it picks then * verifies its own. * * @param string $name Argument name. * @return string|null */ private static function request_text( string $name ): ?string { return isset( $_REQUEST[ $name ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $name ] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing argument; see the docblock } /** * Whether the request carries a cookie with exactly this name. The value is never read. * * @param string $name Cookie name, e.g. LOGGED_IN_COOKIE. * @return bool */ public static function has_cookie( string $name ): bool { return '' !== $name && in_array( sanitize_key( $name ), self::cookie_names(), true ); } /** * Whether the request carries any WordPress logged-in cookie. Names only, never a value. * * Used to skip work on anonymous traffic before authentication has run. It proves * nothing about who the visitor is; request_user_can() and core do that. * * @return bool */ public static function has_login_cookie(): bool { foreach ( self::cookie_names() as $name ) { if ( 0 === strpos( $name, 'wordpress_logged_in_' ) ) { return true; } } return false; } /** * THE ONE PLACE $_COOKIE is read: the NAMES present on the request, reduced to keys. * * Only names, never a value, so nothing a visitor sends as a cookie value reaches * this plugin through here. A cookie name is not forgeable cross-site the way a form * field is, so there is no nonce to check; sanitize_key() is on the reading line. * * @return array */ private static function cookie_names(): array { if ( empty( $_COOKIE ) || ! is_array( $_COOKIE ) ) { return array(); } return array_map( 'sanitize_key', array_keys( $_COOKIE ) ); } /** * The CLI argument vector as one sanitised, capped string, for alert context. * * $_SERVER['argv'] is the one non-scalar member and is only populated under * the CLI, so server() cannot express it. Each argument is text a person * typed at a shell, destined for a Telegram message, so sanitize_text_field() * is the right sanitiser here and the count is capped so a long `wp` command * line does not become the whole alert. * * @param int $max_args How many leading arguments to keep. * @return string */ public static function argv( int $max_args = 10 ): string { if ( ! isset( $_SERVER['argv'] ) || ! is_array( $_SERVER['argv'] ) || ! function_exists( 'sanitize_text_field' ) ) { return ''; } $argv = array_map( 'sanitize_text_field', wp_unslash( $_SERVER['argv'] ) ); $joined = implode( ' ', array_slice( $argv, 0, max( 0, $max_args ) ) ); return trim( $joined ); } } }