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
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:
parent
791f43a80b
commit
850f0dcd54
18 changed files with 1908 additions and 1270 deletions
|
|
@ -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">→</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" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue