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
|
|
@ -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' ),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue