From 836ae882577067f30dad948f170bd78544dcb0de Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 23 Jul 2026 07:39:56 -0500 Subject: [PATCH 1/3] feat(cloud): one-click connect flow + entitlements sync engine Add WebDecoy_Cloud_Connect owning the user-initiated connect handshake and the entitlements sync: - handle_connect: mint a 64-hex nonce (15-min transient), redirect the browser to app.webdecoy.com/connect/wordpress with site details, digest-consent bit, and a return URL. First point the plugin ever leaves the site, and only on an explicit click. - maybe_handle_return: on the settings-page return, verify manage_options + the one-time transient nonce, then server-side POST the token to api.webdecoy.com/.../exchange and persist api_key/site_key/org/plan. - sync_entitlements: GET ingest.webdecoy.com/.../sdk/entitlements with the API key as a Bearer token (same auth scheme as WebDecoy_Violation_Reporter), cache the normalized result with a fetched_at stamp, fail open to free on any error. Runs after connect and twice daily via cron. - handle_disconnect: clear keys/org/entitlements locally, no remote call. Wire into webdecoy.php: store_cloud_credentials/clear_cloud_credentials (API key encrypted at rest like the manual path; sanitizer bypassed for these pre-shaped writes), org/plan defaults, and sanitize_options carry-forward so a settings save never wipes connection metadata. Clean up the entitlements option + cron on deactivate/uninstall. Zero external HTTP calls until the admin clicks Connect. Part of WebDecoy/app#306 --- includes/class-webdecoy-activator.php | 3 + includes/class-webdecoy-cloud-connect.php | 488 ++++++++++++++++++++++ uninstall.php | 4 + webdecoy.php | 118 ++++++ 4 files changed, 613 insertions(+) create mode 100644 includes/class-webdecoy-cloud-connect.php diff --git a/includes/class-webdecoy-activator.php b/includes/class-webdecoy-activator.php index 4909912..71698cf 100644 --- a/includes/class-webdecoy-activator.php +++ b/includes/class-webdecoy-activator.php @@ -47,6 +47,7 @@ public static function deactivate(): void // Clear scheduled events wp_clear_scheduled_hook('webdecoy_cleanup_expired'); wp_clear_scheduled_hook('webdecoy_sync_blocked_ips'); + wp_clear_scheduled_hook('webdecoy_sync_entitlements'); // Flush rewrite rules flush_rewrite_rules(); @@ -258,11 +259,13 @@ public static function uninstall(): void // Delete options delete_option('webdecoy_options'); delete_option('webdecoy_db_version'); + delete_option('webdecoy_entitlements'); // Clear scheduled events wp_clear_scheduled_hook('webdecoy_cleanup_expired'); wp_clear_scheduled_hook('webdecoy_sync_blocked_ips'); wp_clear_scheduled_hook('webdecoy_flush_violations'); + wp_clear_scheduled_hook('webdecoy_sync_entitlements'); } } diff --git a/includes/class-webdecoy-cloud-connect.php b/includes/class-webdecoy-cloud-connect.php new file mode 100644 index 0000000..c7b1951 --- /dev/null +++ b/includes/class-webdecoy-cloud-connect.php @@ -0,0 +1,488 @@ + credentials. */ + private const EXCHANGE_ENDPOINT = 'https://api.webdecoy.com/api/v1/connect/wordpress/exchange'; + + /** + * Entitlements endpoint. Authenticated with the API key exactly like + * {@see WebDecoy_Violation_Reporter} authenticates the violations batch: + * an `Authorization: Bearer ` header against the ingest service. + */ + private const ENTITLEMENTS_ENDPOINT = 'https://ingest.webdecoy.com/api/v1/sdk/entitlements'; + + /** Where the "Upgrade" link points once connected. */ + private const BILLING_URL = 'https://app.webdecoy.com/billing'; + + /** Transient holding the pending connect nonce (site-scoped, one flow at a time). */ + private const NONCE_TRANSIENT = 'webdecoy_connect_nonce'; + + /** Connect nonce lifetime: 15 minutes (literal to avoid a WP-constant load dependency). */ + private const NONCE_TTL = 900; + + /** Option caching the last entitlements response (+ a fetched_at timestamp). */ + private const ENTITLEMENTS_OPTION = 'webdecoy_entitlements'; + + /** Transient carrying a one-shot admin notice across the post-connect redirect. */ + private const NOTICE_TRANSIENT = 'webdecoy_connect_notice'; + + /** Entitlements older than 12 hours are treated as stale (still served). */ + private const ENTITLEMENTS_STALE_AFTER = 43200; + + /** Twice-daily cron hook that refreshes entitlements. */ + public const CRON_HOOK = 'webdecoy_sync_entitlements'; + + /** Feature flags the entitlements contract defines. All default to false (fail open to free). */ + private const FEATURE_KEYS = ['actor_feed', 'enrichment', 'alerts', 'edge_push', 'decoy_packs', 'woo_intel']; + + /** + * Wire up the flow. The cron handler is always registered (wp-cron can fire + * on any front-end request); the interactive handlers only in wp-admin. + */ + public function register(): void + { + add_action(self::CRON_HOOK, [$this, 'sync_entitlements']); + + if (!is_admin()) { + return; + } + + add_action('admin_post_webdecoy_cloud_connect', [$this, 'handle_connect']); + add_action('admin_post_webdecoy_cloud_disconnect', [$this, 'handle_disconnect']); + add_action('admin_init', [$this, 'maybe_handle_return']); + add_action('admin_notices', [$this, 'render_notices']); + } + + /** + * Step 1: mint the nonce and redirect the browser to the app connect page. + * This is the FIRST point at which the plugin ever leaves the site, and only + * because the admin clicked "Connect". + */ + public function handle_connect(): void + { + check_admin_referer('webdecoy_cloud_connect'); + + if (!current_user_can('manage_options')) { + wp_die(esc_html__('You do not have permission to connect this site.', 'webdecoy')); + } + + // 64 hex chars of CSPRNG output; also the app<->exchange binding value. + $nonce = bin2hex(random_bytes(32)); + set_transient(self::NONCE_TRANSIENT, $nonce, self::NONCE_TTL); + + // Consent checkbox: a boolean presence check (the value is never used). + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- boolean presence only; nonce already verified above + $digest = !empty($_POST['digest']) ? '1' : '0'; + + // Build the query with http_build_query so every value is encoded + // exactly once (WP's add_query_arg does not encode appended args). + $query = http_build_query([ + 'site_url' => home_url(), + 'site_name' => get_bloginfo('name'), + 'nonce' => $nonce, + 'digest' => $digest, + 'return_url' => admin_url('admin.php?page=webdecoy&tab=cloud&wd_connect=1'), + ]); + $url = self::CONNECT_URL . '?' . $query; + + // Intentional off-site redirect to the user-chosen Cloud host; not a + // same-origin navigation, so wp_safe_redirect() is not applicable. + // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect + wp_redirect(esc_url_raw($url)); + exit; + } + + /** + * Step 3: on the settings-page return, exchange the one-time token for + * credentials and persist them. Authenticity rests on three checks: the + * caller must hold manage_options, our one-time transient nonce must still + * exist (proving this browser started the flow), and the exchange token is + * server-validated as single-use and bound to site_url + nonce. + */ + public function maybe_handle_return(): void + { + // Routing only; no state changes here. The state-changing exchange below + // is gated by capability + the one-time server-stored transient nonce. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ($page !== 'webdecoy' || !isset($_GET['wd_connect'])) { + return; + } + + if (!current_user_can('manage_options')) { + return; + } + + // Explicit denial from the app: surface it, change nothing. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $error = isset($_GET['wd_connect_error']) ? sanitize_key(wp_unslash($_GET['wd_connect_error'])) : ''; + if ($error !== '') { + $this->set_notice('error', __('Connection was cancelled. No changes were made.', 'webdecoy')); + $this->redirect_clean(); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $raw_token = isset($_GET['wd_connect_token']) ? sanitize_text_field(wp_unslash($_GET['wd_connect_token'])) : ''; + // Further reduce to a strict URL-token charset before use. + $token = self::sanitize_connect_token($raw_token); + if ($token === '') { + // No token and no error — likely a stale/bookmarked return URL. Do nothing. + return; + } + + $nonce = get_transient(self::NONCE_TRANSIENT); + if (!is_string($nonce) || !self::is_hex($nonce, 64)) { + $this->set_notice('error', __('Your connect session expired before it completed. Please click Connect again.', 'webdecoy')); + $this->redirect_clean(); + } + + $result = $this->exchange_token($token, $nonce); + + if ($result === null) { + // exchange_token() has already recorded the specific failure notice. + $this->redirect_clean(); + } + + // Persist credentials + org metadata (API key encrypted at rest by the + // main plugin, mirroring the manual-entry path). + webdecoy()->store_cloud_credentials( + (string) ($result['api_key'] ?? ''), + (string) ($result['site_key'] ?? ''), + (string) ($result['organization_id'] ?? ''), + (string) ($result['organization_name'] ?? ''), + (string) ($result['plan'] ?? 'free_connected') + ); + + // Single-use: burn the nonce so the token can't be replayed. + delete_transient(self::NONCE_TRANSIENT); + + // Pull entitlements immediately, then keep them fresh twice daily. + $this->sync_entitlements(); + $this->schedule_sync(); + + $org = (string) ($result['organization_name'] ?? ''); + $this->set_notice( + 'success', + $org !== '' + /* translators: %s: organization name */ + ? sprintf(__('Connected to WebDecoy Cloud (%s). Cloud features are now active.', 'webdecoy'), $org) + : __('Connected to WebDecoy Cloud. Cloud features are now active.', 'webdecoy') + ); + $this->redirect_clean(); + } + + /** + * Disconnect: clear credentials, org metadata, and cached entitlements + * locally. No remote call is made (P0 contract). + */ + public function handle_disconnect(): void + { + check_admin_referer('webdecoy_cloud_disconnect'); + + if (!current_user_can('manage_options')) { + wp_die(esc_html__('You do not have permission to disconnect this site.', 'webdecoy')); + } + + webdecoy()->clear_cloud_credentials(); + delete_option(self::ENTITLEMENTS_OPTION); + delete_transient(self::NONCE_TRANSIENT); + wp_clear_scheduled_hook(self::CRON_HOOK); + + $this->set_notice('success', __('Disconnected from WebDecoy Cloud. Local protection remains active.', 'webdecoy')); + $this->redirect_clean(); + } + + /** + * POST the one-time token to the exchange endpoint. Returns the decoded + * credentials array on success, or null (with a notice already set) on any + * failure. + * + * @return array|null + */ + private function exchange_token(string $token, string $nonce): ?array + { + $response = wp_remote_post(self::EXCHANGE_ENDPOINT, [ + 'timeout' => 10, + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'body' => wp_json_encode([ + 'connect_token' => $token, + 'site_url' => home_url(), + 'nonce' => $nonce, + ]), + ]); + + if (is_wp_error($response)) { + $this->set_notice('error', __('Could not reach WebDecoy Cloud to complete the connection. Please try again.', 'webdecoy')); + return null; + } + + $code = (int) wp_remote_retrieve_response_code($response); + $body = json_decode((string) wp_remote_retrieve_body($response), true); + $body = is_array($body) ? $body : []; + + if ($code < 200 || $code >= 300) { + $reason = isset($body['error']) && is_string($body['error']) && $body['error'] !== '' + ? $body['error'] + : __('the connection token was invalid or expired', 'webdecoy'); + $this->set_notice( + 'error', + /* translators: %s: reason the connection failed */ + sprintf(__('WebDecoy Cloud declined the connection: %s. Please click Connect again.', 'webdecoy'), $reason) + ); + return null; + } + + if (empty($body['api_key'])) { + $this->set_notice('error', __('WebDecoy Cloud returned an incomplete response. Please try again.', 'webdecoy')); + return null; + } + + return $body; + } + + /** + * Fetch entitlements with the stored API key and cache the normalized + * response. On any error the existing cache is left untouched and the + * accessor continues to fail open to free. Runs after connect and twice + * daily via {@see self::CRON_HOOK}. + */ + public function sync_entitlements(): void + { + if (!function_exists('wp_remote_get')) { + return; + } + + $options = webdecoy()->get_options(); + $api_key = isset($options['api_key']) ? (string) $options['api_key'] : ''; + if ($api_key === '') { + return; + } + + // Same auth scheme as WebDecoy_Violation_Reporter::send(): the API key + // as a Bearer token in the Authorization header. + $response = wp_remote_get(self::ENTITLEMENTS_ENDPOINT, [ + 'timeout' => 5, + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $api_key, + ], + ]); + + if (is_wp_error($response)) { + return; + } + $code = (int) wp_remote_retrieve_response_code($response); + if ($code < 200 || $code >= 300) { + return; + } + + $body = json_decode((string) wp_remote_retrieve_body($response), true); + if (!is_array($body)) { + return; + } + + $body['fetched_at'] = time(); + update_option(self::ENTITLEMENTS_OPTION, self::normalize_entitlements($body, time()), false); + } + + /** + * Ensure the twice-daily entitlements refresh is scheduled. + */ + private function schedule_sync(): void + { + if (!wp_next_scheduled(self::CRON_HOOK)) { + wp_schedule_event(time(), 'twicedaily', self::CRON_HOOK); + } + } + + /** + * Normalized entitlements accessor. Always returns a complete, typed shape; + * a missing/invalid cache fails open to the free tier (all features false). + * + * @return array{plan:string,channel:string,features:array,digest:array{enabled:bool},fetched_at:int,stale:bool} + */ + public static function get_entitlements(): array + { + $cached = get_option(self::ENTITLEMENTS_OPTION, []); + return self::normalize_entitlements(is_array($cached) ? $cached : [], time()); + } + + /** + * Whether this site is connected to WebDecoy Cloud (an API key is stored). + */ + public static function is_connected(): bool + { + $options = get_option('webdecoy_options', []); + return is_array($options) && !empty($options['api_key']); + } + + /** + * The URL the "Upgrade" link points to. + */ + public static function billing_url(): string + { + return self::BILLING_URL; + } + + // --------------------------------------------------------------------- + // Pure helpers (no WordPress calls) — unit-tested in tests/CloudConnectTest.php. + // --------------------------------------------------------------------- + + /** + * Normalize a raw entitlements payload into the canonical shape. Pure: the + * caller supplies "now" so staleness is deterministic and testable. + * + * @param array $raw + * @return array{plan:string,channel:string,features:array,digest:array{enabled:bool},fetched_at:int,stale:bool} + */ + public static function normalize_entitlements(array $raw, int $now): array + { + $features_in = isset($raw['features']) && is_array($raw['features']) ? $raw['features'] : []; + $features = []; + foreach (self::FEATURE_KEYS as $key) { + $features[$key] = !empty($features_in[$key]); + } + + $plan = isset($raw['plan']) && is_string($raw['plan']) && $raw['plan'] !== '' ? $raw['plan'] : 'free'; + $channel = isset($raw['channel']) && is_string($raw['channel']) ? $raw['channel'] : ''; + + $digest_in = isset($raw['digest']) && is_array($raw['digest']) ? $raw['digest'] : []; + $digest_enabled = !empty($digest_in['enabled']); + + $fetched_at = isset($raw['fetched_at']) ? (int) $raw['fetched_at'] : 0; + $age = $fetched_at > 0 ? ($now - $fetched_at) : PHP_INT_MAX; + $stale = $age > self::ENTITLEMENTS_STALE_AFTER; + + return [ + 'plan' => $plan, + 'channel' => $channel, + 'features' => $features, + 'digest' => ['enabled' => $digest_enabled], + 'fetched_at' => $fetched_at, + 'stale' => $stale, + ]; + } + + /** + * Constant-form hex check. `$length` of 0 means "any non-empty length". + */ + public static function is_hex(string $value, int $length = 0): bool + { + if ($value === '') { + return false; + } + if ($length > 0 && strlen($value) !== $length) { + return false; + } + return ctype_xdigit($value) === true; + } + + /** + * Reduce an inbound connect token to safe, URL-token characters and cap its + * length. Returns '' when nothing usable survives. + */ + public static function sanitize_connect_token(string $raw): string + { + $token = preg_replace('/[^A-Za-z0-9._\-]/', '', $raw); + if (!is_string($token)) { + return ''; + } + return substr($token, 0, 256); + } + + /** + * Human-readable label for a plan slug (e.g. free_connected -> "Free Connected"). + */ + public static function plan_label(string $plan): string + { + if ($plan === '' ) { + return 'Connected'; + } + if ($plan === 'free_connected') { + return 'Free Connected'; + } + return ucwords(str_replace(['_', '-'], ' ', $plan)); + } + + // --------------------------------------------------------------------- + // Admin notices (one-shot, carried across the redirect via a transient). + // --------------------------------------------------------------------- + + /** + * Store a one-shot admin notice. + */ + private function set_notice(string $type, string $message): void + { + set_transient(self::NOTICE_TRANSIENT, ['type' => $type, 'message' => $message], MINUTE_IN_SECONDS); + } + + /** + * Render (and consume) any pending connect notice. + */ + public function render_notices(): void + { + $notice = get_transient(self::NOTICE_TRANSIENT); + if (!is_array($notice) || empty($notice['message'])) { + return; + } + delete_transient(self::NOTICE_TRANSIENT); + + $class = ($notice['type'] ?? 'success') === 'error' ? 'notice-error' : 'notice-success'; + printf( + '

