wp-m365-login/includes/class-m365-login-settings.php
Friederich Loheide 9b893e42bc Fix the findings of the third security audit
- Privileged accounts: the UPN rule also applies when bind_oid is off or
  the account is not bound; privileges are checked on every site of a
  multisite user, include code/HTML capabilities (unfiltered_html,
  plugins, themes, users) and the remembered roles of deactivated
  accounts.
- send_auth_cookies protection also works on WordPress 6.0/6.1.
- Run lock via INSERT IGNORE (atomic), refreshed during long runs; a
  shutdown handler reports fatal errors and frees the lock.
- Deprovisioning only for accounts linked in the current tenant (tenant
  recorded per account; legacy links not found are left alone).
- Safety stop based on the accounts linked before the run; new safety
  stop for removals of administrative roles.
- Disable is idempotent; row-action nonces are bound to the state.
- Profile photos are re-encoded to 240 px (drops EXIF and appended
  data), size-limited while downloading, removed on deactivation;
  index.php guard in the photo folder.
- Privacy exporter and eraser for the copied data.
- One-time migration hardens accounts deactivated by 1.0 and cleans a
  stored certificate bundle; the .cer download is always re-exported.
- Password fields hidden in button-only mode even when the connection
  is broken; settings written non-autoloaded; robust user ID queries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 03:52:13 +00:00

790 lines
26 KiB
PHP

