403]); } } public static function url(array $args = []): string { return add_query_arg(['page' => 'fluxcdn-security'] + $args, is_multisite() ? network_admin_url('admin.php') : admin_url('admin.php')); } public static function menu(): void { $icon = file_get_contents(FLX_SECURITY_DIR.'/assets/shield-check.svg'); add_menu_page(I18n::t('title'), I18n::t('title'), is_multisite() ? 'manage_network_options' : 'manage_options', 'fluxcdn-security', [self::class, 'render'], 'data:image/svg+xml;base64,'.base64_encode($icon ?: ''), 80); add_filter((is_multisite() ? 'network_admin_plugin_action_links_' : 'plugin_action_links_').plugin_basename(FLX_SECURITY_FILE), fn ($links) => array_merge([''.esc_html(I18n::t('settings')).''], $links)); } public static function assets(string $hook): void { if ($hook !== 'toplevel_page_fluxcdn-security') { return; } wp_enqueue_style('flx-security-admin', plugins_url('assets/admin.css', FLX_SECURITY_FILE), [], FLX_SECURITY_VERSION); wp_enqueue_script('flx-security-admin', plugins_url('assets/admin.js', FLX_SECURITY_FILE), [], FLX_SECURITY_VERSION, true); wp_add_inline_script('flx-security-admin', 'window.flxSecurity='.wp_json_encode(['url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('flx_security_status'), 'messages' => ['interrupted' => I18n::t('poll_interrupted'), 'reload' => I18n::t('reload_status'), 'status' => I18n::t('status_unavailable')]]).';', 'before'); } private static function feedback(string $key, bool $error = false): void { Store::set('feedback', ['user' => get_current_user_id(), 'session' => hash('sha256', wp_get_session_token()), 'key' => $key, 'error' => $error]); } public static function action(): void { self::guard(); check_admin_referer('flx_security_action'); $operation = isset($_POST['operation']) && is_string($_POST['operation']) ? sanitize_key(wp_unslash($_POST['operation'])) : ''; $redirect = self::url(['tab' => 'settings']); $startSync = false; try { if ($operation === 'connect') { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Opaque credential is strictly allowlist-validated by Client::request and never rendered or logged. $token = isset($_POST['token']) && is_string($_POST['token']) ? trim(wp_unslash($_POST['token'])) : ''; ConnectionChanges::request('connect', $token); $redirect = self::url(); unset($token); } elseif ($operation === 'automatic') { $redirect = self::beginAuthorization(); } elseif ($operation === 'disconnect') { $confirmation = isset($_POST['confirmation']) && is_string($_POST['confirmation']) ? sanitize_text_field(wp_unslash($_POST['confirmation'])) : ''; if ($confirmation === '') { $redirect = self::url(['tab' => 'settings', 'confirm_disconnect' => '1']); } elseif (! wp_verify_nonce($confirmation, 'flx_security_disconnect_'.ConnectionChanges::fingerprint())) { throw new \RuntimeException('connection_changed'); } else { ConnectionChanges::request('disconnect'); $redirect = self::url(); } } elseif ($operation === 'settings') { $hours = isset($_POST['hours']) && is_string($_POST['hours']) ? filter_var(wp_unslash($_POST['hours']), FILTER_VALIDATE_INT) : false; if ($hours === false || $hours < 6 || $hours > 8760) { throw new \RuntimeException('interval_invalid'); } $previous = Plugin::settings(); Store::set('settings', ['automatic' => isset($_POST['automatic']), 'hours' => $hours, 'delete_on_uninstall' => isset($_POST['delete_on_uninstall'])]); if ($previous['hours'] !== $hours || $previous['automatic'] !== isset($_POST['automatic'])) wp_clear_scheduled_hook('flx_security_refresh'); self::feedback('settings_saved'); } elseif ($operation === 'test') { self::testConnection(); } elseif ($operation === 'sync') { $startSync = true; $redirect = self::url(); } else { throw new \RuntimeException('unexpected_error'); } } catch (\Throwable $error) { self::feedback($error->getMessage() === 'interval_invalid' ? 'interval_invalid' : 'error_'.I18n::errorCode($error->getMessage()), true); } if ($startSync && Store::get('connection')) { try { self::feedback(Sync::start(true) ? ($operation === 'connect' ? 'connection_saved' : 'sync_started') : 'busy'); } catch (\Throwable $error) { self::feedback('error_'.I18n::errorCode($error->getMessage()), true); } } Plugin::ensure(); if (str_starts_with($redirect, Client::BASE.'/')) { $allowService = static function (array $hosts): array { $hosts[] = wp_parse_url(Client::BASE, PHP_URL_HOST); return $hosts; }; add_filter('allowed_redirect_hosts', $allowService); wp_safe_redirect($redirect); remove_filter('allowed_redirect_hosts', $allowService); } else { wp_safe_redirect($redirect); } exit; } private static function testConnection(): void { $start = microtime(true); $connection = Store::get('connection'); $fingerprint = ConnectionChanges::fingerprint($connection); Client::request('GET', '/health', public: true); try { $response = $connection ? Client::request('GET', '/sites/'.$connection['site_id'].'/test', token: Store::decrypt($connection['secret']))['data'] : []; } catch (\Throwable $error) { $owner = Store::lock(); if ($owner !== false) { try { if (hash_equals($fingerprint, ConnectionChanges::fingerprint()) && in_array($error->getMessage(), ['invalid_token', 'insufficient_scope', 'site_disconnected', 'connection_required'], true)) Store::set('error', $error->getMessage()); } finally { Store::unlock($owner); } } throw $error; } $owner = Store::lock(); if ($owner !== false) { try { if (hash_equals($fingerprint, ConnectionChanges::fingerprint())) { Client::rememberReceipt($response); if (in_array(Store::get('error', ''), ['token_expired', 'invalid_token', 'insufficient_scope', 'site_disconnected', 'connection_required'], true)) Store::remove('error'); Store::set('test', ['at' => time(), 'milliseconds' => (int) ((microtime(true) - $start) * 1000)]); } } finally { Store::unlock($owner); } } self::feedback($connection ? 'connection_ok' : 'network_ok'); } private static function beginAuthorization(): string { $verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); $state = bin2hex(random_bytes(32)); $callback = admin_url('admin-post.php?action=flx_security_callback'); if (wp_parse_url($callback, PHP_URL_SCHEME) !== 'https' || wp_parse_url(is_multisite() ? network_site_url() : site_url(), PHP_URL_SCHEME) !== 'https') { throw new \RuntimeException('https_required'); } $result = Client::request('POST', '/authorize', ['callback' => $callback, 'challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='), 'state' => $state], public: true)['data']; $url = $result['authorize_url'] ?? ''; if (! is_string($url) || ! str_starts_with($url, Client::BASE.'/') || ! preg_match('~^/(?:en/)?wordpress/connect/[a-f0-9-]{36}$~D', wp_parse_url($url, PHP_URL_PATH) ?? '') || wp_parse_url($url, PHP_URL_QUERY) !== null || wp_parse_url($url, PHP_URL_FRAGMENT) !== null) { throw new \RuntimeException('invalid_response'); } Store::set('authorization', ['user' => get_current_user_id(), 'session' => hash('sha256', wp_get_session_token()), 'expires' => time() + 900, 'state' => $state, 'verifier' => Store::encrypt($verifier)]); // This destination is fixed to the service origin and its authorization route. return $url; } public static function callback(): void { self::guard(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- This PKCE callback verifies one-time state, user and session instead of a form nonce. nocache_headers(); header('Referrer-Policy: no-referrer'); $pending = Store::get('authorization'); $state = self::hexInput($_GET, 'state'); $code = self::hexInput($_GET, 'code'); // phpcs:enable WordPress.Security.NonceVerification.Recommended if (! is_array($pending) || ($pending['user'] ?? 0) !== get_current_user_id() || ($pending['expires'] ?? 0) <= time() || ! hash_equals($pending['session'] ?? '', hash('sha256', wp_get_session_token())) || ! preg_match('/^[a-f0-9]{64}$/D', $state) || ! hash_equals($pending['state'] ?? '', $state) || ! preg_match('/^[a-f0-9]{64}$/D', $code)) { self::feedback('error_invalid_authorization', true); wp_safe_redirect(self::url(['tab' => 'settings'])); exit; } try { if (ConnectionChanges::pending()) throw new \RuntimeException('connection_busy'); Store::remove('authorization'); $result = Client::request('POST', '/exchange', ['code' => $code, 'verifier' => Store::decrypt($pending['verifier'])], public: true)['data']; if (! is_string($result['token'] ?? null)) throw new \RuntimeException('invalid_response'); ConnectionChanges::request('connect', $result['token']); } catch (\Throwable $error) { self::feedback('error_'.I18n::errorCode($error->getMessage()), true); } finally { unset($result, $pending, $code, $state); } wp_safe_redirect(self::url()); exit; } public static function status(): void { self::guard(); check_ajax_referer('flx_security_status', 'nonce'); ConnectionChanges::process(); $state = Store::get('sync'); // An explicitly requested manual scan can progress even when WP-Cron is disabled. if ($state && ! empty($state['interactive']) && ! ConnectionChanges::pending()) { Sync::tick(6, 4); $state = Store::get('sync'); } $change = ConnectionChanges::pending(); wp_send_json_success(['running' => (bool) ($state || $change), 'progress' => $change ? I18n::t($change['kind'] === 'connect' ? 'connecting' : 'disconnecting') : ($state ? I18n::t('progress', number_format_i18n($state['processed'] ?? 0), number_format_i18n(Sync::total($state))) : ''), 'error' => Store::get('error', '') ? I18n::t('error_'.I18n::errorCode(Store::get('error', ''))) : '']); } public static function notice(): void { Notices::render(); } public static function form(string $operation, string $label, string $class = '', bool $confirmed = false): void { echo '
'; } public static function render(): void { self::guard(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Capability-checked, read-only GET filters and pagination must remain bookmarkable. global $wpdb; $flx_security_tab = isset($_GET['tab']) && is_string($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'overview'; if (! in_array($flx_security_tab, ['overview', 'settings', 'logs'], true)) { $flx_security_tab = 'overview'; } $flx_security_snapshot = Store::get('snapshot'); $flx_security_connection = Store::get('connection'); $flx_security_connected = Client::connected(); $flx_security_change = ConnectionChanges::pending(); $flx_security_confirmDisconnect = isset($_GET['confirm_disconnect']) && $_GET['confirm_disconnect'] === '1'; $flx_security_sync = Store::get('sync'); $flx_security_settings = Plugin::settings(); $flx_security_generation = $flx_security_snapshot['generation'] ?? ''; $flx_security_error = Store::get('error', ''); $flx_security_page = isset($_GET['paged']) && is_scalar($_GET['paged']) ? max(1, min(1000, absint($_GET['paged']))) : 1; $flx_security_offset = ($flx_security_page - 1) * 25; $flx_security_rows = []; $flx_security_total = 0; $flx_security_selected = null; $flx_security_reports = []; $flx_security_counts = null; $flx_security_hideUnidentified = ! isset($_GET['hide_unidentified']) || ! is_string($_GET['hide_unidentified']) || $_GET['hide_unidentified'] !== '0'; $flx_security_viewArgs = $flx_security_hideUnidentified ? [] : ['hide_unidentified' => '0']; if ($flx_security_tab === 'overview' && $flx_security_generation) { $flx_security_plugins = Store::table('plugins'); $flx_security_counts = self::cachedRead($wpdb->prepare('SELECT COUNT(*) AS total, COALESCE(SUM(status<>%s),0) AS identified FROM %i WHERE generation=%s AND item_key NOT IN (%s,%s)', 'unknown_product', $flx_security_plugins, $flx_security_generation, Inventory::selfKey(), Inventory::legacyKey()), 'row'); $flx_security_key = self::hexInput($_GET, 'plugin'); if (preg_match('/^[a-f0-9]{64}$/D', $flx_security_key) && ! in_array($flx_security_key, [Inventory::selfKey(), Inventory::legacyKey()], true)) { $flx_security_selected = self::cachedRead($wpdb->prepare('SELECT * FROM %i WHERE generation=%s AND item_key=%s', Store::table('plugins'), $flx_security_generation, $flx_security_key), 'row'); } if ($flx_security_selected) { $flx_security_total = (int) self::cachedRead($wpdb->prepare('SELECT COUNT(*) FROM %i WHERE generation=%s AND item_key=%s', Store::table('findings'), $flx_security_generation, $flx_security_key), 'var'); $flx_security_reports = self::cachedRead($wpdb->prepare('SELECT a.document FROM %i f JOIN %i a ON a.generation=f.generation AND a.vulnerability_id=f.vulnerability_id WHERE f.generation=%s AND f.item_key=%s ORDER BY a.vulnerability_id LIMIT 10 OFFSET %d', Store::table('findings'), Store::table('advisories'), $flx_security_generation, $flx_security_key, ($flx_security_page - 1) * 10), 'results'); } else { $flx_security_total = (int) ($flx_security_hideUnidentified ? ($flx_security_counts->identified ?? 0) : ($flx_security_counts->total ?? 0)); $flx_security_lastPage = max(1, (int) ceil($flx_security_total / 25)); $flx_security_page = min($flx_security_page, $flx_security_lastPage); $flx_security_offset = ($flx_security_page - 1) * 25; $flx_security_rows = self::cachedRead($wpdb->prepare("SELECT p.*, (SELECT COUNT(*) FROM %i f WHERE f.generation=p.generation AND f.item_key=p.item_key) AS finding_count FROM %i p WHERE generation=%s AND item_key NOT IN (%s,%s) AND (%d=0 OR status<>%s) ORDER BY CASE status WHEN 'affected' THEN 0 WHEN 'not_affected_in_known_ranges' THEN 2 ELSE 1 END, name, item_key LIMIT 25 OFFSET %d", Store::table('findings'), $flx_security_plugins, $flx_security_generation, Inventory::selfKey(), Inventory::legacyKey(), (int) $flx_security_hideUnidentified, 'unknown_product', $flx_security_offset), 'results'); } } elseif ($flx_security_tab === 'logs') { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Plugin-owned log table; live results, bounded to 1000 retained entries. $flx_security_total = (int) $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i', Store::table('logs'))); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Plugin-owned log table; live results, limited to 25 rows. $flx_security_rows = $wpdb->get_results($wpdb->prepare('SELECT * FROM %i ORDER BY id DESC LIMIT 25 OFFSET %d', Store::table('logs'), $flx_security_offset)); } $flx_security_feedback = Store::get('feedback'); if ($flx_security_feedback && (($flx_security_feedback['user'] ?? 0) !== get_current_user_id() || ! hash_equals($flx_security_feedback['session'] ?? '', hash('sha256', wp_get_session_token())))) { $flx_security_feedback = []; } elseif ($flx_security_feedback) { Store::remove('feedback'); } require FLX_SECURITY_DIR.'/views/admin.php'; // phpcs:enable WordPress.Security.NonceVerification.Recommended } /** Strict validation preserves opaque one-time values instead of rewriting them. */ private static function hexInput(array $input, string $key): string { $value = isset($input[$key]) && is_string($input[$key]) ? wp_unslash($input[$key]) : ''; return preg_match('/^[a-f0-9]{64}$/D', $value) ? $value : ''; } /** Only prepared queries against an immutable completed generation are accepted by callers. */ private static function cachedRead(string $sql, string $type): mixed { global $wpdb; $key = hash('sha256', Store::cacheVersion().':'.$type.':'.$sql); $cached = wp_cache_get($key, 'flx_security_results', false, $found); if ($found) { return $cached; } // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Private helper receives only wpdb::prepare output for plugin-owned tables. $value = match ($type) { 'var' => $wpdb->get_var($sql), 'row' => $wpdb->get_row($sql), default => $wpdb->get_results($sql), }; // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,PluginCheck.Security.DirectDB.UnescapedDBParameter if ($wpdb->last_error === '') { wp_cache_set($key, $value, 'flx_security_results', 300); } return $value; } public static function link(string $url, string $label, string $class = ''): void { if (in_array(wp_parse_url($url, PHP_URL_SCHEME), ['https', 'http'], true)) { echo ''.esc_html($label).''; } else { echo esc_html($label); } } }