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

@ -402,7 +402,10 @@ class M365_Login_Settings {
if ( defined( 'M365_LOGIN_DISABLE_BUTTON_ONLY' ) && M365_LOGIN_DISABLE_BUTTON_ONLY ) {
return false;
}
return $this->is_configured() && (bool) $this->get( 'button_only' ) && '' !== $this->fallback_key();
// Deliberately not tied to is_configured(): an expired certificate or rotated salts must not
// silently re-enable password sign-in. The fallback link and the constant stay available.
return '' !== (string) $this->get( 'tenant_id' ) && '' !== (string) $this->get( 'client_id' )
&& (bool) $this->get( 'button_only' ) && '' !== $this->fallback_key();
}
/**
@ -497,7 +500,7 @@ class M365_Login_Settings {
$out['client_id'] = strtolower( $client_id );
// Client secret: only replaced when a new value was entered.
$secret_input = isset( $input['client_secret'] ) ? (string) wp_unslash( $input['client_secret'] ) : '';
$secret_input = self::scalar( $input, 'client_secret' );
$secret_input = trim( $secret_input );
if ( ! empty( $input['client_secret_clear'] ) ) {
$out['client_secret'] = '';
@ -520,8 +523,8 @@ class M365_Login_Settings {
// Certificate: keep the stored pair unless a new one is pasted or removal is requested.
$out['cert_private_key'] = $current['cert_private_key'];
$out['cert_certificate'] = $current['cert_certificate'];
$pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : '';
$pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : '';
$pasted_key = trim( self::scalar( $input, 'cert_key_pem' ) );
$pasted_cert = trim( self::scalar( $input, 'cert_cert_pem' ) );
if ( ! empty( $input['cert_remove'] ) ) {
$out['cert_private_key'] = '';
$out['cert_certificate'] = '';
@ -573,7 +576,7 @@ class M365_Login_Settings {
$out['fallback_key'] = $key;
// Custom login page (must be on this site).
$custom = isset( $input['custom_login_url'] ) ? esc_url_raw( trim( wp_unslash( $input['custom_login_url'] ) ) ) : '';
$custom = esc_url_raw( trim( self::scalar( $input, 'custom_login_url' ) ) );
if ( '' !== $custom ) {
if ( 0 === strpos( $custom, '/' ) ) {
$custom = home_url( $custom );
@ -590,13 +593,13 @@ class M365_Login_Settings {
$text = isset( $input['button_text'] ) ? sanitize_text_field( wp_unslash( $input['button_text'] ) ) : '';
$out['button_text'] = '' === trim( $text ) ? $defaults['button_text'] : mb_substr( $text, 0, 80 );
$icon = isset( $input['button_icon'] ) ? esc_url_raw( trim( wp_unslash( $input['button_icon'] ) ) ) : '';
$icon = esc_url_raw( trim( self::scalar( $input, 'button_icon' ) ) );
$out['button_icon'] = self::is_safe_image_url( $icon ) ? $icon : '';
$out['button_show_icon'] = empty( $input['button_show_icon'] ) ? 0 : 1;
foreach ( array( 'button_bg', 'button_bg_hover', 'button_color', 'button_border' ) as $color_key ) {
$color = isset( $input[ $color_key ] ) ? sanitize_hex_color( trim( wp_unslash( $input[ $color_key ] ) ) ) : '';
$color = sanitize_hex_color( trim( self::scalar( $input, $color_key ) ) );
$out[ $color_key ] = $color ? $color : $defaults[ $color_key ];
}
@ -692,6 +695,17 @@ class M365_Login_Settings {
return $out;
}
/**
* Unslashed string value of a posted field ('' for missing or non-scalar values such as arrays).
*
* @param array $input Raw input.
* @param string $key Field.
* @return string
*/
private static function scalar( $input, $key ) {
return isset( $input[ $key ] ) && is_scalar( $input[ $key ] ) ? (string) wp_unslash( $input[ $key ] ) : '';
}
/**
* Sanitises a GUID => name list posted by a group picker.
*