Skip to content

Validate the password WordPress stores, not a sanitized copy of it - #233

Open
dknauss wants to merge 3 commits into
10up:developfrom
dknauss:fix/whitespace-password-policy-bypass
Open

Validate the password WordPress stores, not a sanitized copy of it#233
dknauss wants to merge 3 commits into
10up:developfrom
dknauss:fix/whitespace-password-policy-bypass

Conversation

@dknauss

@dknauss dknauss commented Jul 8, 2026

Copy link
Copy Markdown

Summary

Passwords::validate_strong_password() mishandles $_POST['pass1'] in two ways, and both mean the policy is applied to something other than the password being set.

1. The blank-field test uses trim() as a boolean. That treats an all-whitespace value (" ") — and, because the trimmed result is evaluated as a boolean, a value of "0" — as "no password set", so the function returns early and skips its zxcvbn and Have I Been Pwned checks.

This one is harmless in practice: core already rejects a whitespace-only password on every path, so one cannot actually be set or used through the UI.

  • edit_user() trims pass1 and only saves if ( ! empty( $pass1 ) )" " becomes a no-op change.
  • The reset-password form errors with "The password cannot be a space or all spaces." before reset_password() is called.
  • At login, wp_authenticate() trims the entry and wp_authenticate_username_password() rejects an empty password.

So it is not a policy bypass — the blank-field test should simply key off an empty field, so the plugin's own checks do not silently skip.

2. The password is passed through sanitize_text_field() before validation. This one has teeth. That function strips tags, collapses whitespace, and removes octets, so the length, zxcvbn, and HIBP checks all run against a string the user did not choose and WordPress will not store.

Concretely, my<b>secret phrase is validated as mysecret phrase: a different length, a different zxcvbn score, and a different SHA-1 prefix sent to HIBP. The verdict returned is real — it just belongs to a different password. In practice this surfaces as confusing rejections (a fine password scored as something weaker, or matched against a breach entry the user never used), and it means the breach check does not actually cover the credential that ends up in the database.

Core never sanitizes here, and passwords are the standard example of a value that must not be sanitized. edit_user() takes $_POST['pass1'] as-is — rejecting any password containing a backslash — and wp_insert_user() unslashes before hashing, so wp_unslash( $_POST['pass1'] ) is exactly the string that gets hashed.

Fix

-$password = ( isset( $_POST['pass1'] ) && trim( $_POST['pass1'] ) ) ? sanitize_text_field( $_POST['pass1'] ) : false;
+$password = ( isset( $_POST['pass1'] ) && '' !== $_POST['pass1'] ) ? (string) wp_unslash( $_POST['pass1'] ) : false;

Only a genuinely empty field means "no change", and what gets validated is what gets stored. The phpcs:ignore stays, now with a reason on it: not sanitizing is the point.

Legitimate password changes are unaffected, except that passwords containing markup, doubled spaces, or trailing whitespace are now judged as themselves.

Verification Process

  • php -l includes/classes/Authentication/Passwords.php
  • composer lint — clean; the four warnings the full run reports are pre-existing on develop in AdminCustomizations/EnvironmentIndicator.php and are untouched here.
  • git diff --check
  • Checked against core: wp-admin/includes/user.php (edit_user() trim and backslash rejection) and wp_insert_user()'s wp_unslash() before hashing.

Changelog Entry

Fixed - Validate the password WordPress actually stores: the strong-password checks no longer run against a sanitized copy, and a whitespace-only or "0" entry no longer skips them.

Checklist:

  • My code follows the code style of this project.
  • All new and existing tests pass. (No automated test suite exists in this repo; CI runs lint only.)

AI assistance was used in drafting and reviewing this change; final authorship and verification are mine.

validate_strong_password() detects an empty/unchanged password field with
`trim( $_POST['pass1'] )`. An all-whitespace value (e.g. "   ") trims to '' and
is treated as "no password set", so the function returns early and skips the
length, zxcvbn, and Have I Been Pwned checks — while WordPress still saves the
whitespace password. A user under an enforced strong-password policy can defeat
it this way. (The same early-return also fires for a password of "0", which is
falsy after trim.)

Only a genuinely empty field means "no change", so gate on the string being
non-empty instead of on trim().
@dknauss dknauss changed the title Fix: whitespace-only passwords bypass the strong-password policy Tidy: don't skip strong-password validation for whitespace-only / "0" input Jul 8, 2026
sanitize_text_field() strips tags, collapses whitespace, and removes
octets, so the length, zxcvbn, and Have I Been Pwned checks ran against a
string the user never chose and the site never stores.

Concretely, "my<b>secret phrase" is validated as "mysecret phrase": a
different length, a different zxcvbn score, and a different SHA-1 prefix
sent to HIBP. The verdict is real, it just belongs to another password.

Core never sanitizes here. edit_user() takes $_POST['pass1'] as-is
(rejecting any password containing a backslash), and wp_insert_user()
unslashes before hashing, so wp_unslash( $_POST['pass1'] ) is exactly the
string that gets hashed.

The phpcs:ignore stays, with a reason: not sanitizing is the point.
@dknauss dknauss changed the title Tidy: don't skip strong-password validation for whitespace-only / "0" input Validate the password WordPress stores, not a sanitized copy of it Aug 3, 2026
@jeffpaul
jeffpaul requested a lite review from Copilot August 12, 2026 15:24
@jeffpaul jeffpaul added this to the 1.20.0 milestone Aug 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the strong-password validation logic to ensure the plugin validates the exact password value WordPress will store (i.e., the unslashed raw password), instead of validating a sanitized/modified copy.

Changes:

  • Stop using trim() as the “password set” sentinel so values like "0" don’t skip validation.
  • Stop running password strength / breach checks against sanitize_text_field() output; validate wp_unslash( $_POST['pass1'] ) instead.
  • Add detailed inline rationale and a PHPCS ignore reason explaining why password input must not be sanitized.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread includes/classes/Authentication/Passwords.php Outdated
A crafted request can submit pass1 as an array (pass1[]=...). The
'' !== $_POST['pass1'] check passes for an array, and (string) then
coerces it to the string "Array" (with a PHP array-to-string warning),
so the strength and breach checks ran against "Array" rather than
treating the input as no password. Read the raw value first and treat
any non-string as no password. Addresses the Copilot review note.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants