Fix the findings of a full second security audit
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run

Four-part audit (OIDC/JWT/crypto, user sync, admin UI, login bypasses)
with dynamic PoCs against a real WordPress install; every fix is covered
by a regression test. Report: docs/security-audit.md, section 6.

Critical/High
- Multisite: settings, AJAX actions and certificate download require
  manage_network_options (site admins could sign in as super admin).
- Privileged accounts are only linked (sync and first sign-in) via a
  matching UPN of a member account, never via the settable mail
  attribute; the sync never changes their e-mail address; e-mail change
  notifications stay on.
- Button-only mode exempts by credential (application passwords, WP-CLI)
  instead of request context, closing bypasses through xmlrpc.php and
  REST login handlers; API requests never receive login cookies.
- Multi-tenant mode refuses guest/external identities.

Medium/Low
- Same message for right and wrong passwords; button-only no longer
  switches off when the connection breaks; server-side fallback cookie
  expiry; correct fallback key beats IP lockouts; right-most proxy hop;
  higher start limit; one object ID per account.
- Deactivation sets a random password, revokes application passwords and
  removes the role (restored on reactivation); disabled people are
  deactivated even when their mail vanished; duplicate bindings handled.
- Sync: abort on empty directory answer, no deprovisioning right after a
  tenant change, atomic run lock, strict photo path validation.
- Certificates: key bundles refused, clean re-exported certificate.
- Array-safe sanitising, encoded redirect_to, per-action nonces, escaped
  role lists, no Graph sleeps during sign-in, warnings for public groups,
  multi-tenant group rules and missing salts, uninstall clears the token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-23 17:10:30 +00:00
parent 791f43a80b
commit 850f0dcd54
18 changed files with 1908 additions and 1270 deletions

View file

