It Doesn’t Look Like WordPress Has Had Proper Security Review Since at Least 2009
We focus on the security of WordPress plugins, so we haven’t ventured much in to the security of the core WordPress software. You would reasonably expect that others have. But what we found in just a glancing check at things suggests that either a proper security review of the software hasn’t happened since at least the end of 2009 or the issues identified were not addressed.
Missing Security Hardening
Recently it was announced that WordPress was going to be paring down the number of new major releases. That seems to be caused by a combination of only Automattic seeming all that interested in the block editor (Gutenberg) and Automattic’s reduced involvement in WordPress. (Their reduced role could be because of their poor financial state or because the head of it (who is also the head of WordPress) is still trying to blackmail a competitor.)
We have been thinking about how what is happening could impact security. Counterintuitively, it could, to an extent, lead to better security. As a lack of other new features being added could be partially filled with security improvements. For example, years late, WordPress 6.8 finally introduced more secure password hashing.
Looking at open requests to make changes to WordPress that mention the term security show various proposed security improvements that haven’t been applied to the code base. Looking through some of those suggests that security isn’t getting much focus, and that proposed security changes are not being fully applied.
As an example of the former, one proposed change involving security hardening is connected to a ticket opened in August 2021. Another involves a ticket opened in March 2023.
As an example of the latter, which adds in another wrinkle, a code change was proposed to add sanitation when user input is set to a variable. Here is how the code looks now:
591 592 | $action = $_POST['action']; $taxonomy = get_taxonomy( substr( $action, 4 ) ); |
The proposed change is to use sanitize_text_field() to sanitize the user input:
591 | $action = isset( $_POST['action'] ) ? sanitize_text_field( $_POST['action'] ) : ''; |
Using sanitize_key() would seem to be more appropriate. That is used for fairly similar code two other places in the file, including:
1185 1186 | $taxonomy = sanitize_key( $_POST['tax'] ); $taxonomy_object = get_taxonomy( $taxonomy ); |
What we also noticed is that there is another similar line that still would lack sanitation in the suggested change was applied:
1091 1092 | $taxonomy = ! empty( $_POST['taxonomy'] ) ? $_POST['taxonomy'] : 'post_tag'; $taxonomy_object = get_taxonomy( $taxonomy ); |
Whenever you are applying a security change, you want to make sure to check if there are other places that need to be corrected.
The Wrong Sanitization Since 2009?
Separate from checking on what is going with security of the core WordPress software, while working on code for a plugin that would edit user account info, we noticed the WordPress function to edit a user edit_user() in the file /wp-admin/includes/user.php uses the sanitize_text_field() function to sanitize the email address:
80 81 82 | if ( isset( $_POST['email'] ) ) { $user->user_email = sanitize_text_field( wp_unslash( $_POST['email'] ) ); } |
Why the code is using that instead of what would seem be the relevant sanitzation function sanitize_email() is a mystery to us. No one responded with an answer why that might be when we queried Bluesky.
That sanitization was introduced in version 2.9, which was released in December 2009. sanitize_email() was introduced in version 1.5.
Commented Out CSRF Protection Since 2011
Getting back to our checking on what is going on with security of the core WordPress software because of the slowdown, we ran across more code that appears improperly secured. In the file /wp-admin/includes/ajax-actions.php the function wp_ajax_dismiss_wp_pointer() has a nonce check to prevent cross-site request forgery (CSRF), but it is commented out without a comment explaining why:
2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 | function wp_ajax_dismiss_wp_pointer() { $pointer = $_POST['pointer']; if ( sanitize_key( $pointer ) !== $pointer ) { wp_die( 0 ); } // check_ajax_referer( 'dismiss-pointer_' . $pointer ); $dismissed = array_filter( explode( ',', (string) get_user_meta( get_current_user_id(), 'dismissed_wp_pointers', true ) ) ); if ( in_array( $pointer, $dismissed, true ) ) { wp_die( 0 ); } $dismissed[] = $pointer; $dismissed = implode( ',', $dismissed ); update_user_meta( get_current_user_id(), 'dismissed_wp_pointers', $dismissed ); wp_die( 1 ); } |
It seems like there should be a nonce check in that code.
The comment above the function says “since 3.1.0,” but we found that the code looks to have been introduced in 3.3. That was released in December 2011.
Code Introduced Last Year Looks Insecure
In the file /wp-includes/class-wp-plugin-dependencies.php, the function heck_plugin_dependencies_during_ajax() is described in a comment above as “Checks plugin dependencies after a plugin is installed via AJAX.” You would expect there to be a capability check, as it AJAX registered to anyone logged in to WordPress. While there is some code in that only runs if the requestor has a specified capability. The rest runs no matter who requests it, assuming they provide the required nonce:
446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 | public static function check_plugin_dependencies_during_ajax() { check_ajax_referer( 'updates' ); if ( empty( $_POST['slug'] ) ) { wp_send_json_error( array( 'slug' => '', 'pluginName' => '', 'errorCode' => 'no_plugin_specified', 'errorMessage' => __( 'No plugin specified.' ), ) ); } $slug = sanitize_key( wp_unslash( $_POST['slug'] ) ); $status = array( 'slug' => $slug ); self::get_plugins(); self::get_plugin_dirnames(); if ( ! isset( self::$plugin_dirnames[ $slug ] ) ) { $status['errorCode'] = 'plugin_not_installed'; $status['errorMessage'] = __( 'The plugin is not installed.' ); wp_send_json_error( $status ); } $plugin_file = self::$plugin_dirnames[ $slug ]; $status['pluginName'] = self::$plugins[ $plugin_file ]['Name']; $status['plugin'] = $plugin_file; if ( current_user_can( 'activate_plugin', $plugin_file ) && is_plugin_inactive( $plugin_file ) ) { $status['activateUrl'] = add_query_arg( array( '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $plugin_file ), 'action' => 'activate', 'plugin' => $plugin_file, ), is_multisite() ? network_admin_url( 'plugins.php' ) : admin_url( 'plugins.php' ) ); } if ( is_multisite() && current_user_can( 'manage_network_plugins' ) ) { $status['activateUrl'] = add_query_arg( array( 'networkwide' => 1 ), $status['activateUrl'] ); } self::initialize(); $dependencies = self::get_dependencies( $plugin_file ); if ( empty( $dependencies ) ) { $status['message'] = __( 'The plugin has no required plugins.' ); wp_send_json_success( $status ); } require_once ABSPATH . 'wp-admin/includes/plugin.php'; $inactive_dependencies = array(); foreach ( $dependencies as $dependency ) { if ( false === self::$plugin_dirnames[ $dependency ] || is_plugin_inactive( self::$plugin_dirnames[ $dependency ] ) ) { $inactive_dependencies[] = $dependency; } } if ( ! empty( $inactive_dependencies ) ) { $inactive_dependency_names = array_map( function ( $dependency ) { if ( isset( self::$dependency_api_data[ $dependency ]['Name'] ) ) { $inactive_dependency_name = self::$dependency_api_data[ $dependency ]['Name']; } else { $inactive_dependency_name = $dependency; } return $inactive_dependency_name; }, $inactive_dependencies ); $status['errorCode'] = 'inactive_dependencies'; $status['errorMessage'] = sprintf( /* translators: %s: A list of inactive dependency plugin names. */ __( 'The following plugins must be activated first: %s.' ), implode( ', ', $inactive_dependency_names ) ); $status['errorData'] = array_combine( $inactive_dependencies, $inactive_dependency_names ); wp_send_json_error( $status ); } $status['message'] = __( 'All required plugins are installed and activated.' ); wp_send_json_success( $status ); } |
That is commented as having existed “since 6.5.0,” which appears accurate. That version was released in April of last year.
Security Team Needed
Part of what probably helps to explain the poor security situation is WordPress’ lack of a dedicated security team. As best we can tell, what gets referred to as the WordPress Security Team is actually a group of people in the WordPress Core Team. They don’t appear to have much professional experience handling security and the leader of the team appears to not care much about proper security.
Putting in place a dedicated security team staffed with people with security experience that is properly governed is long overdue. Having that team then work with outside providers like ourselves that actually want to improve WordPress security (as opposed to many security providers profiting off of leaving things insecure), unlike the plugin team, could make quick work of a lot of unnecessary insecurity.
In the meantime, if someone wants us do a security review of WordPress in the context of what we do with security reviews of plugins. The price would be $6100 USD.