get(self::POLICY); if (!$row) { return ['enabled'=>false,'epoch'=>'','_revision'=>0]; } if (($row['_kind'] ?? '') !== 'browser_policy' || !is_bool($row['enabled'] ?? null) || !is_string($row['epoch'] ?? null) || !preg_match('/^[a-f0-9]{32}$/D', $row['epoch'])) { throw new \RuntimeException('The browser-protection policy could not be read. Restore its record or use host recovery.'); } return $row; } private static function record(int $uid): array { $row = (new Store())->get('trusted_browsers_' . $uid); if (!$row) { return ['devices'=>[],'_revision'=>0]; } if (($row['_kind'] ?? '') !== 'trusted_browsers' || !is_array($row['devices'] ?? null) || !array_is_list($row['devices']) || count($row['devices']) > self::LIMIT) { throw new \RuntimeException('Saved browser approvals could not be read safely.'); } $ids=[]; foreach ($row['devices'] as $device) { if (!is_array($device) || count($device)!==7 || !is_string($device['id']??null) || !preg_match('/^[a-f0-9]{32}$/D',$device['id']) || isset($ids[$device['id']]) || !is_string($device['verifier']??null) || !preg_match('/^[a-f0-9]{64}$/D',$device['verifier']) || !is_string($device['epoch']??null) || !preg_match('/^[a-f0-9]{32}$/D',$device['epoch']) || !is_string($device['password']??null) || !preg_match('/^[a-f0-9]{64}$/D',$device['password']) || !is_int($device['generation']??null) || $device['generation']<0 || !is_int($device['created']??null) || !is_int($device['expires']??null) || $device['created']<1 || $device['expires']<=$device['created'] || $device['expires']-$device['created']>self::DAYS*86400) { throw new \RuntimeException('An invalid browser approval was refused.'); } $ids[$device['id']]=true; } return $row; } private static function token(): string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Inspect credential type and byte length before explicit sanitation and exact hexadecimal validation immediately below. if (!isset($_COOKIE[self::COOKIE]) || !is_string($_COOKIE[self::COOKIE]) || strlen($_COOKIE[self::COOKIE]) !== 64) { return ''; } $token = sanitize_text_field(wp_unslash($_COOKIE[self::COOKIE])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only reject a transformed cookie by comparing the already sanitized token with its original bytes; only the validated token is hashed or used. if (!hash_equals($_COOKIE[self::COOKIE], $token)) { return ''; } return preg_match('/^[a-f0-9]{64}$/D', $token) ? $token : ''; } private static function verifier(string $token): string { return hash('sha256', 'antivirus.ai.trusted-browser-v1|' . $token); } private static function password(int $uid): string { clean_user_cache($uid); $user = get_userdata($uid); if (!$user instanceof \WP_User) { throw new \RuntimeException('The account no longer exists.'); } return hash('sha256', $user->user_pass); } private static function active(array $device, array $policy, array $account, string $password): bool { return $device['epoch'] === $policy['epoch'] && $device['generation'] === (int)$account['generation'] && hash_equals($device['password'],$password) && $device['expires'] > time() && $device['created'] <= time(); } public static function status(int $uid): array { $policy = self::settings(); $targeted = $uid > 0 && AccountPolicy::targeted($uid); $state = ['enabled'=>$policy['enabled'],'targeted'=>$targeted,'trusted'=>false,'restricted'=>false,'expires'=>0,'host_recovery'=>self::recovery()]; if (!$targeted || !$policy['enabled'] || self::recovery()) { return $state; } $state['restricted']=true; if (!is_ssl() || $uid!==get_current_user_id()) { return $state; } $token=self::token(); if ($token==='') { return $state; } $account=AccountSecurity::account($uid); if (empty($account['totp']) && empty($account['keys'])) { return $state; } $password=self::password($uid);$verifier=self::verifier($token); foreach (self::record($uid)['devices'] as $device) { if (self::active($device,$policy,$account,$password) && hash_equals($device['verifier'],$verifier)) { $state['trusted']=true;$state['restricted']=false;$state['expires']=$device['expires'];break; } } return $state; } public static function restricted(int $uid): bool { if ($uid<1 || self::recovery() || !AccountPolicy::targeted($uid)) { return false; } try { return self::status($uid)['restricted']; } catch (\Throwable $error) { return true; } } public static function capabilities(array $allcaps, array $caps, array $args, \WP_User $user): array { if (!self::restricted((int)$user->ID)) { return $allcaps; } foreach ($allcaps as $name=>$allowed) { $allcaps[$name] = in_array($name,['read','exist'],true) && $allowed; } $allcaps['read']=true;$allcaps['exist']=true;$allcaps['do_not_allow']=false; return $allcaps; } public static function rest($result) { if (is_wp_error($result)) { return $result; } return self::restricted(get_current_user_id()) ? new \WP_Error('antivirus_ai_browser_approval_required','Verify your second factor and approve this browser on Account protection before using the API.',['status'=>403]) : $result; } /** Recovery routing grants no action: mutations still require endpoint authorization and reauthentication. */ public static function recoveryRequest(): bool { if (!is_user_logged_in() || !current_user_can('read')) { return false; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Bound the raw server path before unslashing; only its sanitized exact basename is used for routing below. if (!isset($_SERVER['SCRIPT_NAME']) || !is_string($_SERVER['SCRIPT_NAME']) || strlen($_SERVER['SCRIPT_NAME']) > 8192) { return false; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Directory bytes are never stored, displayed or trusted. Unslash once, bound the path, then sanitize only the endpoint filename so valid quoted or encoded directories remain usable. $scriptPath = wp_unslash($_SERVER['SCRIPT_NAME']); if (strlen($scriptPath) > 4096 || str_contains($scriptPath, "\0")) { return false; } $basename = basename($scriptPath); $script = sanitize_file_name($basename); if ($script !== $basename) { return false; } $method = isset($_SERVER['REQUEST_METHOD']) && is_string($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : ''; if (!isset($_SERVER['REQUEST_METHOD']) || $method !== $_SERVER['REQUEST_METHOD']) { return false; } if ($method === 'GET' && in_array($script, ['profile.php', 'users.php'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only recovery-page routing does not change state or restore administrative capabilities. $page = isset($_GET['page']) && is_string($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only reject normalization by comparing the already sanitized allowlisted selector with its original unslashed bytes. return $page === 'antivirus-ai-account' && $page === wp_unslash($_GET['page']); } if ($method !== 'POST') { return false; } // Only the route and its nonce are read before origin verification. No operation runs here. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Select the fixed action whose nonce is immediately verified below; this routing function never mutates state. $action = isset($_POST['action']) && is_string($_POST['action']) ? sanitize_key(wp_unslash($_POST['action'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only reject normalization by comparing the already sanitized allowlisted selector with its original unslashed bytes. if ($action === '' || $action !== wp_unslash($_POST['action'])) { return false; } if ($script === 'admin-post.php' && $action === 'antivirus_ai_device_trust') { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only inspect nonce type and byte length before sanitation and the explicit verification immediately below; no action input or state is processed. if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce']) || strlen($_POST['_wpnonce']) > 40) { return false; } $nonce = sanitize_text_field(wp_unslash($_POST['_wpnonce'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the already sanitized nonce with its original unslashed bytes only to reject transformation; only the sanitized value reaches wp_verify_nonce. if ($nonce === '' || strlen($nonce) > 20 || $nonce !== wp_unslash($_POST['_wpnonce'])) { return false; } if (!wp_verify_nonce($nonce, 'antivirus_ai_device_trust')) { return false; } $operation = self::value($_POST, 'operation', 30); return in_array($operation, ['policy', 'trust', 'revoke', 'revoke_all'], true); } if ($script === 'admin-post.php' && $action === 'antivirus_ai_account') { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only inspect nonce type and byte length before sanitation and the explicit verification immediately below; no action input or state is processed. if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce']) || strlen($_POST['_wpnonce']) > 40) { return false; } $nonce = sanitize_text_field(wp_unslash($_POST['_wpnonce'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the already sanitized nonce with its original unslashed bytes only to reject transformation; only the sanitized value reaches wp_verify_nonce. if ($nonce === '' || strlen($nonce) > 20 || $nonce !== wp_unslash($_POST['_wpnonce'])) { return false; } if (!wp_verify_nonce($nonce, 'antivirus_ai_account')) { return false; } $operation = self::value($_POST, 'operation', 30); return in_array($operation, ['totp_begin', 'totp_finish', 'totp_remove', 'key_remove', 'recovery', 'disable', 'sessions_revoke'], true); } if ($script !== 'admin-ajax.php' || $action !== 'antivirus_ai_passkey') { return false; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only inspect nonce type and byte length before sanitation and the explicit verification immediately below; no action input or state is processed. if (!isset($_POST['nonce']) || !is_string($_POST['nonce']) || strlen($_POST['nonce']) > 40) { return false; } $nonce = sanitize_text_field(wp_unslash($_POST['nonce'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the already sanitized nonce with its original unslashed bytes only to reject transformation; only the sanitized value reaches wp_verify_nonce. if ($nonce === '' || strlen($nonce) > 20 || $nonce !== wp_unslash($_POST['nonce'])) { return false; } if (!wp_verify_nonce($nonce, 'antivirus_ai_account')) { return false; } $operation = self::value($_POST, 'operation', 30); return in_array($operation, ['reauth_begin', 'reauth_finish', 'register_begin', 'register_finish'], true); } public static function adminGate(): void { if (!self::restricted(get_current_user_id()) || self::recoveryRequest()) { return; } $method = isset($_SERVER['REQUEST_METHOD']) && is_string($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Compare the already sanitized method with original routing bytes only to reject transformation; no original value is stored, displayed or used as an action. if (wp_doing_ajax() || $method !== 'GET' || $method !== ($_SERVER['REQUEST_METHOD'] ?? null)) { wp_die(esc_html__('Approve this browser on Account protection before continuing.','shieldlume-security'),'', ['response'=>403]); } wp_safe_redirect(AccountSecurity::pageUrl());exit; } public static function loginRedirect(string $redirect, string $requested, $user): string { return $user instanceof \WP_User && self::restricted((int)$user->ID) ? AccountSecurity::pageUrl() : $redirect; } public static function notice(): void { if (!self::restricted(get_current_user_id())) { return; } echo '
Administrator actions are restricted in this browser. Verify your second factor and explicitly approve this browser on Account protection. Existing WordPress sessions are retained.
Expires '.esc_html(wp_date('M j, Y H:i T',$device['expires'])).'.
';self::form('revoke',$policy,$record);echo '';self::proofFields();echo 'Keep up to ten browser approvals per account. If recovery is needed, a trusted host administrator can temporarily set ANTIVIRUS_AI_DISABLE_DEVICE_TRUST to true in wp-config.php, reset this policy, and remove the switch. The existing account-security recovery switch also bypasses this restriction.