Make WordPress Core

Opened 9 days ago

Last modified 12 hours ago

#66337 new defect (bug)

wp_insert_user() allows duplicate email addresses containing an apostrophe

Reported by: abhishekfdd Owned by:
Priority: normal Milestone: Awaiting Review
Component: Users Version:
Severity: normal Keywords: has-patch has-unit-tests
Cc: Focuses:

Description

Description

wp_insert_user() runs pre_user_email, which includes wp_filter_kses. That filter expects slashed input and returns slashed output, so for o'brien@example.com the value of $user_email at the duplicate check is o\'brien@example.com.

email_exists( $user_email ) then queries for the literal backslashed string, never matches the stored row, and the duplicate check silently passes. The value is unslashed later via wp_unslash( $compacted ), so the duplicate is saved with the correct address.

[57711] (#52529) fixed the related comparison on update by slashing $old_user_data->user_email, but the email_exists() lookup was not changed.

Steps to reproduce

Tested on trunk 7.2-alpha-63166, MariaDB 10.11.

wp_insert_user( wp_slash( array( 'user_login' => 'a', 'user_pass' => 'x', 'user_email' => "o'brien@example.com" ) ) );
wp_insert_user( wp_slash( array( 'user_login' => 'b', 'user_pass' => 'x', 'user_email' => "o'brien@example.com" ) ) );
// Second call succeeds. With plain@example.com it correctly returns existing_user_email.

Results with an existing user at o'brien@example.com:

Path Result
wp_insert_user(), slashed input duplicate created
wp_insert_user(), unslashed input duplicate created
wp_update_user() moving another user to that email allowed
POST /wp/v2/users (admin) 201, duplicate created
register_new_user() blocked (own email_exists() check)
POST /wp/v2/users/me (subscriber) blocked (rest_user_invalid_email)

Front-end registration and self-service profile edits are protected by their own checks, so this needs create_users or direct API use. It is a data integrity issue, not a privilege escalation.

Impact

Once duplicates exist, get_user_by( 'email' ) returns whichever row the database hands back first. Anything keyed on email (password reset by email, login with email, plugins that look users up by email) can then resolve to the wrong account.

Proposed fix

Run the comparison and lookup against the unslashed value, for example $check_email = wp_unslash( $user_email ); used in both strcasecmp() and email_exists(). This could also make the slashing of $old_user_data->user_email from [57711] unnecessary, which is worth confirming in review.

Tests should cover insert (slashed and unslashed), update to another user's apostrophe email, and REST create, alongside the existing test_reset_password_with_apostrophe_in_email().

Attachments (1)

bug1.png​ (42.1 KB ) - added by muryam 4 days ago.

Download all attachments as: .zip

Change History (5)

This ticket was mentioned in ​PR #14490 on ​WordPress/wordpress-develop by ​@abhishekfdd.


9 days ago
#1

  • Keywords has-patch has-unit-tests added

What

wp_insert_user() checks for duplicate emails using the output of the pre_user_email filters. One of those filters, wp_filter_kses(), returns a slashed value, so o'brien@example.com reaches the check as o\'brien@example.com. email_exists() never matches the stored row, so a duplicate address is accepted by:

  • wp_insert_user(), with slashed or unslashed input
  • wp_update_user(), when changing a user to another user's email
  • POST /wp/v2/users

This PR unslashes the email once after the filters. That value is used for the duplicate comparison, the email_exists() lookup, and the check that clears user_activation_key when the email changes.

It also removes the wp_slash() on $old_user_data->user_email added in [57711] (#52529). With the comparison done on the unslashed email, that line isn't needed. It was also passing a slashed email to profile_update callbacks: since 6.5, $old_user_data->user_email there is o\'brien@example.com.

Testing

New tests in tests/phpunit/tests/user.php and tests/phpunit/tests/rest-api/rest-users-controller.php:

  • duplicate insert is rejected (slashed and unslashed input)
  • updating to another user's apostrophe email is rejected
  • changing the case of your own apostrophe email is still allowed
  • user_activation_key survives an unrelated update (guards #52529)
  • profile_update receives the old email unslashed
  • REST create rejects the duplicate

Without the fix, 5 of these fail. With it, all pass, along with the existing test_reset_password_with_apostrophe_in_email() from #52529. The user, auth, login and REST users groups pass on single site and multisite.

npm run test:php -- --filter apostrophe

Use of AI Tools

AI assistance: Yes
Tool(s): Claude
Model(s): Opus 5.5
Used for: Claude Code was used to trace the issue, write the fix and tests, and run the test suite. I reviewed and verified the changes.

#2 @laweens
7 days ago

Reproduction Report

Description

This report independently confirms the issue on current trunk, using a different database from the original report SQLite instead of MariaDB.

Environment

  • WordPress version: trunk 7.2-beta1-64670 (nightly)
  • Browser: Chromium 152.0.7977.120
  • Operating system: macOS 27.0.1 (Apple Silicon)
  • Theme: Twenty Twenty-Five 1.5
  • Active plugins: none (one test-only mu-plugin that runs the steps below)
  • PHP version: 8.3.33
  • Server: WordPress Playground (playground.wordpress.net)
  • Database: SQLite via Playground's MySQL emulation (reports 8.0.38-mysql-on-sqlite-3.1.0)
  • Multisite: no
  • WP_DEBUG / WP_DEBUG_LOG: true

Steps to Reproduce

Run as the administrator. Before each scenario, all test users were deleted so each one started clean.

  1. Control: wp_insert_user( wp_slash( [...] ) ) twice with plain@…
  2. The ticket's exact steps: wp_insert_user( wp_slash( [...] ) ) twice with o'brien@…
  3. The same as step 2, but with unslashed input
  4. Create a user with o'brien@… and a second user with other@…, then wp_update_user() the second user onto o'brien@…
  5. With an existing o'brien@… user, POST /wp/v2/users (via rest_do_request) with that email
  6. With an existing o'brien@… user, register_new_user( 't66337_reg', "o'brien@…" )
  7. With one stored o'brien@… user, call email_exists() with unslashed and slashed input, and check what the pre_user_email filters return for slashed input

Actual Results

# Scenario Result Rows with that email
1 Control: insert x2, plain@… second call: WP_Error existing_user_email ✅ 1
2 Insert x2, o'brien@…, slashed second call succeeds (user ID 4) ❌ 2
3 Insert x2, o'brien@…, unslashed second call succeeds (user ID 6) ❌ 2
4 wp_update_user() onto o'brien@… succeeds ❌ 2
5 POST /wp/v2/users as admin 201 Created ❌ 2
6 register_new_user() WP_Error email_exists ✅ 1

Step 7:

email_exists( "o'brien@example.com" )            → 11 (found)
email_exists( wp_slash( "o'brien@example.com" ) ) → false
pre_user_email output for slashed input           → o\'brien@example.com

This matches the explanation in the description. The duplicate check receives the still-slashed value from the pre_user_email filters, so email_exists() looks for o\'brien@… and never finds the stored row.

After scenario 2, get_user_by( 'email', "o'brien@…" ) returned only the first user (ID 3), so the second account can't be found by its own email.

Screenshot attached: 66337-reproduction-trunk.png

Expected Results

Scenarios 2–5 should return existing_user_email, as the plain-email control does, and leave one row.

Conclusion

✅ Reproduced on trunk 7.2-beta1-64670 with PHP 8.3 and SQLite. Every path I tested matches the table in the description.
Not tested: POST /wp/v2/users/me as a subscriber, other PHP versions, multisite, and a real MySQL or MariaDB server.

AI assistance: Yes
Used for: Setting up SQlite and taking screenshots.

#3 @muryam
4 days ago

Reproduction Report

Independent confirmation of #66337 on current trunk. (Note: comment:2 already reproduced this on Playground/SQLite — this run focuses on [ multisite ]

Environment

  • WordPress version: trunk [7.2-beta1]
  • PHP version: 8.3.x
  • Database: [ SQLite via Playground ]
  • Multisite: [yes]
  • Theme: Twenty Twenty-Five · Active plugins: none (one test-only mu-plugin)
  • Server: WordPress Playground (playground.wordpress.net)
  • WP_DEBUG / WP_DEBUG_LOG: true

How I tested

Ran as an administrator; deleted the test users before each scenario so each starts clean.

<?php
$email = "o'brien@example.com";
// 1. duplicate insert (slashed) — ticket's exact steps
$a = wp_insert_user( wp_slash( ['user_login'=>'t66337_a','user_pass'=>'x','user_email'=>$email] ) );
$b = wp_insert_user( wp_slash( ['user_login'=>'t66337_b','user_pass'=>'x','user_email'=>$email] ) );
var_dump( is_wp_error($b) ? $b->get_error_code() : "created ID $b" );

// 2. email_exists probe
var_dump( email_exists( $email ) );            // expect: user ID
var_dump( email_exists( wp_slash( $email ) ) ); // bug: false

Steps

  1. Control: insert twice with plain@example.com (slashed).
  2. Ticket steps: insert twice with o'brien@example.com (slashed).
  3. Same as 2 but with unslashed input.
  4. Create o'brien@… and another user, then wp_update_user() the other user onto o'brien@….
  5. With an existing o'brien@… user, POST /wp/v2/users as admin.
  6. register_new_user( 't66337_reg', "o'brien@…" ).
  7. [ If testing the gap ] POST /wp/v2/users/me as a subscriber / repeat on multisite.

Actual Results

# Scenario Result Rows with that email
1 Control, plain@ (x2) [ WP_Error existing_user_email ✅ ] [1]
2 o'brien@, slashed (x2) [ second call succeeds ❌ ] [2]
3 o'brien@, unslashed (x2) [ ❌ / ✅ ] [ ]
4 wp_update_user() onto o'brien@ [ ❌ / ✅ ] [ ]
5 POST /wp/v2/users (admin) [ 201 ❌ / rejected ✅ ] [ ]
6 register_new_user() [ WP_Error email_exists ✅ ] [1]
7 [ /users/me subscriber / multisite ] [ your result ] [ ]

email_exists probe:

email_exists( "o'brien@example.com" )            → [ user ID ]
email_exists( wp_slash( "o'brien@example.com" ) ) → [ false ]

Expected Results

Scenarios 2–5 should return existing_user_email (like the plain-email control) and leave one row.


Conclusion

✅ Reproduced on trunk Beta 7.2 with PHP 8.3 and MYSQL. [Confirmed on multisite]

Last edited 4 days ago by muryam (previous) (diff)

#4 @dmsnell
12 hours ago

Linking to #31992 because the mechanisms at play are related: comparing different versions of an email address against each other for equality.

Note: See TracTickets for help on using tickets.