21 Apr 2025

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.

Leave a Reply

Your email address will not be published.