@ -55,6 +55,8 @@ class M365_Login_Auth {
add_action( 'init', array( $this, 'maybe_accept_fallback_key' ), 6 );
// Runs after core's username/password handlers (priority 20), which would otherwise overwrite an early WP_Error.
add_filter( 'authenticate', array( $this, 'block_password_login' ), 99, 3 );
// No session cookies from API contexts (XML-RPC, REST) while password sign-in is disabled.
add_filter( 'send_auth_cookies', array( $this, 'block_api_auth_cookies' ), 99, 4 );
// Custom login page: send people back there after logging out.
add_filter( 'logout_redirect', array( $this, 'logout_redirect' ), 10, 3 );
@ -74,16 +76,25 @@ class M365_Login_Auth {
return true; // Nothing is hidden, the form is always available.
}
$cookie = isset( $_COOKIE[ self::FALLBACK_COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::FALLBACK_COOKIE ] ) ) : '';
return '' !== $cookie && hash_equals( $this->fallback_cookie_value(), $cookie );
$parts = explode( '|', $cookie );
if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) ) {
return false;
}
$issued = (int) $parts[0];
if ( $issued > time() + 60 || time() - $issued > self::FALLBACK_TTL ) {
return false; // Expired on the server side, whatever the browser keeps.
}
return hash_equals( $this->fallback_cookie_value( $issued ), $cookie );
}
/**
* Expected fallback cookie value (HMAC of the key, so the key itself never sits in the cookie).
* Fallback cookie value for an issue time: "time|HMAC" (the key itself never sits in the cookie).
*
* @param int $issued Issue timestamp.
* @return string
*/
private function fallback_cookie_value() {
return hash_hmac( 'sha256', 'fallback|' . $this->settings->fallback_key(), wp_salt( 'auth' ) );
private function fallback_cookie_value( $issued ) {
return $issued . '|' . hash_hmac( 'sha256', 'fallback|' . $issued . '|' . $this->settings->fallback_key(), wp_salt( 'auth' ) );
}
/**
@ -99,20 +110,21 @@ class M365_Login_Auth {
return;
}
// Slow down brute force attempts on the key.
$ip_key = 'm365_login_fb_' . md5( $this->client_ip() );
$attempts = (int) get_transient( $ip_key );
if ( $attempts >= 10 ) {
$this->fail( 'fallback_locked' );
}
// The correct key always works (a shared office IP must not lock the administrator out);
// wrong keys are slowed down per IP. The key has ~139 bits, the limit only reduces log noise.
$ip_key = 'm365_login_fb_' . md5( $this->client_ip() );
if ( ! hash_equals( $this->settings->fallback_key(), $given ) ) {
$attempts = (int) get_transient( $ip_key );
if ( $attempts >= 10 ) {
$this->fail( 'fallback_locked' );
}
set_transient( $ip_key, $attempts + 1, 15 * MINUTE_IN_SECONDS );
$this->fail( 'fallback_invalid' );
}
delete_transient( $ip_key );
$this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value(), time() + self::FALLBACK_TTL );
$issued = time();
$this->send_cookie( self::FALLBACK_COOKIE, $this->fallback_cookie_value( $issued ), $issued + self::FALLBACK_TTL );
nocache_headers();
wp_safe_redirect( add_query_arg( 'm365_fallback', 'on', $this->settings->login_page_url() ) );
exit;
@ -149,16 +161,12 @@ class M365_Login_Auth {
if ( '' === (string) $username && '' === (string) $password ) {
return $user; // Initial form render or cookie auth, no password attempt.
}
// Interactive password logins only: XML-RPC, REST (application passwords), WP-CLI and cron keep working.
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
|| ( defined( 'REST_REQUEST' ) && REST_REQUEST )
|| ( defined( 'WP_CLI' ) && WP_CLI )
|| wp_doing_cron() ) {
// Exempt by credential, not by request context: application passwords (XML-RPC, REST) and
// WP-CLI keep working. A normal password is refused everywhere also in forms of other
// plugins that happen to run inside xmlrpc.php or a REST request.
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( $user instanceof WP_User && did_action( 'application_password_did_authenticate' ) ) ) {
return $user;
}
if ( ! $user instanceof WP_User ) {
return $user; // Already failed for another reason; keep core's message.
}
/**
* Allows exempting a password sign-in from button-only mode (e.g. a trusted membership plugin).
@ -166,13 +174,36 @@ class M365_Login_Auth {
* @param bool $block Whether to block. Default true.
* @param WP_User $user Authenticated user.
*/
if ( ! apply_filters( 'm365_login_block_password_login', true, $user ) ) {
if ( $user instanceof WP_User && ! apply_filters( 'm365_login_block_password_login', true, $user ) ) {
return $user;
}
// Same answer for right and wrong passwords: the form must not become a password oracle.
return new WP_Error( 'm365_login_button_only', __( 'Password sign-in is disabled on this site. Please use the Microsoft button.', 'm365-login' ) );
}
/**
* While button-only mode is active, API requests (XML-RPC, REST) never receive session cookies.
*
* Core can set cookies there, e.g. when an application password is used to change the account
* password via REST, or when another plugin's login handler runs inside xmlrpc.php.
*
* @param bool $send Whether to send the cookies.
* @param int $expire Expiry (unused).
* @param int $expiration Expiration (unused).
* @param int $user_id User ID (0 when cookies are cleared).
* @return bool
*/
public function block_api_auth_cookies( $send, $expire = 0, $expiration = 0, $user_id = 0 ) {
if ( ! $send || ! $user_id || ! $this->settings->button_only() || $this->fallback_active() ) {
return $send;
}
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
return false;
}
return $send;
}
/**
* Best-effort client IP for rate limiting.
*
@ -190,7 +221,10 @@ class M365_Login_Auth {
*/
$header = apply_filters( 'm365_login_client_ip_header', defined( 'M365_LOGIN_CLIENT_IP_HEADER' ) ? M365_LOGIN_CLIENT_IP_HEADER : '' );
if ( '' !== $header && ! empty( $_SERVER[ $header ] ) ) {
$candidate = trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) )[0] );
// Proxies append to the list: the right-most entry was written by the trusted proxy,
// entries further left are supplied by the client.
$hops = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) );
$candidate = trim( (string) end( $hops ) );
if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
$ip = $candidate;
}
@ -254,9 +288,10 @@ class M365_Login_Auth {
* @return string
*/
public function start_url( $redirect_to = '' ) {
$args = array( 'action' => self::ACTION_START );
$args = array( 'action' => self::ACTION_START );
$redirect_to = '' !== (string) $redirect_to ? wp_validate_redirect( (string) $redirect_to, '' ) : '';
if ( '' !== $redirect_to ) {
$args['redirect_to'] = $redirect_to;
$args['redirect_to'] = rawurlencode( $redirect_to ); // add_query_arg() does not encode values.
}
return add_query_arg( $args, wp_login_url() );
}
@ -276,7 +311,7 @@ class M365_Login_Auth {
// Cap the number of pending login attempts one client can create (state records are stored server-side).
$throttle_key = 'm365_login_start_' . md5( $this->client_ip() );
$starts = (int) get_transient( $throttle_key );
if ( $starts >= 30 ) {
if ( $starts >= 300 ) {
$this->fail( 'too_many_attempts' );
}
set_transient( $throttle_key, $starts + 1, self::STATE_TTL );
@ -421,6 +456,11 @@ class M365_Login_Auth {
$this->fail( 'invalid_token' );
}
// In multi-tenant mode a guest or federated identity could present any user name.
if ( $this->settings->is_multi_tenant() && $this->is_external_identity( $claims ) ) {
$this->fail( 'external_identity' );
}
$email = $this->email_from_claims( $claims );
if ( '' === $email ) {
$this->fail( 'no_email' );
@ -454,18 +494,41 @@ class M365_Login_Auth {
}
// Bind the account to the immutable Microsoft object ID after first login.
$stored = strtolower( (string) get_user_meta( $user->ID, self::META_OID, true ) );
if ( $this->settings->get( 'bind_oid' ) ) {
if ( '' === $oid || ! M365_Login_Settings::is_guid( $oid ) ) {
$this->fail( 'invalid_token' );
}
$stored = (string) get_user_meta( $user->ID, self::META_OID, true );
if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) {
$this->log( sprintf( 'Object ID mismatch for user #%d.', $user->ID ) );
$this->fail( 'oid_mismatch' );
}
if ( '' === $stored ) {
update_user_meta( $user->ID, self::META_OID, $oid );
}
// Privileged accounts that are not bound yet: only the user principal name of a member
// account may claim them (its domain is verified in the tenant, the e-mail attribute is not).
if ( '' === $stored && M365_Login_Sync::is_privileged( $user ) && ! $this->may_claim_privileged( $claims, $user ) ) {
$this->log( sprintf( 'Refused first sign-in of privileged user #%d without a matching user principal name.', $user->ID ) );
$this->fail( 'privileged_unlinked' );
}
if ( $this->settings->get( 'bind_oid' ) && '' === $stored ) {
// One Microsoft identity, one WordPress account.
$taken = get_users(
array(
'meta_key' => self::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $oid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'exclude' => array( $user->ID ),
'fields' => 'ID',
'number' => 1,
'blog_id' => 0,
)
);
if ( ! empty( $taken ) ) {
$this->log( sprintf( 'Object ID is already bound to another account (user #%d).', $user->ID ) );
$this->fail( 'oid_mismatch' );
}
update_user_meta( $user->ID, self::META_OID, $oid );
}
/**
@ -642,6 +705,35 @@ class M365_Login_Auth {
return $body;
}
/**
* Whether the token comes from a guest or another external identity provider.
*
* Entra only adds the "idp" claim when the identity provider differs from the issuer.
*
* @param array $claims Verified claims.
* @return bool
*/
private function is_external_identity( $claims ) {
$idp = isset( $claims['idp'] ) && is_string( $claims['idp'] ) ? $claims['idp'] : '';
$iss = isset( $claims['iss'] ) && is_string( $claims['iss'] ) ? $claims['iss'] : '';
return '' !== $idp && $idp !== $iss;
}
/**
* Whether a token may claim a privileged account that is not bound yet.
*
* @param array $claims Verified claims.
* @param WP_User $user Matched account.
* @return bool
*/
private function may_claim_privileged( $claims, $user ) {
$upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? strtolower( trim( $claims['preferred_username'] ) ) : '';
return '' !== $upn
&& ! $this->settings->is_multi_tenant()
&& ! $this->is_external_identity( $claims )
&& hash_equals( strtolower( $user->user_email ), $upn );
}
/**
* Applies the Entra group rules: members of an excluded group are refused,
* everybody else needs membership in one of the allowed groups (if any are set).
@ -699,7 +791,7 @@ class M365_Login_Auth {
return 'invalid_token';
}
$matches = $this->graph->check_member_groups( $oid, $denied );
$matches = $this->graph->check_member_groups( $oid, $denied, false );
if ( is_wp_error( $matches ) ) {
$this->log( 'Excluded-group check via Microsoft Graph failed: ' . $matches->get_error_message() );
return 'group_check_failed';
@ -741,7 +833,7 @@ class M365_Login_Auth {
return 'invalid_token';
}
$matches = $this->graph->check_member_groups( $oid, $allowed );
$matches = $this->graph->check_member_groups( $oid, $allowed, false );
if ( is_wp_error( $matches ) ) {
$this->log( 'Group check via Microsoft Graph failed: ' . $matches->get_error_message() );
return 'group_check_failed';
@ -899,6 +991,13 @@ class M365_Login_Auth {
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$out = array();
if ( $this->settings->button_only() && ! $this->settings->is_configured() && ! $this->fallback_active() ) {
$out[] = array(
'type' => 'error',
'code' => 'unavailable',
'text' => __( 'Microsoft sign-in is temporarily unavailable. Please contact an administrator.', 'm365-login' ),
);
}
if ( $fallback_on && $this->settings->button_only() && $this->fallback_active() ) {
$out[] = array(
'type' => 'message',
@ -924,24 +1023,26 @@ class M365_Login_Auth {
*/
private function error_messages() {
return array(
'not_configured' => __( 'Microsoft login is not configured yet.', 'm365-login' ),
'invalid_state' => __( 'The login request expired or was invalid. Please try again.', 'm365-login' ),
'access_denied' => __( 'Microsoft sign-in was cancelled.', 'm365-login' ),
'provider_error' => __( 'Microsoft returned an error. Please try again.', 'm365-login' ),
'token_exchange' => __( 'Could not complete the sign-in with Microsoft. Please try again or contact an administrator.', 'm365-login' ),
'invalid_token' => __( 'The Microsoft sign-in could not be verified.', 'm365-login' ),
'no_email' => __( 'Your Microsoft account did not provide an e-mail address.', 'm365-login' ),
'domain_not_allowed' => __( 'Your e-mail domain is not allowed to sign in here.', 'm365-login' ),
'no_user' => __( 'No WordPress account exists for your Microsoft e-mail address.', 'm365-login' ),
'oid_mismatch' => __( 'This WordPress account is linked to a different Microsoft account. Please contact an administrator.', 'm365-login' ),
'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ),
'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ),
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ),
'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', 'm365-login' ),
'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ),
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ),
'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ),
'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ),
'not_configured' => __( 'Microsoft login is not configured yet.', 'm365-login' ),
'invalid_state' => __( 'The login request expired or was invalid. Please try again.', 'm365-login' ),
'access_denied' => __( 'Microsoft sign-in was cancelled.', 'm365-login' ),
'provider_error' => __( 'Microsoft returned an error. Please try again.', 'm365-login' ),
'token_exchange' => __( 'Could not complete the sign-in with Microsoft. Please try again or contact an administrator.', 'm365-login' ),
'invalid_token' => __( 'The Microsoft sign-in could not be verified.', 'm365-login' ),
'no_email' => __( 'Your Microsoft account did not provide an e-mail address.', 'm365-login' ),
'domain_not_allowed' => __( 'Your e-mail domain is not allowed to sign in here.', 'm365-login' ),
'no_user' => __( 'No WordPress account exists for your Microsoft e-mail address.', 'm365-login' ),
'oid_mismatch' => __( 'This WordPress account is linked to a different Microsoft account. Please contact an administrator.', 'm365-login' ),
'not_allowed' => __( 'You are not allowed to sign in with this account.', 'm365-login' ),
'not_in_group' => __( 'Your Microsoft account is not a member of a group that is allowed to sign in here.', 'm365-login' ),
'group_check_failed' => __( 'Your group membership could not be verified. Please contact an administrator.', 'm365-login' ),
'in_denied_group' => __( 'Your Microsoft account is a member of a group that is not allowed to sign in here.', 'm365-login' ),
'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ),
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ),
'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ),
'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ),
'privileged_unlinked' => __( 'For security reasons this administrator account can only be linked to a Microsoft account whose user principal name equals the WordPress e-mail address. Please contact an administrator.', 'm365-login' ),
'external_identity' => __( 'Guest and external accounts cannot sign in here.', 'm365-login' ),
);
}