<?php
/**
* Settings storage and sanitisation.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Reads, sanitises and writes plugin settings.
*/
class M365_Login_Settings {
/**
* Cached settings.
*
* @var array|null
*/
private $cache = null;
/**
* Set while the plugin writes already sanitised values (skips the form sanitiser).
*
* @var bool
*/
private $raw_write = false;
/**
* Default settings.
*
* @return array
*/
public function defaults() {
return array(
// Connection.
'tenant_id' => '',
'client_id' => '',
'client_secret' => '', // Stored encrypted.
'auth_method' => 'secret', // 'secret' or 'certificate'.
'cert_private_key' => '', // PEM, stored encrypted.
'cert_certificate' => '', // PEM (public).
'prompt' => 'select_account',
// Security / matching.
'upn_fallback' => 1,
'bind_oid' => 1,
'allowed_domains' => '',
'allowed_groups' => array(), // id => display name.
'denied_groups' => array(), // id => display name; members may never sign in.
'remember_me' => 0,
// Button-only mode.
'button_only' => 0,
'fallback_key' => '',
// Button appearance.
'button_text' => __( 'Sign in with Microsoft', 'm365-login' ),
'button_icon' => '', // Empty = bundled Microsoft logo.
'button_show_icon' => 1,
'button_bg' => '#2f2f2f',
'button_bg_hover' => '#1a1a1a',
'button_color' => '#ffffff',
'button_border' => '#2f2f2f',
'button_radius' => 4,
'button_position' => 'below',
'divider_text' => __( 'or', 'm365-login' ),
// Custom login pages.
'custom_login_url' => '',
'inject_form' => 1, // Add the button to wp_login_form() output.
// User sync.
'sync_enabled' => 0, // Scheduled sync via WP-Cron.
'sync_interval' => 'daily',
'sync_guests' => 0,
'sync_scope_groups' => array(), // id => display name; empty = whole tenant.
'sync_default_role' => 'subscriber',
'sync_role_map' => array(), // id => array( 'name' => .., 'role' => .. ), in priority order.
'sync_role_mode' => 'add', // 'add' (extra roles) or 'replace' (first match replaces the default role).
'sync_manage_existing' => 0, // Also manage roles of accounts that existed before the sync.
'sync_attributes' => array( 'displayName', 'givenName', 'surname' ),
'sync_disabled_action' => 'disable', // Account disabled in Microsoft 365: none|disable|delete.
'sync_deleted_action' => 'disable', // Account deleted in Microsoft 365: none|disable|delete.
'sync_scope_action' => 'none', // Removed from the sync groups: none|disable|delete.
'sync_reassign' => 0, // User ID that receives content of deleted users.
);
}
/**
* Returns all settings merged with defaults.
*
* @return array
*/
public function all() {
if ( null === $this->cache ) {
$stored = get_option( M365_LOGIN_OPTION, array() );
$this->cache = wp_parse_args( is_array( $stored ) ? $stored : array(), $this->defaults() );
}
return $this->cache;
}
/**
* Drops the cached settings (after the option was written).
*/
public function flush() {
$this->cache = null;
}
/**
* Returns a single setting.
*
* @param string $key Setting key.
* @param mixed $default Fallback.
* @return mixed
*/
public function get( $key, $default = null ) {
$all = $this->all();
return array_key_exists( $key, $all ) ? $all[ $key ] : $default;
}
/**
* Decrypted client secret.
*
* @return string
*/
public function client_secret() {
$enc = (string) $this->get( 'client_secret', '' );
if ( '' === $enc ) {
return '';
}
$plain = M365_Login_Crypto::decrypt( $enc );
return is_string( $plain ) ? $plain : '';
}
/**
* Selected client authentication method.
*
* @return string 'secret' or 'certificate'.
*/
public function auth_method() {
return 'certificate' === $this->get( 'auth_method' ) ? 'certificate' : 'secret';
}
/**
* Decrypted certificate private key (PEM) or ''.
*
* @return string
*/
public function certificate_key() {
$enc = (string) $this->get( 'cert_private_key', '' );
if ( '' === $enc ) {
return '';
}
$plain = M365_Login_Crypto::decrypt( $enc );
return is_string( $plain ) ? $plain : '';
}
/**
* Certificate PEM (public part) or ''.
*
* @return string
*/
public function certificate_pem() {
return (string) $this->get( 'cert_certificate', '' );
}
/**
* Whether a usable certificate + key pair is stored.
*
* @return bool
*/
public function has_certificate() {
return '' !== $this->certificate_pem() && '' !== $this->certificate_key();
}
/**
* Parsed certificate metadata or null.
*
* @return array|null
*/
public function certificate_info() {
return $this->has_certificate() ? M365_Login_Certificate::info( $this->certificate_pem() ) : null;
}
/**
* Whether the plugin has everything it needs to start a login.
*
* @return bool
*/
public function is_configured() {
if ( '' === $this->get( 'tenant_id' ) || '' === $this->get( 'client_id' ) ) {
return false;
}
if ( 'certificate' === $this->auth_method() ) {
$info = $this->certificate_info();
return null !== $info && ( 0 === $info['not_after'] || $info['not_after'] > time() );
}
return '' !== $this->client_secret();
}
/**
* Client authentication parameters for the token endpoint (secret or signed assertion).
*
* @param string $token_endpoint Token endpoint URL (assertion audience).
* @return array|WP_Error
*/
public function client_auth_params( $token_endpoint ) {
if ( 'certificate' === $this->auth_method() ) {
$assertion = M365_Login_Certificate::assertion( $this->certificate_key(), $this->certificate_pem(), (string) $this->get( 'client_id' ), $token_endpoint );
if ( is_wp_error( $assertion ) ) {
return $assertion;
}
return array(
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion' => $assertion,
);
}
return array( 'client_secret' => $this->client_secret() );
}
/**
* Stores a validated key/certificate pair (key encrypted).
*
* @param array $pair array( 'private_key' => PEM, 'certificate' => PEM ).
* @return true|WP_Error
*/
public function store_certificate( $pair ) {
$enc = M365_Login_Crypto::encrypt( $pair['private_key'] );
if ( false === $enc ) {
return new WP_Error( 'encrypt', __( 'The private key could not be encrypted. Is the OpenSSL extension available?', 'm365-login' ) );
}
$all = $this->all();
$all['cert_private_key'] = $enc;
$all['cert_certificate'] = $pair['certificate'];
$this->write( $all );
return true;
}
/**
* Replaces the stored certificate by a clean re-export (drops key bundles or chains saved by older versions).
*/
public function normalise_stored_certificate() {
$pem = $this->certificate_pem();
if ( '' === $pem ) {
return;
}
$clean = M365_Login_Certificate::clean_pem( $pem );
if ( '' !== $clean && $clean !== $pem ) {
$all = $this->all();
$all['cert_certificate'] = $clean;
$this->write( $all );
}
}
/**
* Removes the stored certificate and key.
*/
public function remove_certificate() {
$all = $this->all();
$all['cert_private_key'] = '';
$all['cert_certificate'] = '';
$this->write( $all );
}
/**
* Stores already sanitised settings.
*
* The option is registered with sanitize() as callback, which expects raw form input
* (it would, for example, encrypt the stored client secret a second time).
*
* @param array $all Complete settings.
*/
private function write( $all ) {
$this->raw_write = true;
update_option( M365_LOGIN_OPTION, $all, false ); // Holds encrypted secrets: never autoloaded.
$this->raw_write = false;
$this->cache = null;
}
/**
* Tenant segment used in Microsoft endpoints.
*
* @return string
*/
public function tenant() {
$tenant = (string) $this->get( 'tenant_id', '' );
return '' === $tenant ? 'organizations' : $tenant;
}
/**
* Whether sign-ins from more than one tenant are accepted (no tenant GUID pinned).
*
* @return bool
*/
public function is_multi_tenant() {
return ! self::is_guid( $this->tenant() );
}
/**
* Redirect URI registered in Entra ID.
*
* @return string
*/
public function redirect_uri() {
if ( $this->uses_pretty_callback() ) {
$uri = home_url( '/m365-login/callback' );
} else {
$uri = add_query_arg( 'm365-login', 'callback', home_url( '/' ) );
}
/**
* Filters the redirect URI registered in Entra ID.
*
* @param string $uri Redirect URI.
*/
return (string) apply_filters( 'm365_login_redirect_uri', $uri );
}
/**
* Whether the callback can use a path (requires rewrite rules) instead of a query argument.
*
* @return bool
*/
public function uses_pretty_callback() {
return '' !== (string) get_option( 'permalink_structure', '' );
}
/**
* Allowed e-mail domains as an array (lowercase, no leading @).
*
* @return string[]
*/
public function allowed_domains() {
$raw = (string) $this->get( 'allowed_domains', '' );
if ( '' === trim( $raw ) ) {
return array();
}
$parts = preg_split( '/[\s,;]+/', strtolower( $raw ) );
$out = array();
foreach ( $parts as $p ) {
$p = ltrim( trim( $p ), '@' );
if ( '' !== $p ) {
$out[] = $p;
}
}
return array_values( array_unique( $out ) );
}
/**
* Allowed Entra group IDs (lowercase GUIDs) mapped to display names.
*
* @return array
*/
public function allowed_groups() {
return self::guid_map( $this->get( 'allowed_groups', array() ) );
}
/**
* Excluded Entra group IDs (lowercase GUIDs) mapped to display names.
*
* @return array
*/
public function denied_groups() {
return self::guid_map( $this->get( 'denied_groups', array() ) );
}
/**
* Groups that limit the user sync (lowercase GUID => name); empty = whole tenant.
*
* @return array
*/
public function sync_scope_groups() {
return self::guid_map( $this->get( 'sync_scope_groups', array() ) );
}
/**
* Group → role mapping in priority order.
*
* @return array lowercase GUID => array( 'name' => string, 'role' => string ).
*/
public function sync_role_map() {
$raw = $this->get( 'sync_role_map', array() );
$out = array();
if ( is_array( $raw ) ) {
foreach ( $raw as $id => $row ) {
$id = strtolower( (string) $id );
if ( self::is_guid( $id ) && is_array( $row ) && ! empty( $row['role'] ) ) {
$out[ $id ] = array(
'name' => isset( $row['name'] ) ? (string) $row['name'] : $id,
'role' => (string) $row['role'],
);
}
}
}
return $out;
}
/**
* Keeps GUID keys (lowercased) of an id => name array.
*
* @param mixed $raw Stored value.
* @return array
*/
private static function guid_map( $raw ) {
$out = array();
if ( is_array( $raw ) ) {
foreach ( $raw as $id => $name ) {
$id = strtolower( (string) $id );
if ( self::is_guid( $id ) ) {
$out[ $id ] = (string) $name;
}
}
}
return $out;
}
/**
* Whether the password form is hidden and password sign-in blocked.
*
* @return bool
*/
public function button_only() {
if ( defined( 'M365_LOGIN_DISABLE_BUTTON_ONLY' ) && M365_LOGIN_DISABLE_BUTTON_ONLY ) {
return false;
}
// Deliberately not tied to is_configured(): an expired certificate or rotated salts must not
// silently re-enable password sign-in. The fallback link and the constant stay available.
return '' !== (string) $this->get( 'tenant_id' ) && '' !== (string) $this->get( 'client_id' )
&& (bool) $this->get( 'button_only' ) && '' !== $this->fallback_key();
}
/**
* Secret key that re-enables the password form.
*
* @return string
*/
public function fallback_key() {
$key = (string) $this->get( 'fallback_key', '' );
return preg_match( '/^[A-Za-z0-9]{16,64}$/', $key ) ? $key : '';
}
/**
* URL that shows the password form again when button-only mode is active.
*
* @return string
*/
public function fallback_url() {
$key = $this->fallback_key();
return '' === $key ? '' : add_query_arg( 'm365_fallback', $key, $this->login_page_url() );
}
/**
* URL of a custom login page (same site), or empty string.
*
* @return string
*/
public function custom_login_url() {
$url = (string) $this->get( 'custom_login_url', '' );
if ( '' === $url ) {
return '';
}
$validated = wp_validate_redirect( $url, '' );
return is_string( $validated ) ? $validated : '';
}
/**
* Where users land after a failed Microsoft sign-in (custom page or wp-login.php).
*
* @return string
*/
public function login_page_url() {
$custom = $this->custom_login_url();
return '' !== $custom ? $custom : wp_login_url();
}
/**
* Generates a new fallback key.
*
* @return string
*/
public static function generate_fallback_key() {
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
$key = '';
for ( $i = 0; $i < 24; $i++ ) {
$key .= $alphabet[ random_int( 0, strlen( $alphabet ) - 1 ) ];
}
return $key;
}
/**
* Sanitises settings coming from the admin form.
*
* @param array $input Raw input.
* @return array
*/
public function sanitize( $input ) {
if ( $this->raw_write ) {
return $input;
}
$defaults = $this->defaults();
$current = $this->all();
$input = is_array( $input ) ? $input : array();
$out = $current;
// Tenant: GUID or one of the well-known aliases.
$tenant = isset( $input['tenant_id'] ) ? trim( sanitize_text_field( wp_unslash( $input['tenant_id'] ) ) ) : '';
$tenant = strtolower( $tenant );
if ( '' !== $tenant && ! self::is_valid_tenant( $tenant ) ) {
add_settings_error( M365_LOGIN_OPTION, 'tenant_id', __( 'The tenant ID must be a GUID (e.g. 1a2b3c4d-…) or one of "organizations", "common", "consumers".', 'm365-login' ) );
$tenant = $current['tenant_id'];
}
$out['tenant_id'] = $tenant;
// Client ID: GUID.
$client_id = isset( $input['client_id'] ) ? trim( sanitize_text_field( wp_unslash( $input['client_id'] ) ) ) : '';
if ( '' !== $client_id && ! self::is_guid( $client_id ) ) {
add_settings_error( M365_LOGIN_OPTION, 'client_id', __( 'The application (client) ID must be a GUID.', 'm365-login' ) );
$client_id = $current['client_id'];
}
$out['client_id'] = strtolower( $client_id );
// Client secret: only replaced when a new value was entered.
$secret_input = self::scalar( $input, 'client_secret' );
$secret_input = trim( $secret_input );
if ( ! empty( $input['client_secret_clear'] ) ) {
$out['client_secret'] = '';
} elseif ( '' !== $secret_input ) {
if ( strlen( $secret_input ) > 512 || preg_match( '/[\x00-\x1F\x7F]/', $secret_input ) ) {
add_settings_error( M365_LOGIN_OPTION, 'client_secret', __( 'The client secret contains invalid characters.', 'm365-login' ) );
} else {
$enc = M365_Login_Crypto::encrypt( $secret_input );
if ( false === $enc ) {
add_settings_error( M365_LOGIN_OPTION, 'client_secret', __( 'The client secret could not be encrypted. Is the OpenSSL extension available?', 'm365-login' ) );
} else {
$out['client_secret'] = $enc;
}
}
}
$method = isset( $input['auth_method'] ) ? sanitize_key( $input['auth_method'] ) : 'secret';
$out['auth_method'] = 'certificate' === $method ? 'certificate' : 'secret';
// Certificate: keep the stored pair unless a new one is pasted or removal is requested.
$out['cert_private_key'] = $current['cert_private_key'];
$out['cert_certificate'] = $current['cert_certificate'];
$pasted_key = trim( self::scalar( $input, 'cert_key_pem' ) );
$pasted_cert = trim( self::scalar( $input, 'cert_cert_pem' ) );
if ( ! empty( $input['cert_remove'] ) ) {
$out['cert_private_key'] = '';
$out['cert_certificate'] = '';
} elseif ( '' !== $pasted_key || '' !== $pasted_cert ) {
if ( '' === $pasted_key || '' === $pasted_cert ) {
add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'Please paste both the private key and the certificate.', 'm365-login' ) );
} elseif ( strlen( $pasted_key ) > 20000 || strlen( $pasted_cert ) > 20000 ) {
add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'The pasted key or certificate is too large.', 'm365-login' ) );
} else {
$pair = M365_Login_Certificate::from_pem( $pasted_key, $pasted_cert );
if ( is_wp_error( $pair ) ) {
add_settings_error( M365_LOGIN_OPTION, 'certificate', $pair->get_error_message() );
} else {
$enc = M365_Login_Crypto::encrypt( $pair['private_key'] );
if ( false === $enc ) {
add_settings_error( M365_LOGIN_OPTION, 'certificate', __( 'The private key could not be encrypted. Is the OpenSSL extension available?', 'm365-login' ) );
} else {
$out['cert_private_key'] = $enc;
$out['cert_certificate'] = $pair['certificate'];
}
}
}
}
if ( 'certificate' === $out['auth_method'] && '' === $out['cert_certificate'] ) {
add_settings_error( M365_LOGIN_OPTION, 'auth_method', __( 'Certificate authentication is selected but no certificate is stored yet. Generate one or paste your own; the Microsoft button stays hidden until then.', 'm365-login' ), 'warning' );
}
$prompt = isset( $input['prompt'] ) ? sanitize_key( $input['prompt'] ) : '';
$out['prompt'] = in_array( $prompt, array( 'none', 'select_account', 'login' ), true ) ? $prompt : 'none';
$out['upn_fallback'] = empty( $input['upn_fallback'] ) ? 0 : 1;
$out['bind_oid'] = empty( $input['bind_oid'] ) ? 0 : 1;
$out['remember_me'] = empty( $input['remember_me'] ) ? 0 : 1;
$domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : '';
$domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains );
$out['allowed_domains'] = trim( (string) $domains );
// Allowed groups: GUID => name.
$out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_groups'] : array() );
$out['denied_groups'] = self::sanitize_group_list( isset( $input['denied_groups'] ) ? $input['denied_groups'] : array() );
// Button-only mode + fallback key.
$out['button_only'] = empty( $input['button_only'] ) ? 0 : 1;
$key = (string) $current['fallback_key'];
if ( ! empty( $input['fallback_regenerate'] ) || ! preg_match( '/^[A-Za-z0-9]{16,64}$/', $key ) ) {
$key = self::generate_fallback_key();
}
$out['fallback_key'] = $key;
// Custom login page (must be on this site).
$custom = esc_url_raw( trim( self::scalar( $input, 'custom_login_url' ) ) );
if ( '' !== $custom ) {
if ( 0 === strpos( $custom, '/' ) ) {
$custom = home_url( $custom );
}
if ( '' === wp_validate_redirect( $custom, '' ) ) {
add_settings_error( M365_LOGIN_OPTION, 'custom_login_url', __( 'The custom login page must be a URL on this site.', 'm365-login' ) );
$custom = $current['custom_login_url'];
}
}
$out['custom_login_url'] = $custom;
$out['inject_form'] = empty( $input['inject_form'] ) ? 0 : 1;
// Button.
$text = isset( $input['button_text'] ) ? sanitize_text_field( wp_unslash( $input['button_text'] ) ) : '';
$out['button_text'] = '' === trim( $text ) ? $defaults['button_text'] : mb_substr( $text, 0, 80 );
$icon = esc_url_raw( trim( self::scalar( $input, 'button_icon' ) ) );
$out['button_icon'] = self::is_safe_image_url( $icon ) ? $icon : '';
$out['button_show_icon'] = empty( $input['button_show_icon'] ) ? 0 : 1;
foreach ( array( 'button_bg', 'button_bg_hover', 'button_color', 'button_border' ) as $color_key ) {
$color = sanitize_hex_color( trim( self::scalar( $input, $color_key ) ) );
$out[ $color_key ] = $color ? $color : $defaults[ $color_key ];
}
$radius = isset( $input['button_radius'] ) ? absint( $input['button_radius'] ) : $defaults['button_radius'];
$out['button_radius'] = min( 50, $radius );
$position = isset( $input['button_position'] ) ? sanitize_key( $input['button_position'] ) : 'below';
$out['button_position'] = in_array( $position, array( 'above', 'below' ), true ) ? $position : 'below';
$divider = isset( $input['divider_text'] ) ? sanitize_text_field( wp_unslash( $input['divider_text'] ) ) : '';
$out['divider_text'] = mb_substr( $divider, 0, 40 );
$out = $this->sanitize_sync( $input, $out );
$this->cache = null;
return $out;
}
/**
* Sanitises the user sync settings.
*
* @param array $input Raw input.
* @param array $out Settings sanitised so far.
* @return array
*/
private function sanitize_sync( $input, $out ) {
$defaults = $this->defaults();
$out['sync_enabled'] = empty( $input['sync_enabled'] ) ? 0 : 1;
$out['sync_guests'] = empty( $input['sync_guests'] ) ? 0 : 1;
$out['sync_manage_existing'] = empty( $input['sync_manage_existing'] ) ? 0 : 1;
$interval = isset( $input['sync_interval'] ) ? sanitize_key( $input['sync_interval'] ) : '';
$out['sync_interval'] = in_array( $interval, array( 'hourly', 'twicedaily', 'daily' ), true ) ? $interval : $defaults['sync_interval'];
$mode = isset( $input['sync_role_mode'] ) ? sanitize_key( $input['sync_role_mode'] ) : '';
$out['sync_role_mode'] = in_array( $mode, array( 'add', 'replace' ), true ) ? $mode : $defaults['sync_role_mode'];
$role = isset( $input['sync_default_role'] ) ? sanitize_key( $input['sync_default_role'] ) : '';
$out['sync_default_role'] = '' !== $role && get_role( $role ) ? $role : $defaults['sync_default_role'];
$out['sync_scope_groups'] = self::sanitize_group_list( isset( $input['sync_scope_groups'] ) ? $input['sync_scope_groups'] : array() );
$map = array();
if ( ! empty( $input['sync_role_map'] ) && is_array( $input['sync_role_map'] ) ) {
foreach ( $input['sync_role_map'] as $id => $row ) {
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
if ( ! self::is_guid( $id ) || ! is_array( $row ) ) {
continue;
}
$map_role = isset( $row['role'] ) ? sanitize_key( $row['role'] ) : '';
if ( '' === $map_role || ! get_role( $map_role ) ) {
continue;
}
$name = isset( $row['name'] ) && is_scalar( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : '';
$map[ $id ] = array(
'name' => '' === $name ? $id : mb_substr( $name, 0, 120 ),
'role' => $map_role,
);
if ( count( $map ) >= 100 ) {
break;
}
}
}
$out['sync_role_map'] = $map;
$attributes = array();
if ( ! empty( $input['sync_attributes'] ) && is_array( $input['sync_attributes'] ) ) {
$known = array_keys( M365_Login_Sync::attributes() );
foreach ( $input['sync_attributes'] as $attribute ) {
if ( ! is_scalar( $attribute ) ) {
continue;
}
$attribute = sanitize_text_field( wp_unslash( (string) $attribute ) );
if ( in_array( $attribute, $known, true ) ) {
$attributes[] = $attribute;
}
}
}
$out['sync_attributes'] = array_values( array_unique( $attributes ) );
foreach ( array( 'sync_disabled_action', 'sync_deleted_action', 'sync_scope_action' ) as $key ) {
$action = isset( $input[ $key ] ) ? sanitize_key( $input[ $key ] ) : '';
$out[ $key ] = in_array( $action, array( 'none', 'disable', 'delete' ), true ) ? $action : $defaults[ $key ];
}
$reassign = isset( $input['sync_reassign'] ) ? absint( $input['sync_reassign'] ) : 0;
$out['sync_reassign'] = $reassign && get_userdata( $reassign ) ? $reassign : 0;
$deletes = in_array( 'delete', array( $out['sync_disabled_action'], $out['sync_deleted_action'], $out['sync_scope_action'] ), true );
if ( $deletes && ! $out['sync_reassign'] ) {
add_settings_error( M365_LOGIN_OPTION, 'sync_reassign', __( 'User sync: "Delete" needs a user who receives the posts of deleted accounts. Until one is selected, accounts are deactivated instead.', 'm365-login' ), 'warning' );
}
return $out;
}
/**
* Unslashed string value of a posted field ('' for missing or non-scalar values such as arrays).
*
* @param array $input Raw input.
* @param string $key Field.
* @return string
*/
private static function scalar( $input, $key ) {
return isset( $input[ $key ] ) && is_scalar( $input[ $key ] ) ? (string) wp_unslash( $input[ $key ] ) : '';
}
/**
* Sanitises a GUID => name list posted by a group picker.
*
* @param mixed $raw Raw input.
* @return array
*/
private static function sanitize_group_list( $raw ) {
$groups = array();
if ( empty( $raw ) || ! is_array( $raw ) ) {
return $groups;
}
foreach ( $raw as $id => $name ) {
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
if ( ! self::is_guid( $id ) ) {
continue;
}
$name = is_scalar( $name ) ? sanitize_text_field( wp_unslash( (string) $name ) ) : '';
$groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 );
if ( count( $groups ) >= 100 ) {
break;
}
}
return $groups;
}
/**
* Checks a GUID.
*
* @param string $value Value.
* @return bool
*/
public static function is_guid( $value ) {
return (bool) preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value );
}
/**
* Checks a tenant identifier.
*
* @param string $value Value.
* @return bool
*/
public static function is_valid_tenant( $value ) {
return self::is_guid( $value ) || in_array( $value, array( 'organizations', 'common', 'consumers' ), true );
}
/**
* Only allows http(s) image URLs with a known image extension.
*
* @param string $url URL.
* @return bool
*/
public static function is_safe_image_url( $url ) {
if ( '' === $url ) {
return false;
}
$parts = wp_parse_url( $url );
if ( empty( $parts['scheme'] ) || ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
return false;
}
$path = isset( $parts['path'] ) ? strtolower( $parts['path'] ) : '';
return (bool) preg_match( '/\.(png|jpe?g|gif|svg|webp)$/', $path );
}
}