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

@ -12,15 +12,18 @@ defined( 'ABSPATH' ) || exit;
*/
class M365_Login_Admin {
const PAGE = 'm365-login';
const GROUP = 'm365_login';
const AJAX_TEST = 'm365_login_test_connection';
const AJAX_GROUPS = 'm365_login_search_groups';
const AJAX_CERT = 'm365_login_certificate';
const AJAX_SYNC = 'm365_login_sync_run';
const POST_CERT = 'm365_login_download_cert';
const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+';
const NONCE_TEST = 'm365_login_test';
const PAGE = 'm365-login';
const GROUP = 'm365_login';
const AJAX_TEST = 'm365_login_test_connection';
const AJAX_GROUPS = 'm365_login_search_groups';
const AJAX_CERT = 'm365_login_certificate';
const AJAX_SYNC = 'm365_login_sync_run';
const POST_CERT = 'm365_login_download_cert';
const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+';
const NONCE_TEST = 'm365_login_test';
const NONCE_GROUPS = 'm365_login_groups';
const NONCE_CERT = 'm365_login_cert';
const NONCE_SYNC = 'm365_login_sync';
/**
* Settings.
@ -82,6 +85,19 @@ class M365_Login_Admin {
add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) );
add_action( 'admin_notices', array( $this, 'setup_notice' ) );
add_filter( 'submenu_file', array( $this, 'highlight_submenu' ) );
add_filter( 'option_page_capability_' . self::GROUP, array( __CLASS__, 'capability' ) );
}
/**
* Capability needed for the settings, the connection test, certificates and the user sync.
*
* On multisite only super admins: the settings decide which Microsoft identities may sign in
* as which (network-wide) WordPress users, so a site administrator must not control them.
*
* @return string
*/
public static function capability() {
return is_multisite() ? 'manage_network_options' : 'manage_options';
}
/**
@ -91,7 +107,7 @@ class M365_Login_Admin {
$this->hook = add_menu_page(
__( 'M365 Login', 'm365-login' ),
__( 'M365 Login', 'm365-login' ),
'manage_options',
self::capability(),
self::PAGE,
array( $this, 'render' ),
self::MENU_ICON,
@ -103,7 +119,7 @@ class M365_Login_Admin {
self::PAGE,
$label . ' ' . __( 'M365 Login', 'm365-login' ),
$label,
'manage_options',
self::capability(),
'connection' === $tab ? self::PAGE : 'admin.php?page=' . self::PAGE . '&tab=' . $tab
);
}
@ -182,7 +198,19 @@ class M365_Login_Admin {
* Nudges administrators to finish the setup.
*/
public function setup_notice() {
if ( $this->settings->is_configured() || ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( self::capability() ) ) {
return;
}
if ( ! $this->settings->is_configured() && $this->settings->button_only() ) {
printf(
'<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
esc_html__( 'M365 Login: button-only mode is on, but the connection to Microsoft is broken (missing or undecryptable secret, or expired certificate). Nobody can sign in except through the fallback link.', 'm365-login' ),
esc_url( self::url() ),
esc_html__( 'Open the settings', 'm365-login' )
);
return;
}
if ( $this->settings->is_configured() ) {
return;
}
$screen = get_current_screen();
@ -220,7 +248,12 @@ class M365_Login_Admin {
'm365LoginAdmin',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE_TEST ),
'nonces' => array(
'test' => wp_create_nonce( self::NONCE_TEST ),
'groups' => wp_create_nonce( self::NONCE_GROUPS ),
'cert' => wp_create_nonce( self::NONCE_CERT ),
'sync' => wp_create_nonce( self::NONCE_SYNC ),
),
'action' => self::AJAX_TEST,
'groupAction' => self::AJAX_GROUPS,
'certAction' => self::AJAX_CERT,
@ -257,7 +290,7 @@ class M365_Login_Admin {
*/
public function ajax_test_connection() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( self::capability() ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
@ -295,8 +328,8 @@ class M365_Login_Admin {
* AJAX: search Entra groups through Microsoft Graph.
*/
public function ajax_search_groups() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
check_ajax_referer( self::NONCE_GROUPS, 'nonce' );
if ( ! current_user_can( self::capability() ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
if ( ! $this->settings->is_configured() ) {
@ -321,8 +354,8 @@ class M365_Login_Admin {
* AJAX: generate a new self-signed certificate and store it (key encrypted).
*/
public function ajax_certificate() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
check_ajax_referer( self::NONCE_CERT, 'nonce' );
if ( ! current_user_can( self::capability() ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : '';
@ -354,8 +387,8 @@ class M365_Login_Admin {
* AJAX: run the user sync (dry run or live) with the saved settings.
*/
public function ajax_sync() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'create_users' ) ) {
check_ajax_referer( self::NONCE_SYNC, 'nonce' );
if ( ! current_user_can( self::capability() ) || ! current_user_can( 'create_users' ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : '';
@ -486,7 +519,7 @@ class M365_Login_Admin {
<?php if ( 'roles' === $mode ) : ?>
<span class="m365-group-chip__arrow" aria-hidden="true">&rarr;</span>
<select name="<?php echo esc_attr( $base . '[' . $gid . '][role]' ); ?>" aria-label="<?php esc_attr_e( 'WordPress role', 'm365-login' ); ?>">
<?php wp_dropdown_roles( $value['role'] ); ?>
<?php $this->role_options( $value['role'] ); ?>
</select>
<input type="hidden" name="<?php echo esc_attr( $base . '[' . $gid . '][name]' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
<?php else : ?>
@ -498,7 +531,7 @@ class M365_Login_Admin {
</ul>
<?php if ( 'roles' === $mode ) : ?>
<select class="m365-picker__role-template" hidden disabled>
<?php wp_dropdown_roles( 'editor' ); ?>
<?php $this->role_options( 'editor' ); ?>
</select>
<?php endif; ?>
</div>
@ -506,11 +539,30 @@ class M365_Login_Admin {
<?php
}
/**
* Prints <option> elements for all editable roles (escaped, unlike wp_dropdown_roles()).
*
* @param string $selected Selected role slug.
*/
private function role_options( $selected ) {
if ( ! function_exists( 'get_editable_roles' ) ) {
require_once ABSPATH . 'wp-admin/includes/user.php';
}
foreach ( array_reverse( get_editable_roles() ) as $role => $details ) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $role ),
selected( $selected, $role, false ),
esc_html( translate_user_role( $details['name'] ) )
);
}
}
/**
* Sends the public certificate as a .cer download (never the private key).
*/
public function download_certificate() {
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( self::capability() ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 );
}
check_admin_referer( self::POST_CERT );
@ -599,13 +651,14 @@ class M365_Login_Admin {
<div class="m365-field">
<label for="m365-sync-role"><?php esc_html_e( 'Default role', 'm365-login' ); ?></label>
<select id="m365-sync-role" name="<?php echo $field( 'sync_default_role' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
<?php wp_dropdown_roles( $s['sync_default_role'] ); ?>
<?php $this->role_options( $s['sync_default_role'] ); ?>
</select>
<p class="description"><?php esc_html_e( 'Every imported user gets this role. The sync manages the roles of imported accounts manual role changes are overwritten on the next run.', 'm365-login' ); ?></p>
</div>
<h3 class="m365-card__subtitle"><?php esc_html_e( 'Additional roles from Microsoft 365 groups', 'm365-login' ); ?></h3>
<p class="description"><?php esc_html_e( 'Members of a group (nested memberships count) get the role next to it. If a person leaves the group, the role is removed again on the next sync.', 'm365-login' ); ?></p>
<p class="m365-warning"><?php esc_html_e( 'Whoever can change a group\'s members controls the mapped role. For roles with administrative rights use security groups (ideally role-assignable ones) never public Microsoft 365 groups or Teams, which members can join themselves.', 'm365-login' ); ?></p>
<?php $this->group_picker( 'sync_role_map', $this->settings->sync_role_map(), 'roles', __( 'No group mapping everybody gets the default role.', 'm365-login' ) ); ?>
<div class="m365-field">
@ -703,7 +756,7 @@ class M365_Login_Admin {
* Renders the settings screen.
*/
public function render() {
if ( ! current_user_can( 'manage_options' ) ) {
if ( ! current_user_can( self::capability() ) ) {
wp_die( esc_html__( 'You are not allowed to access this page.', 'm365-login' ) );
}
@ -806,6 +859,9 @@ class M365_Login_Admin {
</label>
<?php endif; ?>
<p class="description"><?php esc_html_e( 'Stored encrypted (AES-256-GCM, key derived from your WordPress salts) and never displayed again. Client secrets expire note the expiry date in Entra ID.', 'm365-login' ); ?></p>
<?php if ( ! defined( 'AUTH_KEY' ) || ! defined( 'SECURE_AUTH_KEY' ) ) : ?>
<p class="m365-warning"><?php esc_html_e( 'AUTH_KEY and SECURE_AUTH_KEY are not defined in wp-config.php, so WordPress keeps its salts in the database right next to the encrypted secret. Add the salts to wp-config.php to make the encryption effective.', 'm365-login' ); ?></p>
<?php endif; ?>
</div>
<details class="m365-guide" <?php echo $has_secret ? '' : 'open'; ?>>
@ -1085,6 +1141,9 @@ class M365_Login_Admin {
<h2 class="m365-card__title"><?php esc_html_e( 'Excluded Entra groups (optional)', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Members of these groups can never sign in with Microsoft even if they are in an allowed group. Nested memberships count.', 'm365-login' ); ?></p>
<?php if ( $this->settings->is_multi_tenant() ) : ?>
<p class="m365-warning m365-warning--strong"><?php esc_html_e( 'Group rules need a pinned tenant ID (GUID) on the Connection tab. In multi-tenant mode the group check cannot ask Microsoft Graph, so every sign-in is refused while groups are selected here or above.', 'm365-login' ); ?></p>
<?php endif; ?>
<?php $this->group_picker( 'denied_groups', $this->settings->denied_groups(), 'chips', __( 'No groups excluded.', 'm365-login' ) ); ?>
<div class="m365-field">
<p class="description"><?php esc_html_e( 'The plugin asks Microsoft Graph on every sign-in (application permission "User.Read.All" or "Directory.Read.All"), because a "groups" claim may be filtered and cannot prove that someone is not a member. If the check fails, the sign-in is refused. Password sign-in is not affected combine with button-only mode if needed.', 'm365-login' ); ?></p>
@ -1093,7 +1152,7 @@ class M365_Login_Admin {
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Button-only mode', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every interactive password sign-in on the site, including custom login forms. Application passwords, REST, XML-RPC and WP-CLI are not affected.', 'm365-login' ); ?></p>
<p class="m365-card__intro"><?php esc_html_e( 'Hides the username/password fields (on wp-login.php and in wp_login_form() forms) and refuses every sign-in with a normal password on the site custom login forms, XML-RPC and login endpoints of other plugins included. Application passwords (REST, XML-RPC) and WP-CLI keep working; API requests never receive a login cookie.', 'm365-login' ); ?></p>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'button_only' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['button_only'] ); ?> id="m365-button-only" />