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