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)
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
#2
@
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.
- Control: wp_insert_user( wp_slash( [...] ) ) twice with plain@…
- The ticket's exact steps: wp_insert_user( wp_slash( [...] ) ) twice with o'brien@…
- The same as step 2, but with unslashed input
- Create a user with o'brien@… and a second user with other@…, then wp_update_user() the second user onto o'brien@…
- With an existing o'brien@… user, POST /wp/v2/users (via rest_do_request) with that email
- With an existing o'brien@… user, register_new_user( 't66337_reg', "o'brien@…" )
- 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
@
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
- Control: insert twice with
plain@example.com(slashed). - Ticket steps: insert twice with
o'brien@example.com(slashed). - Same as 2 but with unslashed input.
- Create
o'brien@…and another user, then wp_update_user() the other user ontoo'brien@…. - With an existing
o'brien@…user, POST /wp/v2/users as admin. - register_new_user( 't66337_reg', "o'brien@…" ).
- [ 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]
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)

What
wp_insert_user()checks for duplicate emails using the output of thepre_user_emailfilters. One of those filters,wp_filter_kses(), returns a slashed value, soo'brien@example.comreaches the check aso\'brien@example.com.email_exists()never matches the stored row, so a duplicate address is accepted by:wp_insert_user(), with slashed or unslashed inputwp_update_user(), when changing a user to another user's emailPOST /wp/v2/usersThis PR unslashes the email once after the filters. That value is used for the duplicate comparison, the
email_exists()lookup, and the check that clearsuser_activation_keywhen the email changes.It also removes the
wp_slash()on$old_user_data->user_emailadded in [57711] (#52529). With the comparison done on the unslashed email, that line isn't needed. It was also passing a slashed email toprofile_updatecallbacks: since 6.5,$old_user_data->user_emailthere iso\'brien@example.com.Testing
New tests in
tests/phpunit/tests/user.phpandtests/phpunit/tests/rest-api/rest-users-controller.php:user_activation_keysurvives an unrelated update (guards #52529)profile_updatereceives the old email unslashedWithout 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 apostropheUse 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.