%s %s

', + esc_attr($class), + esc_html__('WebDecoy Cloud:', 'webdecoy'), + esc_html((string) $notice['message']) + ); + } + + /** + * Redirect back to the clean Cloud tab (stripping the one-time token from + * the address bar) and stop. Never returns. + */ + private function redirect_clean(): void + { + wp_safe_redirect(admin_url('admin.php?page=webdecoy&tab=cloud')); + exit; + } +} diff --git a/uninstall.php b/uninstall.php index c00e373..90e9e0a 100644 --- a/uninstall.php +++ b/uninstall.php @@ -46,10 +46,12 @@ delete_option('webdecoy_api_last_check'); delete_option('webdecoy_api_last_error'); delete_option('webdecoy_encryption_key'); + delete_option('webdecoy_entitlements'); // Clear scheduled events wp_clear_scheduled_hook('webdecoy_cleanup_expired'); wp_clear_scheduled_hook('webdecoy_sync_blocked_ips'); + wp_clear_scheduled_hook('webdecoy_sync_entitlements'); } // Delete all transients with webdecoy_ prefix @@ -91,10 +93,12 @@ delete_option('webdecoy_api_last_check'); delete_option('webdecoy_api_last_error'); delete_option('webdecoy_encryption_key'); + delete_option('webdecoy_entitlements'); // Clear scheduled events for this site wp_clear_scheduled_hook('webdecoy_cleanup_expired'); wp_clear_scheduled_hook('webdecoy_sync_blocked_ips'); + wp_clear_scheduled_hook('webdecoy_sync_entitlements'); // Delete transients for this site $wpdb->query( diff --git a/webdecoy.php b/webdecoy.php index eb400ff..88a1733 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -138,6 +138,13 @@ final class WebDecoy_Plugin */ private ?\WebDecoy\BotDetector $detector = null; + /** + * Cloud connect controller (one-click connect + entitlements sync). + * + * @var WebDecoy_Cloud_Connect|null + */ + private ?WebDecoy_Cloud_Connect $cloud_connect = null; + /** * Get plugin instance * @@ -177,6 +184,13 @@ private function load_options(): void // Optional scope passed to the clearance client (advanced). 'clearance_scope' => '', + // Cloud connection metadata, populated by the one-click connect flow + // (WebDecoy_Cloud_Connect). Managed outside the settings form; the + // sanitizer carries these forward so a normal save never wipes them. + 'organization_id' => '', + 'organization_name' => '', + 'plan' => '', + // Proxy / client IP resolution. By default the plugin uses the direct // connection IP (REMOTE_ADDR) and IGNORES forwarding headers, which are // spoofable. Sites behind a reverse proxy/CDN must opt in so that @@ -778,6 +792,12 @@ public function load_includes(): void require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-decoy-response.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-rate-limit-rule.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-wp-traps.php'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-cloud-connect.php'; + + // One-click Cloud connect + entitlements sync. Makes no external request + // until the admin explicitly clicks "Connect". + $this->cloud_connect = new WebDecoy_Cloud_Connect(); + $this->cloud_connect->register(); if (class_exists('WooCommerce')) { require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-woocommerce.php'; @@ -1775,6 +1795,15 @@ public function sanitize_options(array $input): array $sanitized['site_key'] = sanitize_text_field($input['site_key'] ?? ''); $sanitized['clearance_scope'] = sanitize_text_field($input['clearance_scope'] ?? ''); + // Cloud connection metadata is owned by the connect flow, not this form. + // Carry the stored values forward so saving settings never wipes them. + $existing = get_option('webdecoy_options', []); + foreach (['organization_id', 'organization_name', 'plan'] as $connect_key) { + if (is_array($existing) && isset($existing[$connect_key])) { + $sanitized[$connect_key] = sanitize_text_field((string) $existing[$connect_key]); + } + } + // Proxy / client IP resolution $sanitized['behind_cloudflare'] = !empty($input['behind_cloudflare']); $sanitized['trusted_proxies'] = $this->sanitize_trusted_proxies($input['trusted_proxies'] ?? ''); @@ -2744,6 +2773,95 @@ public function get_options(): array return $this->options; } + /** + * Persist Cloud credentials returned by the connect exchange. + * + * Called by {@see WebDecoy_Cloud_Connect} after a successful token exchange. + * The API key is encrypted at rest exactly like the manual-entry path; the + * publishable site key and org metadata are stored alongside it. + * + * @param string $api_key Plaintext secret API key. + * @param string $site_key Publishable site key (org id). + * @param string $organization_id Cloud organization id. + * @param string $organization_name Human-readable org name. + * @param string $plan Plan slug (e.g. free_connected). + */ + public function store_cloud_credentials(string $api_key, string $site_key, string $organization_id, string $organization_name, string $plan): void + { + $options = get_option('webdecoy_options', []); + if (!is_array($options)) { + $options = []; + } + + if ($api_key !== '') { + $options['api_key'] = $this->is_encrypted($api_key) ? $api_key : $this->encrypt_value($api_key); + } + $options['site_key'] = sanitize_text_field($site_key); + $options['organization_id'] = sanitize_text_field($organization_id); + $options['organization_name'] = sanitize_text_field($organization_name); + $options['plan'] = sanitize_text_field($plan); + + $this->update_options_raw($options); + + // Reflect the change in the in-request cache with the API key decrypted, + // matching load_options() so downstream getters see the plaintext key. + $this->options = $options; + if ($api_key !== '') { + $this->options['api_key'] = $this->is_encrypted($api_key) ? $this->decrypt_value($api_key) : $api_key; + } + + // Force a fresh API status check on next use now that creds changed. + $this->clear_api_status_cache(); + } + + /** + * Clear all Cloud credentials + org metadata locally (no remote call). + * Used by the Disconnect action. + */ + public function clear_cloud_credentials(): void + { + $options = get_option('webdecoy_options', []); + if (!is_array($options)) { + $options = []; + } + + $options['api_key'] = ''; + $options['site_key'] = ''; + $options['organization_id'] = ''; + $options['organization_name'] = ''; + $options['plan'] = ''; + + $this->update_options_raw($options); + + $this->options['api_key'] = ''; + $this->options['site_key'] = ''; + $this->options['organization_id'] = ''; + $this->options['organization_name'] = ''; + $this->options['plan'] = ''; + + $this->clear_api_status_cache(); + } + + /** + * Write the options option verbatim, bypassing the Settings API sanitizer. + * + * sanitize_options() is written for a full form POST: it rebuilds the array + * from string form fields and would mangle the array-typed values (e.g. + * custom_allowlist) already present in a stored, complete options array. For + * these trusted, pre-shaped writes we detach the sanitize filter for the + * duration of the update, then restore it if it was attached. + * + * @param array $options Complete, pre-sanitized options array. + */ + private function update_options_raw(array $options): void + { + $had_filter = remove_filter('sanitize_option_webdecoy_options', [$this, 'sanitize_options']); + update_option('webdecoy_options', $options); + if ($had_filter) { + add_filter('sanitize_option_webdecoy_options', [$this, 'sanitize_options']); + } + } + } /** From 0a79b503d3b320065448770b41ccc5bbdd410d76 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 23 Jul 2026 07:40:04 -0500 Subject: [PATCH 2/3] feat(cloud): Cloud tab connect UI + connected state Rework the WebDecoy Cloud settings tab: - Disconnected: primary "Connect to WebDecoy Cloud" button with a "Send me a monthly security report" consent checkbox (default off) above it; the manual API key / site key fields (behavior unchanged) move into a collapsed "Advanced: manual configuration"
fold. - Connected: org name, plan badge ("Free Connected" for free_connected, else the humanized plan name), monthly-report status line, Upgrade link to app.webdecoy.com/billing, and a nonce-protected Disconnect button. Connect/disconnect post to admin-post.php via two standalone forms placed outside the settings form (no nested forms); the tab controls target them with the HTML5 form="" attribute. Settings page honors ?tab=cloud so the connect return lands on this tab without the JS hash router. Styling added to the enqueued admin stylesheet (no inline CSS). --- admin/css/webdecoy-admin.css | 129 ++++++++++++++ admin/partials/settings-page.php | 293 ++++++++++++++++++++----------- 2 files changed, 315 insertions(+), 107 deletions(-) diff --git a/admin/css/webdecoy-admin.css b/admin/css/webdecoy-admin.css index d39e903..9010fd0 100644 --- a/admin/css/webdecoy-admin.css +++ b/admin/css/webdecoy-admin.css @@ -871,3 +871,132 @@ .webdecoy-cloud-upsell .button { margin-right: 8px; } + +/* ========================================================================== + Cloud Connect (one-click connect + connected state) + ========================================================================== */ + +.webdecoy-connect-cta { + background: #fff; + border: 1px solid #dcdcde; + border-radius: 8px; + padding: 24px; + margin: 16px 0 20px; +} + +.webdecoy-connect-cta .button-hero { + display: block; + margin-top: 12px; +} + +.webdecoy-digest-consent { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 500; + color: #1d2327; +} + +.webdecoy-digest-consent input { + margin: 0; +} + +/* Connected state card */ +.webdecoy-connected-card { + background: linear-gradient(135deg, #f0fff4 0%, #e8f7ee 100%); + border: 1px solid #b6e3c6; + border-radius: 8px; + padding: 24px; + margin: 16px 0 20px; +} + +.webdecoy-connected-head { + display: flex; + align-items: center; + gap: 12px; +} + +.webdecoy-connected-head > .dashicons { + color: #1a7f37; + font-size: 28px; + width: 28px; + height: 28px; +} + +.webdecoy-connected-title { + flex: 1 1 auto; +} + +.webdecoy-connected-title strong { + font-size: 15px; + color: #14532d; +} + +.webdecoy-connected-org { + color: #3c4858; + font-size: 13px; + margin-top: 2px; +} + +.webdecoy-plan-badge { + display: inline-block; + background: #1a7f37; + color: #fff; + font-size: 12px; + font-weight: 600; + line-height: 1; + padding: 6px 12px; + border-radius: 999px; + white-space: nowrap; +} + +.webdecoy-digest-status { + display: flex; + align-items: center; + gap: 6px; + color: #3c4858; + margin: 16px 0 0; +} + +.webdecoy-digest-status .dashicons { + font-size: 18px; + width: 18px; + height: 18px; +} + +.webdecoy-connected-actions { + margin: 20px 0 0; +} + +.webdecoy-connected-actions .button { + margin-right: 8px; +} + +/* Advanced fold */ +.webdecoy-advanced-config { + margin-top: 8px; + border-top: 1px solid #dcdcde; + padding-top: 12px; +} + +.webdecoy-advanced-config > summary { + cursor: pointer; + font-weight: 600; + color: #2271b1; + padding: 6px 0; + list-style: revert; +} + +.webdecoy-advanced-config[open] > summary { + margin-bottom: 8px; +} + +.webdecoy-text-link { + text-decoration: none; +} + +/* Empty helper forms carry no visible UI. */ +.webdecoy-action-form { + display: inline; + margin: 0; +} diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index d61cdaa..54f2fc2 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -11,6 +11,20 @@ } $options = get_option('webdecoy_options', []); + +// Which tab to show active on load. Defaults to Protection; the Cloud connect +// return redirect lands here with ?tab=cloud so the connected/connect UI is +// visible without needing the JS hash router. Read-only UI selector, no state +// change, so no nonce is required. +// phpcs:ignore WordPress.Security.NonceVerification.Recommended +$wd_active_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'detection'; +if ($wd_active_tab !== 'cloud') { + $wd_active_tab = 'detection'; +} + +// Cloud connection state for the Cloud tab. +$wd_cloud_connected = class_exists('WebDecoy_Cloud_Connect') && WebDecoy_Cloud_Connect::is_connected(); +$wd_entitlements = class_exists('WebDecoy_Cloud_Connect') ? WebDecoy_Cloud_Connect::get_entitlements() : []; ?>
@@ -23,7 +37,7 @@
-
+

@@ -710,119 +724,184 @@ -
+

-

-
- - - - - - - - - - - - - - - - -
- - - - -

-
- - - -

- -

-
+ + +
+
+ +
+ + +
+ +
+ +
+ +

+ - - - - - - - - - - - -

- -

- - - - - - - - - - - - - -

- -

- -
- - -

-
- - -
-

-

-
    -
  • -
  • -
  • -
  • -
  • -
-

- - - - - - -

-
+

+
+ +

+ +
+ + +

+
+ +
+

+
    +
  • +
  • +
  • +
  • +
  • +
+

+ + + +

+
+ +
+ +

+ + + + + + + + + + + + + + + + + +
+ + + + +

+
+ + + +

+ +

+
+ + + + + + + + + + + + +

+ +

+ + + + + + + + + + + + + +

+ +

+ +
+ + +

+
+
+ + +
+ + +
+
+ + +
From 498fa04c1850409903d6cc7bf7e22ed2fd864574 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 23 Jul 2026 07:40:08 -0500 Subject: [PATCH 3/3] test(cloud): cover entitlements normalization + token/nonce helpers Add tests/CloudConnectTest.php to the dependency-free suite (php tests/run.php): entitlements normalization (fail-open to free, feature coercion, 12h staleness boundary, garbage-shape safety), is_hex nonce validation, connect-token sanitization + length cap, and plan labelling. --- tests/CloudConnectTest.php | 113 +++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/CloudConnectTest.php diff --git a/tests/CloudConnectTest.php b/tests/CloudConnectTest.php new file mode 100644 index 0000000..9cf1f3a --- /dev/null +++ b/tests/CloudConnectTest.php @@ -0,0 +1,113 @@ + 0'); + $true($e['stale'] === true, 'never-fetched is stale'); + foreach (['actor_feed', 'enrichment', 'alerts', 'edge_push', 'decoy_packs', 'woo_intel'] as $key) { + $same(false, $e['features'][$key], "feature {$key} defaults false"); + } +}); + +$t('a full payload is normalized to typed values', function () use ($same, $true) { + $now = 2000000; + $raw = [ + 'plan' => 'free_connected', + 'channel' => 'wordpress', + 'features' => [ + 'actor_feed' => true, + 'enrichment' => 1, // truthy non-bool -> true + 'alerts' => false, + 'edge_push' => 0, // falsy -> false + // decoy_packs + woo_intel omitted -> default false + ], + 'digest' => ['enabled' => true], + 'fetched_at' => $now - 100, // fresh + ]; + $e = WebDecoy_Cloud_Connect::normalize_entitlements($raw, $now); + $same('free_connected', $e['plan']); + $same('wordpress', $e['channel']); + $same(true, $e['features']['actor_feed']); + $same(true, $e['features']['enrichment'], 'truthy coerced to bool true'); + $same(false, $e['features']['alerts']); + $same(false, $e['features']['edge_push'], 'falsy coerced to bool false'); + $same(false, $e['features']['decoy_packs'], 'omitted feature is false'); + $same(false, $e['features']['woo_intel'], 'omitted feature is false'); + $same(true, $e['digest']['enabled']); + $same($now - 100, $e['fetched_at']); + $true($e['stale'] === false, 'recent fetch is not stale'); +}); + +$t('staleness flips at the 12h boundary', function () use ($true) { + $now = 1000000; + // 11h old -> fresh; 13h old -> stale (threshold is 12h = 43200s). + $fresh = WebDecoy_Cloud_Connect::normalize_entitlements(['fetched_at' => $now - (11 * 3600)], $now); + $stale = WebDecoy_Cloud_Connect::normalize_entitlements(['fetched_at' => $now - (13 * 3600)], $now); + $true($fresh['stale'] === false, '11h old is fresh'); + $true($stale['stale'] === true, '13h old is stale'); +}); + +$t('garbage feature/digest shapes never fatal, always typed', function () use ($same) { + $e = WebDecoy_Cloud_Connect::normalize_entitlements([ + 'plan' => 123, // non-string -> default free + 'features' => 'nope', // non-array -> all false + 'digest' => 'nope', // non-array -> off + ], 500); + $same('free', $e['plan'], 'non-string plan falls back to free'); + $same(false, $e['features']['actor_feed']); + $same(false, $e['digest']['enabled']); +}); + +echo "\nCloud Connect: token & nonce validation\n"; + +$t('is_hex validates a 64-char connect nonce', function () use ($true) { + $good = str_repeat('a1b2', 16); // 64 hex chars + $true(WebDecoy_Cloud_Connect::is_hex($good, 64) === true, '64 hex chars pass'); + $true(WebDecoy_Cloud_Connect::is_hex($good, 32) === false, 'wrong length fails'); + $true(WebDecoy_Cloud_Connect::is_hex('zzzz', 4) === false, 'non-hex fails'); + $true(WebDecoy_Cloud_Connect::is_hex('', 64) === false, 'empty fails'); + $true(WebDecoy_Cloud_Connect::is_hex('deadbeef') === true, 'any-length hex passes with no length arg'); +}); + +$t('sanitize_connect_token strips unsafe chars and caps length', function () use ($same, $true) { + $same('abcXYZ-9._', WebDecoy_Cloud_Connect::sanitize_connect_token('abcXYZ-9._'), 'url-safe token preserved'); + $same('abcscriptdef', WebDecoy_Cloud_Connect::sanitize_connect_token('abc