Fix the findings of the third security audit

- Privileged accounts: the UPN rule also applies when bind_oid is off or
  the account is not bound; privileges are checked on every site of a
  multisite user, include code/HTML capabilities (unfiltered_html,
  plugins, themes, users) and the remembered roles of deactivated
  accounts.
- send_auth_cookies protection also works on WordPress 6.0/6.1.
- Run lock via INSERT IGNORE (atomic), refreshed during long runs; a
  shutdown handler reports fatal errors and frees the lock.
- Deprovisioning only for accounts linked in the current tenant (tenant
  recorded per account; legacy links not found are left alone).
- Safety stop based on the accounts linked before the run; new safety
  stop for removals of administrative roles.
- Disable is idempotent; row-action nonces are bound to the state.
- Profile photos are re-encoded to 240 px (drops EXIF and appended
  data), size-limited while downloading, removed on deactivation;
  index.php guard in the photo folder.
- Privacy exporter and eraser for the copied data.
- One-time migration hardens accounts deactivated by 1.0 and cleans a
  stored certificate bundle; the .cer download is always re-exported.
- Password fields hidden in button-only mode even when the connection
  is broken; settings written non-autoloaded; robust user ID queries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-24 03:52:13 +00:00
parent b35f6a867b
commit 9b893e42bc
9 changed files with 582 additions and 104 deletions

View file

@ -20,6 +20,7 @@ class M365_Login_Auth {
const STATE_TTL = 600; // 10 minutes.
const META_OID = '_m365_login_oid';
const META_LAST_LOGIN = '_m365_login_last_login';
const META_TID = '_m365_login_tid'; // Tenant the object ID belongs to.
const JWKS_CACHE_TTL = 12 * HOUR_IN_SECONDS;
const HTTP_TIMEOUT = 15;
@ -220,14 +221,16 @@ class M365_Login_Auth {
* 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).
* @param bool $send Whether to send the cookies.
* @param int $expire Expiry (unused).
* @param int $expiration Expiration (unused).
* @param int|null $user_id User ID (0 when cookies are cleared; not passed before WordPress 6.2).
* @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() ) {
public function block_api_auth_cookies( $send, $expire = 0, $expiration = 0, $user_id = null ) {
// Before WordPress 6.2 the filter gets no user ID: block in API contexts anyway (also blocks
// clearing cookies there, which is harmless).
if ( ! $send || 0 === $user_id || ! $this->settings->button_only() || $this->fallback_active() ) {
return $send;
}
if ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
@ -537,9 +540,11 @@ class M365_Login_Auth {
}
}
// 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 ) ) {
// Privileged accounts are only safe through a verified binding (bind_oid on and the stored
// object ID matches checked above). Otherwise 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.
$bound = $this->settings->get( 'bind_oid' ) && '' !== $stored;
if ( ! $bound && 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' );
}
@ -561,6 +566,9 @@ class M365_Login_Auth {
$this->fail( 'oid_mismatch' );
}
update_user_meta( $user->ID, self::META_OID, $oid );
if ( isset( $claims['tid'] ) && M365_Login_Settings::is_guid( (string) $claims['tid'] ) ) {
update_user_meta( $user->ID, self::META_TID, strtolower( (string) $claims['tid'] ) );
}
}
/**