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

@ -107,9 +107,10 @@ class M365_Login_Graph {
* @param string $path Path relative to the v1.0 base (with query string) or an absolute Graph URL (paging links).
* @param array|null $json JSON body for POST requests.
* @param array $headers Extra headers.
* @param bool $retry Retry on 429/503/504.
* @return array|WP_Error Response array from wp_remote_request().
*/
private function raw_request( $method, $path, $json = null, $headers = array() ) {
private function raw_request( $method, $path, $json = null, $headers = array(), $retry = true ) {
$url = 0 === strpos( $path, self::GRAPH_BASE . '/' ) ? $path : self::GRAPH_BASE . $path;
if ( 0 !== strpos( $url, self::GRAPH_BASE . '/' ) ) {
return new WP_Error( 'graph_bad_url', 'Refusing to call a non-Graph URL.' );
@ -146,7 +147,7 @@ class M365_Login_Graph {
if ( 401 === $code ) {
$this->flush_token();
}
if ( $attempt < 4 && in_array( $code, array( 429, 503, 504 ), true ) ) {
if ( $retry && $attempt < 4 && in_array( $code, array( 429, 503, 504 ), true ) ) {
$wait = (int) wp_remote_retrieve_header( $response, 'retry-after' );
sleep( max( 1, min( 10, $wait > 0 ? $wait : $attempt * 2 ) ) );
continue;
@ -162,10 +163,11 @@ class M365_Login_Graph {
* @param string $path Path relative to the v1.0 base (with query string).
* @param array|null $json JSON body for POST requests.
* @param array $headers Extra headers.
* @param bool $retry Retry on 429/503/504.
* @return array|WP_Error Decoded JSON. Errors carry array( 'status' => HTTP code ) as data.
*/
private function request( $method, $path, $json = null, $headers = array() ) {
$response = $this->raw_request( $method, $path, $json, $headers );
private function request( $method, $path, $json = null, $headers = array(), $retry = true ) {
$response = $this->raw_request( $method, $path, $json, $headers, $retry );
if ( is_wp_error( $response ) ) {
return $response;
}
@ -390,10 +392,10 @@ class M365_Login_Graph {
*/
public function search_groups( $query ) {
$query = trim( (string) $query );
$select = '$select=id,displayName,description,securityEnabled,mailEnabled&$top=25&$orderby=displayName';
$select = '$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility&$top=25&$orderby=displayName';
if ( '' !== $query && M365_Login_Settings::is_guid( $query ) ) {
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled';
$path = '/groups/' . rawurlencode( strtolower( $query ) ) . '?$select=id,displayName,description,securityEnabled,mailEnabled,groupTypes,visibility';
$item = $this->request( 'GET', $path );
if ( is_wp_error( $item ) ) {
return $item;
@ -437,6 +439,10 @@ class M365_Login_Graph {
} elseif ( ! empty( $item['mailEnabled'] ) ) {
$type = __( 'Microsoft 365 group', 'm365-login' );
}
$unified = isset( $item['groupTypes'] ) && is_array( $item['groupTypes'] ) && in_array( 'Unified', $item['groupTypes'], true );
if ( $unified && isset( $item['visibility'] ) && 'Public' === $item['visibility'] ) {
$type = __( 'Public Microsoft 365 group anyone in the organisation can join', 'm365-login' );
}
return array(
'id' => strtolower( (string) $item['id'] ),
'name' => isset( $item['displayName'] ) ? (string) $item['displayName'] : (string) $item['id'],
@ -450,9 +456,10 @@ class M365_Login_Graph {
*
* @param string $user_oid User object ID.
* @param string[] $group_ids Group object IDs (any count; chunked by 20).
* @param bool $retry Retry on throttling (off in the interactive sign-in).
* @return string[]|WP_Error Matching group IDs.
*/
public function check_member_groups( $user_oid, $group_ids ) {
public function check_member_groups( $user_oid, $group_ids, $retry = true ) {
if ( ! M365_Login_Settings::is_guid( $user_oid ) ) {
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
}
@ -462,7 +469,9 @@ class M365_Login_Graph {
$result = $this->request(
'POST',
'/users/' . rawurlencode( strtolower( $user_oid ) ) . '/checkMemberGroups',
array( 'groupIds' => $chunk )
array( 'groupIds' => $chunk ),
array(),
$retry
);
if ( is_wp_error( $result ) ) {
return $result;