From 9ac10bcda7ae05ee3b73f9db35133c9edb0abbcc Mon Sep 17 00:00:00 2001 From: Marcello Alarcon Date: Tue, 9 Jun 2026 17:26:28 -0300 Subject: [PATCH 1/9] feat(security): input validation, media path LFI guard, save snapshot/rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the write API against the main risks of letting an AI agent mutate a live WordPress site: - New Validator class: - validate_tree(): structural + bounded depth (30) + size (5000) checks on any element tree before it replaces _elementor_data. Rejects malformed nodes, unknown elTypes, widgets without widgetType, non-array settings/children. - resolve_media_path(): canonicalizes the import path and confirms it lives inside wp_upload_dir() — closes a path-traversal / LFI vector in media import (previously copy() ran on any server path). Rejects remote URLs and non-image MIME types. - is_valid_element_id(): validates element ids arriving in request BODIES (URL routes were already constrained; bodies were not). - Elementor_Data: - save_page_data() now snapshots the prior _elementor_data to a backup meta slot before overwriting; new restore_backup() reverts one level — a bad write is now undoable. - import_image() dedups by SHA-1 content hash (not title — different images sharing a title no longer collapse) and uses wp_unique_filename() to avoid clobbering existing uploads. - REST_Controller: validate_tree on update_page/create_page/build_page/add_element, id checks on patch-bulk, resolved media paths on import_media/build_page, plus new POST /page/{id}/restore, GET /kit/globals, and a public GET /health probe. Uniform error_response() carries HTTP status from WP_Error data. - Bootstrap: load Validator; add str_starts_with() polyfill so the declared "Requires PHP 7.4" floor is real (widget discovery used a PHP 8.0 function). Co-Authored-By: Claude Opus 4.8 (1M context) --- includes/class-elementor-data.php | 114 ++++++++++++++++-- includes/class-rest-controller.php | 117 ++++++++++++++++-- includes/class-validator.php | 184 +++++++++++++++++++++++++++++ neoservice-elementor-api.php | 14 ++- 4 files changed, 409 insertions(+), 20 deletions(-) create mode 100644 includes/class-validator.php diff --git a/includes/class-elementor-data.php b/includes/class-elementor-data.php index 3652d54..fbb333b 100644 --- a/includes/class-elementor-data.php +++ b/includes/class-elementor-data.php @@ -31,13 +31,25 @@ public static function get_page_data(int $post_id): ?array { return is_array($data) ? $data : null; } + /** Post-meta key holding the previous `_elementor_data` for one-step rollback. */ + const BACKUP_META_KEY = '_neoservice_elementor_backup'; + /** * Save Elementor data for a page. * Tries native Elementor save first, falls back to direct meta write. * + * Before overwriting, the current `_elementor_data` is snapshotted to + * {@see BACKUP_META_KEY} so a bad write can be reverted with {@see restore_backup}. + * * @return bool Success. */ public static function save_page_data(int $post_id, array $data): bool { + // Snapshot the current state for rollback (best-effort, never blocks the save). + $previous = get_post_meta($post_id, '_elementor_data', true); + if (!empty($previous)) { + update_post_meta($post_id, self::BACKUP_META_KEY, $previous); + } + // Ensure Elementor meta flags are set update_post_meta($post_id, '_elementor_edit_mode', 'builder'); update_post_meta($post_id, '_elementor_template_type', 'wp-page'); @@ -63,6 +75,27 @@ public static function save_page_data(int $post_id, array $data): bool { return true; } + /** + * Restore the previous `_elementor_data` snapshot taken by the last + * {@see save_page_data} call. One level deep — there is exactly one backup slot. + * + * @return bool True if a backup existed and was restored, false if none. + */ + public static function restore_backup(int $post_id): bool { + $backup = get_post_meta($post_id, self::BACKUP_META_KEY, true); + if (empty($backup)) { + return false; + } + + $decoded = is_string($backup) ? json_decode($backup, true) : $backup; + if (!is_array($decoded)) { + return false; + } + + // Re-save through the normal path (which itself snapshots, enabling redo). + return self::save_page_data($post_id, $decoded); + } + /** * Get a compact page structure (IDs, types, widget types). */ @@ -237,10 +270,63 @@ public static function update_kit_settings(array $settings): bool { return true; } + /** + * Get the design-system globals declared in the active Kit. + * + * Returns the global colors and fonts as a flat, agent-friendly map so the + * generation side can reference them via Elementor's `__globals__` mechanism + * (e.g. `globals/colors?id=primary`) instead of hardcoding inline hex — the + * single biggest lever for producing *professional*, brand-consistent pages. + * + * Shape: + * { + * "colors": [{"_id":"primary","title":"Primary","color":"#FF0000"}, ...], + * "typography": [{"_id":"primary","title":"Primary","family":"Inter","weight":"600"}, ...] + * } + */ + public static function get_kit_globals(): array { + $settings = self::get_kit_settings(); + + $colors = []; + foreach (['system_colors', 'custom_colors'] as $bucket) { + if (!empty($settings[$bucket]) && is_array($settings[$bucket])) { + foreach ($settings[$bucket] as $c) { + $colors[] = [ + '_id' => $c['_id'] ?? '', + 'title' => $c['title'] ?? '', + 'color' => $c['color'] ?? '', + 'bucket' => $bucket === 'system_colors' ? 'system' : 'custom', + ]; + } + } + } + + $typography = []; + foreach (['system_typography', 'custom_typography'] as $bucket) { + if (!empty($settings[$bucket]) && is_array($settings[$bucket])) { + foreach ($settings[$bucket] as $t) { + $typography[] = [ + '_id' => $t['_id'] ?? '', + 'title' => $t['title'] ?? '', + 'family' => $t['typography_font_family'] ?? '', + 'weight' => $t['typography_font_weight'] ?? '', + 'bucket' => $bucket === 'system_typography' ? 'system' : 'custom', + ]; + } + } + } + + return ['colors' => $colors, 'typography' => $typography]; + } + // ── Media ──────────────────────────────────────────────── /** * Import an image from a file path into the WP media library. + * + * Dedup is keyed on the file's SHA-1 content hash (stored as attachment meta), + * not the title — two different images sharing a title must NOT collapse into one, + * and re-importing the identical file must be idempotent. */ public static function import_image(string $source_path, string $title = ''): int { if (!file_exists($source_path)) return 0; @@ -248,19 +334,23 @@ public static function import_image(string $source_path, string $title = ''): in $filename = basename($source_path); $title = $title ?: pathinfo($filename, PATHINFO_FILENAME); - // Check if already imported - global $wpdb; - $existing = $wpdb->get_var($wpdb->prepare( - "SELECT ID FROM {$wpdb->posts} WHERE post_type='attachment' AND post_title=%s LIMIT 1", - $title - )); - if ($existing) return (int) $existing; + // Content-hash dedup: re-importing the same bytes returns the existing attachment. + $hash = @sha1_file($source_path); + if ($hash) { + global $wpdb; + $existing = $wpdb->get_var($wpdb->prepare( + "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key='_neoservice_source_hash' AND meta_value=%s LIMIT 1", + $hash + )); + if ($existing) return (int) $existing; + } $upload_dir = wp_upload_dir(); - $dest = $upload_dir['path'] . '/' . $filename; + // Collision-safe destination filename inside the uploads dir. + $dest = $upload_dir['path'] . '/' . wp_unique_filename($upload_dir['path'], $filename); - if (!file_exists($dest)) { - copy($source_path, $dest); + if (!@copy($source_path, $dest)) { + return 0; } $filetype = wp_check_filetype($filename); @@ -275,6 +365,10 @@ public static function import_image(string $source_path, string $title = ''): in $metadata = wp_generate_attachment_metadata($attach_id, $dest); wp_update_attachment_metadata($attach_id, $metadata); + if ($hash && $attach_id) { + update_post_meta($attach_id, '_neoservice_source_hash', $hash); + } + return $attach_id; } diff --git a/includes/class-rest-controller.php b/includes/class-rest-controller.php index 81b0c9e..d12deab 100644 --- a/includes/class-rest-controller.php +++ b/includes/class-rest-controller.php @@ -44,6 +44,14 @@ public function register_routes(): void { ...$editor, ]); + // Roll back the last save (restores the snapshot taken before the most + // recent write to this page). One level deep. + register_rest_route(self::NAMESPACE, '/page/(?P\d+)/restore', [ + 'methods' => 'POST', + 'callback' => [$this, 'restore_page'], + ...$editor, + ]); + // ── Elements (granular operations) ─────────────── register_rest_route(self::NAMESPACE, '/page/(?P\d+)/element', [ 'methods' => 'POST', @@ -139,6 +147,14 @@ public function register_routes(): void { ...$editor, ]); + // Design-system globals (colors + fonts) in an agent-friendly flat shape, + // ready to reference from widgets via `__globals__`. + register_rest_route(self::NAMESPACE, '/kit/globals', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_kit_globals'], + ...$reader, + ]); + // ── Widgets ────────────────────────────────────── register_rest_route(self::NAMESPACE, '/widgets', [ 'methods' => 'GET', @@ -178,6 +194,15 @@ public function register_routes(): void { 'callback' => [$this, 'build_page'], ...$editor, ]); + + // ── Health ──────────────────────────────────────── + // Public, read-only probe. Lets a client confirm the plugin is installed and + // active (and which Elementor it sees) WITHOUT needing edit credentials. + register_rest_route(self::NAMESPACE, '/health', [ + 'methods' => 'GET', + 'callback' => [$this, 'health'], + 'permission_callback' => '__return_true', + ]); } // ── Permission checks ──────────────────────────────────── @@ -190,6 +215,19 @@ public function check_edit_permission(): bool { return current_user_can('edit_posts'); } + /** + * Convert a WP_Error (carrying an optional HTTP status in its data) into a + * uniform REST response: {"error": "", "code": ""}. + */ + private function error_response(\WP_Error $error): \WP_REST_Response { + $data = $error->get_error_data(); + $status = is_array($data) && isset($data['status']) ? (int) $data['status'] : 400; + return new \WP_REST_Response([ + 'error' => $error->get_error_message(), + 'code' => $error->get_error_code(), + ], $status); + } + // ── Pages ──────────────────────────────────────────────── public function list_pages(\WP_REST_Request $request): \WP_REST_Response { @@ -256,6 +294,11 @@ public function update_page(\WP_REST_Request $request): \WP_REST_Response { return new \WP_REST_Response(['error' => 'Missing or invalid "data" array'], 400); } + $valid = Validator::validate_tree($body['data']); + if (is_wp_error($valid)) { + return $this->error_response($valid); + } + $success = Elementor_Data::save_page_data($id, $body['data']); return new \WP_REST_Response([ @@ -281,8 +324,12 @@ public function create_page(\WP_REST_Request $request): \WP_REST_Response { return new \WP_REST_Response(['error' => $post_id->get_error_message()], 500); } - // If Elementor data provided, save it + // If Elementor data provided, validate then save it if (!empty($body['data']) && is_array($body['data'])) { + $valid = Validator::validate_tree($body['data']); + if (is_wp_error($valid)) { + return $this->error_response($valid); + } Elementor_Data::save_page_data($post_id, $body['data']); } @@ -293,6 +340,17 @@ public function create_page(\WP_REST_Request $request): \WP_REST_Response { ], 201); } + public function restore_page(\WP_REST_Request $request): \WP_REST_Response { + $id = (int) $request['id']; + + $restored = Elementor_Data::restore_backup($id); + if (!$restored) { + return new \WP_REST_Response(['error' => 'No backup available to restore'], 404); + } + + return new \WP_REST_Response(['success' => true, 'id' => $id], 200); + } + // ── Elements ───────────────────────────────────────────── public function get_element(\WP_REST_Request $request): \WP_REST_Response { @@ -327,8 +385,14 @@ public function add_element(\WP_REST_Request $request): \WP_REST_Response { return new \WP_REST_Response(['error' => 'Missing "element" object'], 400); } - // Ensure element has an ID - if (empty($element['id'])) { + // Validate the element subtree before insertion. + $valid = Validator::validate_tree([$element]); + if (is_wp_error($valid)) { + return $this->error_response($valid); + } + + // Ensure element has a valid ID (generate one if missing or malformed). + if (empty($element['id']) || !Validator::is_valid_element_id($element['id'])) { $element['id'] = Element_Factory::generate_id(); } @@ -495,6 +559,10 @@ public function patch_elements_bulk(\WP_REST_Request $request): \WP_REST_Respons $results[] = ['index' => $i, 'id' => $eid, 'status' => 'skipped', 'reason' => 'missing id or settings']; continue; } + if (!Validator::is_valid_element_id($eid)) { + $results[] = ['index' => $i, 'id' => $eid, 'status' => 'skipped', 'reason' => 'invalid element id']; + continue; + } $ok = Elementor_Data::update_element_settings($data, $eid, $settings); $results[] = [ 'index' => $i, @@ -705,6 +773,10 @@ public function update_kit(\WP_REST_Request $request): \WP_REST_Response { return new \WP_REST_Response(['success' => $success], $success ? 200 : 500); } + public function get_kit_globals(\WP_REST_Request $request): \WP_REST_Response { + return new \WP_REST_Response(Elementor_Data::get_kit_globals(), 200); + } + // ── Widgets ────────────────────────────────────────────── public function list_widgets(\WP_REST_Request $request): \WP_REST_Response { @@ -740,11 +812,14 @@ public function import_media(\WP_REST_Request $request): \WP_REST_Response { $path = $body['path'] ?? ''; $title = $body['title'] ?? ''; - if (empty($path)) { - return new \WP_REST_Response(['error' => 'Missing "path"'], 400); + // Resolve + validate the path: must be a real image inside the uploads dir. + // Guards against path traversal / LFI on this write-capable endpoint. + $resolved = Validator::resolve_media_path((string) $path); + if (is_wp_error($resolved)) { + return $this->error_response($resolved); } - $attach_id = Elementor_Data::import_image($path, $title); + $attach_id = Elementor_Data::import_image($resolved, $title); if (!$attach_id) { return new \WP_REST_Response(['error' => "Failed to import: $path"], 500); @@ -776,6 +851,14 @@ public function flush_css(\WP_REST_Request $request): \WP_REST_Response { public function build_page(\WP_REST_Request $request): \WP_REST_Response { $body = $request->get_json_params(); + // Validate the element tree up front (if provided) — fail before creating a page. + if (!empty($body['data']) && is_array($body['data'])) { + $valid = Validator::validate_tree($body['data']); + if (is_wp_error($valid)) { + return $this->error_response($valid); + } + } + // Create or update page $page_id = $body['page_id'] ?? 0; if (!$page_id) { @@ -790,11 +873,16 @@ public function build_page(\WP_REST_Request $request): \WP_REST_Response { } } - // Import images if provided + // Import images if provided (each path validated against traversal/LFI). $media_map = []; if (!empty($body['images']) && is_array($body['images'])) { foreach ($body['images'] as $key => $img) { - $attach_id = Elementor_Data::import_image($img['path'] ?? '', $img['title'] ?? ''); + $resolved = Validator::resolve_media_path((string) ($img['path'] ?? '')); + if (is_wp_error($resolved)) { + $media_map[$key] = ['id' => 0, 'url' => '', 'error' => $resolved->get_error_message()]; + continue; + } + $attach_id = Elementor_Data::import_image($resolved, $img['title'] ?? ''); $media_map[$key] = [ 'id' => $attach_id, 'url' => $attach_id ? wp_get_attachment_url($attach_id) : '', @@ -814,4 +902,17 @@ public function build_page(\WP_REST_Request $request): \WP_REST_Response { 'media_map' => $media_map, ], 201); } + + // ── Health ─────────────────────────────────────────────── + + public function health(\WP_REST_Request $request): \WP_REST_Response { + return new \WP_REST_Response([ + 'ok' => true, + 'plugin' => 'neoservice-elementor-api', + 'plugin_version' => defined('NEOSERVICE_ELEMENTOR_API_VERSION') ? NEOSERVICE_ELEMENTOR_API_VERSION : 'unknown', + 'elementor_active' => did_action('elementor/loaded') > 0, + 'elementor_version' => defined('ELEMENTOR_VERSION') ? ELEMENTOR_VERSION : null, + 'namespace' => self::NAMESPACE, + ], 200); + } } diff --git a/includes/class-validator.php b/includes/class-validator.php new file mode 100644 index 0000000..1f68709 --- /dev/null +++ b/includes/class-validator.php @@ -0,0 +1,184 @@ + self::MAX_DEPTH) { + return new \WP_Error( + 'tree_too_deep', + sprintf('Element tree exceeds max depth of %d.', self::MAX_DEPTH), + ['status' => 400] + ); + } + + foreach ($nodes as $node) { + if (!is_array($node)) { + return new \WP_Error('invalid_node', 'Every element must be an object.', ['status' => 400]); + } + + if (++$count > self::MAX_ELEMENTS) { + return new \WP_Error( + 'tree_too_large', + sprintf('Element tree exceeds max of %d elements.', self::MAX_ELEMENTS), + ['status' => 400] + ); + } + + $el_type = $node['elType'] ?? null; + if (!is_string($el_type) || !in_array($el_type, self::ALLOWED_ELTYPES, true)) { + return new \WP_Error( + 'invalid_eltype', + sprintf('Invalid or missing elType: %s', is_scalar($el_type) ? (string) $el_type : gettype($el_type)), + ['status' => 400] + ); + } + + if ($el_type === 'widget' && empty($node['widgetType'])) { + return new \WP_Error('missing_widget_type', 'Widget elements require a widgetType.', ['status' => 400]); + } + + if (isset($node['settings']) && !is_array($node['settings'])) { + return new \WP_Error('invalid_settings', 'Element settings must be an object.', ['status' => 400]); + } + + if (!empty($node['elements'])) { + if (!is_array($node['elements'])) { + return new \WP_Error('invalid_children', 'Element children must be an array.', ['status' => 400]); + } + $result = self::validate_nodes($node['elements'], $depth + 1, $count); + if ($result !== true) { + return $result; + } + } + } + + return true; + } + + /** + * Validate a single element ID coming from a request body (not the URL — the URL + * route already constrains the pattern to `[a-f0-9]+`). Bodies (`add_element`, + * `patch-bulk`) are unconstrained, so check them explicitly. + * + * @param mixed $id + * @return bool + */ + public static function is_valid_element_id($id): bool { + return is_string($id) && (bool) preg_match('/^[a-z0-9]{1,16}$/i', $id); + } + + /** + * Resolve and validate a media import source path against traversal / LFI. + * + * Accepts a path INSIDE the WordPress uploads directory only (the canonical place + * an agent stages assets), rejecting symlink escapes and `../` traversal. Callers + * that need remote URLs should download to uploads first; this method intentionally + * does not fetch URLs. + * + * @param string $path Raw path from the request. + * @return string|\WP_Error Canonical absolute path on success, WP_Error on rejection. + */ + public static function resolve_media_path(string $path) { + if ($path === '') { + return new \WP_Error('missing_path', 'Missing media "path".', ['status' => 400]); + } + + // Reject obvious remote schemes early — this endpoint is local-path only. + if (preg_match('#^[a-z][a-z0-9+.\-]*://#i', $path)) { + return new \WP_Error( + 'remote_path_rejected', + 'Remote URLs are not accepted by media import. Stage the file in the uploads directory first.', + ['status' => 400] + ); + } + + $upload_dir = wp_upload_dir(); + $base = $upload_dir['basedir'] ?? ''; + if (!$base) { + return new \WP_Error('no_upload_dir', 'Could not resolve the uploads directory.', ['status' => 500]); + } + + // Resolve to a real, canonical path. realpath() collapses `..` and follows symlinks, + // so comparing the canonical child against the canonical base defeats traversal. + $real_path = realpath($path); + $real_base = realpath($base); + + if ($real_path === false || $real_base === false) { + return new \WP_Error('path_not_found', 'Media path does not exist.', ['status' => 404]); + } + + // Ensure the resolved path is the base itself or a descendant of it. + $prefix = rtrim($real_base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + if ($real_path !== $real_base && strpos($real_path, $prefix) !== 0) { + return new \WP_Error( + 'path_outside_uploads', + 'Media path must be inside the WordPress uploads directory.', + ['status' => 403] + ); + } + + if (!is_file($real_path)) { + return new \WP_Error('not_a_file', 'Media path is not a regular file.', ['status' => 400]); + } + + // Only allow real image MIME types that WordPress recognises. + $check = wp_check_filetype(basename($real_path)); + $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/avif']; + if (empty($check['type']) || !in_array($check['type'], $allowed, true)) { + return new \WP_Error( + 'unsupported_media_type', + 'Only image files (jpg, png, gif, webp, svg, avif) can be imported.', + ['status' => 415] + ); + } + + return $real_path; + } +} diff --git a/neoservice-elementor-api.php b/neoservice-elementor-api.php index f7ecaac..68fe6e6 100644 --- a/neoservice-elementor-api.php +++ b/neoservice-elementor-api.php @@ -2,7 +2,7 @@ /** * Plugin Name: NeoService Elementor API * Description: REST API + MCP tools for AI-driven Elementor page building. Exposes endpoints to create, read, update pages, elements, templates, and global settings programmatically. Compatible with WordPress MCP Adapter. - * Version: 1.3.0 + * Version: 1.4.0 * Author: NeoService * Requires at least: 6.0 * Requires PHP: 7.4 @@ -11,10 +11,20 @@ if (!defined('ABSPATH')) exit; -define('NEOSERVICE_ELEMENTOR_API_VERSION', '1.3.0'); +// PHP 7.4 polyfill — the plugin declares "Requires PHP: 7.4" but the widget +// discovery code uses str_starts_with() (PHP 8.0+). Provide it on older runtimes +// so the declared floor is real rather than a latent fatal. +if (!function_exists('str_starts_with')) { + function str_starts_with(string $haystack, string $needle): bool { + return $needle === '' || strncmp($haystack, $needle, strlen($needle)) === 0; + } +} + +define('NEOSERVICE_ELEMENTOR_API_VERSION', '1.4.0'); define('NEOSERVICE_ELEMENTOR_API_PATH', plugin_dir_path(__FILE__)); // Load core includes +require_once NEOSERVICE_ELEMENTOR_API_PATH . 'includes/class-validator.php'; require_once NEOSERVICE_ELEMENTOR_API_PATH . 'includes/class-element-factory.php'; require_once NEOSERVICE_ELEMENTOR_API_PATH . 'includes/class-elementor-data.php'; require_once NEOSERVICE_ELEMENTOR_API_PATH . 'includes/class-rest-controller.php'; From d90bb98139e40311556fccc66d07a044c094c18f Mon Sep 17 00:00:00 2001 From: Marcello Alarcon Date: Tue, 9 Jun 2026 17:26:36 -0300 Subject: [PATCH 2/9] =?UTF-8?q?fix(mcp):=20close=20REST=E2=86=94Abilities?= =?UTF-8?q?=20drift=20and=20an=20add-element=20crash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP Abilities surface had drifted behind the REST surface (1.3.0 added bulk/ find/section to REST only). Bring them to parity and fix a latent bug: - Bug: the add-element ability inserted into $parent['element']['elements'] without ensuring the key existed — a parent container with no children array triggered an undefined-index error. The REST twin already guarded this; the ability now does too. - New abilities (parity with REST): find-elements, patch-elements-bulk, restore-page, get-kit-globals. - Reuse the new Validator across write abilities: validate_tree on save-page-data / create-page / build-page / add-element, valid id checks in patch-bulk, and resolve_media_path on build-page image import (same LFI guard as REST). Co-Authored-By: Claude Opus 4.8 (1M context) --- includes/class-abilities-provider.php | 184 +++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) diff --git a/includes/class-abilities-provider.php b/includes/class-abilities-provider.php index 2f670b4..e826f7e 100644 --- a/includes/class-abilities-provider.php +++ b/includes/class-abilities-provider.php @@ -65,6 +65,7 @@ public static function register_category(): void { public static function register(): void { self::register_page_abilities(); self::register_element_abilities(); + self::register_bulk_abilities(); self::register_template_abilities(); self::register_kit_abilities(); self::register_widget_abilities(); @@ -218,6 +219,11 @@ private static function register_page_abilities(): void { ], 'execute_callback' => function ($input) { $post_id = (int) $input['post_id']; + if (!is_array($input['data'])) { + return new \WP_Error('invalid_data', 'data must be an array.'); + } + $valid = Validator::validate_tree($input['data']); + if (is_wp_error($valid)) return $valid; $success = Elementor_Data::save_page_data($post_id, $input['data']); return ['success' => $success, 'post_id' => $post_id]; }, @@ -270,6 +276,11 @@ private static function register_page_abilities(): void { ]); if (is_wp_error($post_id)) return $post_id; if (!empty($input['data'])) { + if (!is_array($input['data'])) { + return new \WP_Error('invalid_data', 'data must be an array.'); + } + $valid = Validator::validate_tree($input['data']); + if (is_wp_error($valid)) return $valid; Elementor_Data::save_page_data($post_id, $input['data']); } return ['post_id' => $post_id, 'url' => get_permalink($post_id)]; @@ -473,11 +484,22 @@ private static function register_element_abilities(): void { } $element = $input['element']; + if (!is_array($element)) { + return new \WP_Error('invalid_element', 'element must be an object.'); + } + $valid = Validator::validate_tree([$element]); + if (is_wp_error($valid)) return $valid; + if (empty($element['id']) || !Validator::is_valid_element_id($element['id'])) { + $element['id'] = Element_Factory::generate_id(); + } $position = (int) ($input['position'] ?? -1); if (!empty($input['parent_id'])) { $found = Elementor_Data::find_element($data, $input['parent_id']); if (!$found) return new \WP_Error('not_found', 'Parent element not found.'); + if (!isset($found['element']['elements'])) { + $found['element']['elements'] = []; + } Elementor_Data::insert_element($found['element']['elements'], $element, $position); } else { Elementor_Data::insert_element($data, $element, $position); @@ -671,6 +693,141 @@ private static function register_element_abilities(): void { ]); } + // ── Bulk / discovery abilities (parity with REST 1.3+) ── + + private static function register_bulk_abilities(): void { + + wp_register_ability('neoservice/find-elements', [ + 'label' => 'Find Elements', + 'description' => 'Find elements on a page by widgetType, elType, or text contained in their settings. Returns each match with its id, type, depth, and parent_id. Use this to locate elements surgically before patching them.', + 'category' => 'neoservice-elementor', + 'input_schema' => [ + 'type' => 'object', + 'required' => ['post_id'], + 'properties' => [ + 'post_id' => ['type' => 'integer', 'description' => 'The WordPress page/post ID.'], + 'widget' => ['type' => 'string', 'description' => 'Match widgetType (e.g. "heading").'], + 'elType' => ['type' => 'string', 'description' => 'Match elType (e.g. "container").'], + 'contains' => ['type' => 'string', 'description' => 'Match text contained in the element settings.'], + ], + 'additionalProperties' => false, + ], + 'output_schema' => ['type' => 'object'], + 'execute_callback' => function ($input) { + $post_id = (int) $input['post_id']; + $widget = $input['widget'] ?? null; + $eltype = $input['elType'] ?? null; + $needle = $input['contains'] ?? null; + if (!$widget && !$eltype && !$needle) { + return new \WP_Error('missing_filter', 'Provide at least one of: widget, elType, contains.'); + } + $data = Elementor_Data::get_page_data($post_id); + if (!$data) return new \WP_Error('not_found', 'Page not found.'); + + $matches = []; + $walk = function ($nodes, $parent_id = null, $depth = 0) use (&$walk, &$matches, $widget, $eltype, $needle) { + foreach ($nodes as $node) { + $match = true; + if ($widget && ($node['widgetType'] ?? null) !== $widget) $match = false; + if ($eltype && ($node['elType'] ?? null) !== $eltype) $match = false; + if ($needle && stripos(json_encode($node['settings'] ?? []), $needle) === false) $match = false; + if ($match) { + $matches[] = [ + 'id' => $node['id'] ?? null, + 'widgetType' => $node['widgetType'] ?? null, + 'elType' => $node['elType'] ?? null, + 'depth' => $depth, + 'parent_id' => $parent_id, + ]; + } + if (!empty($node['elements']) && is_array($node['elements'])) { + $walk($node['elements'], $node['id'] ?? null, $depth + 1); + } + } + }; + $walk($data); + return ['count' => count($matches), 'matches' => $matches]; + }, + 'permission_callback' => [self::class, 'can_read'], + 'meta' => self::meta_read(), + ]); + + wp_register_ability('neoservice/patch-elements-bulk', [ + 'label' => 'Patch Elements (Bulk)', + 'description' => 'Apply many element settings patches in ONE page load/save cycle. Far more efficient and race-safe than issuing N update-element calls. Body: patches = [{id, settings}, ...]. Patches apply in order.', + 'category' => 'neoservice-elementor', + 'input_schema' => [ + 'type' => 'object', + 'required' => ['post_id', 'patches'], + 'properties' => [ + 'post_id' => ['type' => 'integer', 'description' => 'The WordPress page/post ID.'], + 'patches' => [ + 'type' => 'array', + 'description' => 'Array of {id, settings} objects.', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'settings' => ['type' => 'object'], + ], + ], + ], + ], + 'additionalProperties' => false, + ], + 'output_schema' => ['type' => 'object'], + 'execute_callback' => function ($input) { + $post_id = (int) $input['post_id']; + $patches = $input['patches'] ?? []; + if (!is_array($patches) || empty($patches)) { + return new \WP_Error('missing_patches', 'Missing "patches" array.'); + } + $data = Elementor_Data::get_page_data($post_id); + if (!$data) return new \WP_Error('not_found', 'Page not found.'); + + $results = []; + foreach ($patches as $i => $patch) { + $eid = $patch['id'] ?? ''; + $settings = $patch['settings'] ?? []; + if (!Validator::is_valid_element_id($eid) || !is_array($settings)) { + $results[] = ['index' => $i, 'id' => $eid, 'status' => 'skipped']; + continue; + } + $ok = Elementor_Data::update_element_settings($data, $eid, $settings); + $results[] = ['index' => $i, 'id' => $eid, 'status' => $ok ? 'ok' : 'not_found']; + } + Elementor_Data::save_page_data($post_id, $data); + $ok_count = count(array_filter($results, fn($r) => $r['status'] === 'ok')); + return ['success' => true, 'applied' => $ok_count, 'total' => count($patches), 'results' => $results]; + }, + 'permission_callback' => [self::class, 'can_edit'], + 'meta' => self::meta_write(), + ]); + + wp_register_ability('neoservice/restore-page', [ + 'label' => 'Restore Page (Undo Last Save)', + 'description' => 'Roll a page back to the snapshot taken immediately before its most recent save. One level deep. Use this to undo a bad write.', + 'category' => 'neoservice-elementor', + 'input_schema' => [ + 'type' => 'object', + 'required' => ['post_id'], + 'properties' => [ + 'post_id' => ['type' => 'integer', 'description' => 'The WordPress page/post ID.'], + ], + 'additionalProperties' => false, + ], + 'output_schema' => ['type' => 'object'], + 'execute_callback' => function ($input) { + $post_id = (int) $input['post_id']; + $ok = Elementor_Data::restore_backup($post_id); + if (!$ok) return new \WP_Error('no_backup', 'No backup available to restore.'); + return ['success' => true, 'post_id' => $post_id]; + }, + 'permission_callback' => [self::class, 'can_edit'], + 'meta' => self::meta_write(true), + ]); + } + // ── Template Abilities ────────────────────────────────── private static function register_template_abilities(): void { @@ -810,6 +967,18 @@ private static function register_kit_abilities(): void { 'permission_callback' => [self::class, 'can_edit'], 'meta' => self::meta_write(), ]); + + wp_register_ability('neoservice/get-kit-globals', [ + 'label' => 'Get Design-System Globals', + 'description' => 'Get the global colors and fonts from the active Kit in a flat, ready-to-reference shape. Use these IDs with a widget\'s __globals__ object (e.g. globals/colors?id=primary) to keep generated pages brand-consistent instead of hardcoding inline hex.', + 'category' => 'neoservice-elementor', + 'output_schema' => ['type' => 'object'], + 'execute_callback' => function () { + return Elementor_Data::get_kit_globals(); + }, + 'permission_callback' => [self::class, 'can_read'], + 'meta' => self::meta_read(), + ]); } // ── Widget Discovery ──────────────────────────────────── @@ -951,12 +1120,21 @@ private static function register_utility_abilities(): void { ], ], 'execute_callback' => function ($input) { - // Import images first + // Validate the element tree before doing any work. + if (empty($input['data']) || !is_array($input['data'])) { + return new \WP_Error('invalid_data', 'data must be a non-empty array.'); + } + $valid = Validator::validate_tree($input['data']); + if (is_wp_error($valid)) return $valid; + + // Import images first (each path validated against traversal/LFI). $image_ids = []; if (!empty($input['images'])) { foreach ($input['images'] as $img) { - $id = Elementor_Data::import_image($img['source_path'], $img['title'] ?? ''); - if ($id) $image_ids[$img['title'] ?? basename($img['source_path'])] = $id; + $resolved = Validator::resolve_media_path((string) ($img['source_path'] ?? '')); + if (is_wp_error($resolved)) continue; + $id = Elementor_Data::import_image($resolved, $img['title'] ?? ''); + if ($id) $image_ids[$img['title'] ?? basename($resolved)] = $id; } } From ab51a84084515a9858fc1806bf8e95e75a3e5be3 Mon Sep 17 00:00:00 2001 From: Marcello Alarcon Date: Tue, 9 Jun 2026 17:26:42 -0300 Subject: [PATCH 3/9] test: dependency-free PHP test harness (Validator + Element_Factory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 41 assertions covering the plugin's pure-logic surface — tree validation (shape/depth/size), element-id checks, media-path traversal/LFI guard, and factory structure/id-reassignment. Runs on a bare PHP CLI (no composer, no PHPUnit) via `php tests/run.php`, exit 1 on any failure. WP stubs only cover the handful of functions the tested code reaches; runtime behaviour needing a live WordPress+Elementor stays out of scope and is flagged for on-install verification. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/run.php | 58 +++++++++++++++ tests/test-element-factory.php | 60 ++++++++++++++++ tests/test-validator.php | 126 +++++++++++++++++++++++++++++++++ tests/wp-stubs.php | 57 +++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 tests/run.php create mode 100644 tests/test-element-factory.php create mode 100644 tests/test-validator.php create mode 100644 tests/wp-stubs.php diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..bfed39a --- /dev/null +++ b/tests/run.php @@ -0,0 +1,58 @@ +passed++; + } else { + $this->failed++; + $this->failures[] = $label; + } + } + + public function report(): void { + foreach ($this->failures as $f) { + fwrite(STDERR, " FAIL: $f\n"); + } + $total = $this->passed + $this->failed; + echo sprintf("\n%d/%d assertions passed (%d failed)\n", $this->passed, $total, $this->failed); + } +} + +require __DIR__ . '/wp-stubs.php'; +require __DIR__ . '/../includes/class-validator.php'; +require __DIR__ . '/../includes/class-element-factory.php'; + +$t = new Asserter(); + +$suites = [ + 'Validator' => __DIR__ . '/test-validator.php', + 'Element_Factory' => __DIR__ . '/test-element-factory.php', +]; + +foreach ($suites as $name => $file) { + echo "── $name ──\n"; + $suite = require $file; + $suite($t); +} + +$t->report(); +exit($t->failed > 0 ? 1 : 0); diff --git a/tests/test-element-factory.php b/tests/test-element-factory.php new file mode 100644 index 0000000..151aca8 --- /dev/null +++ b/tests/test-element-factory.php @@ -0,0 +1,60 @@ +true(is_string($id) && strlen($id) === 8, 'generate_id returns 8 chars'); + $t->true((bool) preg_match('/^[a-f0-9]{8}$/', $id), 'generate_id is lowercase hex'); + $t->true(Element_Factory::generate_id() !== Element_Factory::generate_id(), 'generate_id is unique per call'); + + // ── container ──────────────────────────────────────────── + $c = Element_Factory::container(['flex_direction' => 'row'], [], true); + $t->true($c['elType'] === 'container', 'container elType'); + $t->true($c['isInner'] === true, 'container isInner honoured'); + $t->true($c['settings']['flex_direction'] === 'row', 'container setting override applied'); + $t->true($c['settings']['content_width'] === 'boxed', 'container default preserved'); + $t->true(isset($c['id']) && isset($c['elements']), 'container has id + elements'); + + // ── widget ─────────────────────────────────────────────── + $w = Element_Factory::widget('counter', ['starting_number' => 0]); + $t->true($w['elType'] === 'widget' && $w['widgetType'] === 'counter', 'widget type set'); + $t->true($w['settings']['starting_number'] === 0, 'widget settings passed through'); + + // ── heading ────────────────────────────────────────────── + $h = Element_Factory::heading('Title', 'h1'); + $t->true($h['widgetType'] === 'heading', 'heading widgetType'); + $t->true($h['settings']['title'] === 'Title' && $h['settings']['header_size'] === 'h1', 'heading title+tag'); + + // ── reassign_ids (used by duplicate) ───────────────────── + $tree = [ + 'id' => 'old-root', 'elType' => 'container', + 'elements' => [ + ['id' => 'old-child-1', 'elType' => 'widget', 'widgetType' => 'heading', 'elements' => []], + ['id' => 'old-child-2', 'elType' => 'container', 'elements' => [ + ['id' => 'old-grandchild', 'elType' => 'widget', 'widgetType' => 'text-editor', 'elements' => []], + ]], + ], + ]; + $before = json_encode($tree); + Element_Factory::reassign_ids($tree); + + $ids = []; + $collect = function ($node) use (&$collect, &$ids) { + $ids[] = $node['id']; + foreach ($node['elements'] ?? [] as $child) $collect($child); + }; + $collect($tree); + + $t->true(!in_array('old-root', $ids, true), 'root id reassigned'); + $t->true(!in_array('old-grandchild', $ids, true), 'nested id reassigned recursively'); + $t->true(count($ids) === count(array_unique($ids)), 'all reassigned ids are unique'); + $t->true(count($ids) === 4, 'all 4 nodes retained after reassign'); +}; diff --git a/tests/test-validator.php b/tests/test-validator.php new file mode 100644 index 0000000..2da3b32 --- /dev/null +++ b/tests/test-validator.php @@ -0,0 +1,126 @@ +true(Validator::validate_tree([]) === true, 'empty tree is valid'); + + $valid_tree = [[ + 'id' => 'abc123', 'elType' => 'container', 'settings' => [], + 'elements' => [ + ['id' => 'def456', 'elType' => 'widget', 'widgetType' => 'heading', 'settings' => ['title' => 'Hi']], + ], + ]]; + $t->true(Validator::validate_tree($valid_tree) === true, 'well-formed container+widget tree is valid'); + + $t->true( + Validator::validate_tree([['id' => 'x', 'elType' => 'section', 'elements' => [ + ['id' => 'y', 'elType' => 'column', 'elements' => [ + ['id' => 'z', 'elType' => 'widget', 'widgetType' => 'text-editor'], + ]], + ]]]) === true, + 'legacy section/column/widget tree is valid' + ); + + // ── validate_tree: rejections ──────────────────────────── + $r = Validator::validate_tree([['id' => 'a', 'elType' => 'widget']]); // missing widgetType + $t->true(is_wp_error($r) && $r->get_error_code() === 'missing_widget_type', 'widget without widgetType rejected'); + + $r = Validator::validate_tree([['id' => 'a', 'elType' => 'bogus']]); + $t->true(is_wp_error($r) && $r->get_error_code() === 'invalid_eltype', 'unknown elType rejected'); + + $r = Validator::validate_tree([['id' => 'a']]); // missing elType + $t->true(is_wp_error($r) && $r->get_error_code() === 'invalid_eltype', 'missing elType rejected'); + + $r = Validator::validate_tree([['elType' => 'container', 'settings' => 'not-an-array']]); + $t->true(is_wp_error($r) && $r->get_error_code() === 'invalid_settings', 'non-array settings rejected'); + + $r = Validator::validate_tree([['elType' => 'container', 'elements' => 'nope']]); + $t->true(is_wp_error($r) && $r->get_error_code() === 'invalid_children', 'non-array children rejected'); + + $r = Validator::validate_tree(['not-an-object']); + $t->true(is_wp_error($r) && $r->get_error_code() === 'invalid_node', 'scalar node rejected'); + + // depth guard: build a tree deeper than MAX_DEPTH + $deep = ['elType' => 'container', 'elements' => []]; + $cursor =& $deep; + for ($i = 0; $i < 40; $i++) { + $cursor['elements'] = [['elType' => 'container', 'elements' => []]]; + $cursor =& $cursor['elements'][0]; + } + unset($cursor); + $r = Validator::validate_tree([$deep]); + $t->true(is_wp_error($r) && $r->get_error_code() === 'tree_too_deep', 'over-deep tree rejected'); + + // size guard + $many = []; + for ($i = 0; $i < 5001; $i++) { + $many[] = ['elType' => 'widget', 'widgetType' => 'spacer']; + } + $r = Validator::validate_tree($many); + $t->true(is_wp_error($r) && $r->get_error_code() === 'tree_too_large', 'oversized tree rejected'); + + // ── is_valid_element_id ────────────────────────────────── + $t->true(Validator::is_valid_element_id('f8703b57'), 'hex id accepted'); + $t->true(Validator::is_valid_element_id('ABC123'), 'mixed-case alnum id accepted'); + $t->true(!Validator::is_valid_element_id('has space'), 'id with space rejected'); + $t->true(!Validator::is_valid_element_id('../etc'), 'id with traversal chars rejected'); + $t->true(!Validator::is_valid_element_id(''), 'empty id rejected'); + $t->true(!Validator::is_valid_element_id(12345), 'non-string id rejected'); + $t->true(!Validator::is_valid_element_id(str_repeat('a', 17)), 'over-long id rejected'); + + // ── resolve_media_path: traversal / LFI guard ──────────── + $sandbox = sys_get_temp_dir() . '/neoservice-test-' . uniqid(); + $uploads = $sandbox . '/uploads'; + mkdir($uploads, 0777, true); + $GLOBALS['__test_upload_basedir'] = $uploads; + + // a legit image inside uploads + $good = $uploads . '/photo.png'; + file_put_contents($good, 'PNGDATA'); + $res = Validator::resolve_media_path($good); + $t->true(!is_wp_error($res) && $res === realpath($good), 'image inside uploads resolves'); + + // a file OUTSIDE uploads (the classic LFI target) + $outside = $sandbox . '/secret.png'; + file_put_contents($outside, 'SECRET'); + $res = Validator::resolve_media_path($outside); + $t->true(is_wp_error($res) && $res->get_error_code() === 'path_outside_uploads', 'file outside uploads rejected'); + + // traversal string that escapes uploads + $res = Validator::resolve_media_path($uploads . '/../secret.png'); + $t->true(is_wp_error($res) && $res->get_error_code() === 'path_outside_uploads', '../ traversal rejected'); + + // remote URL rejected + $res = Validator::resolve_media_path('https://evil.test/x.png'); + $t->true(is_wp_error($res) && $res->get_error_code() === 'remote_path_rejected', 'remote URL rejected'); + + // non-image inside uploads rejected (e.g. a PHP file staged in uploads) + $php = $uploads . '/shell.php'; + file_put_contents($php, 'true(is_wp_error($res) && $res->get_error_code() === 'unsupported_media_type', 'non-image in uploads rejected'); + + // missing path + $res = Validator::resolve_media_path(''); + $t->true(is_wp_error($res) && $res->get_error_code() === 'missing_path', 'empty path rejected'); + + // nonexistent path + $res = Validator::resolve_media_path($uploads . '/ghost.png'); + $t->true(is_wp_error($res) && $res->get_error_code() === 'path_not_found', 'nonexistent path rejected'); + + // cleanup + @unlink($good); @unlink($outside); @unlink($php); + @rmdir($uploads); @rmdir($sandbox); + unset($GLOBALS['__test_upload_basedir']); +}; diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php new file mode 100644 index 0000000..6e6cc53 --- /dev/null +++ b/tests/wp-stubs.php @@ -0,0 +1,57 @@ +code = $code; + $this->message = $message; + $this->data = $data; + } + public function get_error_code() { return $this->code; } + public function get_error_message() { return $this->message; } + public function get_error_data() { return $this->data; } + } +} + +if (!function_exists('is_wp_error')) { + function is_wp_error($thing): bool { + return $thing instanceof WP_Error; + } +} + +/** + * Test-controllable uploads basedir. Tests set $GLOBALS['__test_upload_basedir']. + */ +if (!function_exists('wp_upload_dir')) { + function wp_upload_dir(): array { + $base = $GLOBALS['__test_upload_basedir'] ?? sys_get_temp_dir(); + return [ + 'basedir' => $base, + 'path' => $base, + 'url' => 'http://example.test/uploads', + ]; + } +} + +if (!function_exists('wp_check_filetype')) { + function wp_check_filetype(string $filename): array { + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + $map = [ + 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', + 'png' => 'image/png', 'gif' => 'image/gif', + 'webp' => 'image/webp', 'svg' => 'image/svg+xml', + 'avif' => 'image/avif', 'php' => false, 'txt' => false, + ]; + $type = $map[$ext] ?? false; + return ['ext' => $type ? $ext : false, 'type' => $type ?: null]; + } +} From b18bcea93d6ae07ad147a5b0cc3044d044d4ce73 Mon Sep 17 00:00:00 2001 From: Marcello Alarcon Date: Tue, 9 Jun 2026 17:29:02 -0300 Subject: [PATCH 4/9] docs: fork attribution, CHANGELOG, IMPROVEMENTS, skill sync (v1.4.0) - README: "Fork of bvisible/elementor-mcp-api" section + highlights; new endpoints (/page/{id}/restore, /kit/globals, /health); Security + Testing sections; license attribution note; corrected abilities count. - CHANGELOG.md: full 1.4.0 entry (security/added/fixed/needs-validation). - IMPROVEMENTS.md: deep understanding, prioritized plan, what was implemented, what needs a live WP to validate, deferred work. - claude-skill/skill.md: __globals__ design-system-first guidance, restore-to- undo note, new endpoints in the reference table. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 58 +++++++++++++++++ IMPROVEMENTS.md | 143 ++++++++++++++++++++++++++++++++++++++++++ README.md | 44 ++++++++++++- claude-skill/skill.md | 34 ++++++++++ 4 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 IMPROVEMENTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c91f862 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to this fork of [bvisible/elementor-mcp-api](https://github.com/bvisible/elementor-mcp-api) are documented here. +Format loosely follows [Keep a Changelog](https://keepachangelog.com/); versions are the plugin header version. + +## [1.4.0] — 2026-06-09 + +First release of the `mt-alarcon` fork. Focus: hardening the plugin for unattended, +AI-driven writes to live WordPress sites, and closing gaps between the REST and MCP +surfaces. Forked from upstream 1.3.0. + +### Security +- **Media import path guard (LFI fix).** `import_media` / `build-page` previously + `copy()`-ed any server path into the media library — a path-traversal / Local File + Inclusion vector. Paths are now canonicalized and must resolve inside + `wp_upload_dir()`; remote URLs and non-image MIME types are rejected. + (`Validator::resolve_media_path`) +- **Element-tree validation.** Every full-page write (`update_page`, `create_page`, + `build_page`, `add_element`, and their MCP twins) is structurally validated and + bounded — max depth 30, max 5000 elements, valid `elType`, `widgetType` required on + widgets, array-typed `settings`/`elements` — before it replaces `_elementor_data`. + Malformed payloads return a 400 instead of bricking the page. + (`Validator::validate_tree`) +- **Request-body element-id validation.** Element ids arriving in bodies + (`add_element`, `patch-bulk`) are validated; URL routes were already constrained. + +### Added +- **Save snapshot + rollback.** Each save snapshots the prior `_elementor_data` to a + backup meta slot. New `POST /page/{id}/restore` endpoint and `restore-page` ability + revert one level — bad writes are now undoable. +- **`GET /kit/globals`** + `get-kit-globals` ability — the active Kit's global colors + and fonts in a flat, agent-friendly shape, so generated widgets can reference + Elementor's `__globals__` (e.g. `globals/colors?id=primary`) instead of hardcoding + inline hex. (Design-system-first generation — the #1 professional-quality lever.) +- **`GET /health`** — public, unauthenticated probe reporting plugin/Elementor + versions and active state. Lets a client confirm the plugin is installed without + edit credentials. +- **MCP parity abilities** — `find-elements`, `patch-elements-bulk`, `restore-page`, + `get-kit-globals` (the REST surface had these since 1.3.0; the MCP surface lagged). +- **Dependency-free test harness** — `php tests/run.php` (41 assertions, no composer / + PHPUnit) over the pure-logic surface. + +### Fixed +- **`add-element` ability crash** on a parent container with no `elements` key + (undefined-index). The REST twin already guarded this; the ability now matches. +- **Media dedup correctness.** `import_image` deduped by *title* only — two different + images sharing a title collapsed into one. Now dedups by SHA-1 content hash and uses + `wp_unique_filename()` to avoid clobbering existing uploads. +- **PHP 7.4 floor.** Widget discovery used `str_starts_with()` (PHP 8.0+) while the + header declares `Requires PHP: 7.4`. Added a polyfill so the declared floor is real. + +### Notes / needs validation on a real install +- The validation/snapshot/health/globals/parity changes are statically verified + (lint + unit tests) but the runtime paths (data save, kit reads, REST/MCP wiring) + require a live WordPress + Elementor to confirm end-to-end. + +## [1.3.0] — upstream (bvisible) +- Bulk patch, column-width helper, and find endpoints. See upstream history. diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..ce22cb4 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,143 @@ +# IMPROVEMENTS — fork engineering notes + +Deep-work record for the `mt-alarcon/elementor-mcp-api` fork (GPL-3.0, forked from +[bvisible/elementor-mcp-api](https://github.com/bvisible/elementor-mcp-api) at 1.3.0). +This documents what the plugin *is*, what was *changed in 1.4.0*, what's *deferred*, and +what *needs a live WordPress* to validate. + +--- + +## 1. Understanding — what the plugin is and how it works + +A WordPress plugin (no build step, plain PHP) that exposes an Elementor page-building API +two ways from the same core: + +- **REST** under `neoservice/v1` (`class-rest-controller.php`). +- **MCP abilities** under the `neoservice-elementor` category (`class-abilities-provider.php`), + registered only when the WordPress Abilities API + MCP Adapter plugins are present. + +### Boot flow (`neoservice-elementor-api.php`) +1. Guards on `ABSPATH`, defines version/path constants. +2. Loads `includes/` classes. +3. On `rest_api_init` (only if `elementor/loaded` fired) → registers REST routes. +4. On the Abilities API hooks (only if that plugin is active) → registers the category + + abilities. +5. Registers `_elementor_data` post-meta with `show_in_rest` and an `edit_posts` auth callback. + +### The three core classes +- **`Element_Factory`** — pure builders that emit well-formed Elementor element JSON: + `container`/`row`/`column`/`widget` primitives, ~12 widget shortcuts (heading, text, + image, button, divider, spacer, icon, social-icons, nav-menu, form), and two composites + (`hero`, `content_row`). Also `generate_id()` (8-char hex) and `reassign_ids()` (for + duplication). The composites carry opinionated brand defaults (specific fonts/colors). +- **`Elementor_Data`** — the read/write engine over `_elementor_data`. Dual-path: native + Elementor document API when present (regenerates CSS), direct post-meta fallback + otherwise. Tree ops (find/insert/remove/duplicate/update-settings), structure summary, + templates (theme-builder), kit read/write, media import, widget discovery + (`list/schema/defaults`, schema is read live from the widget — not hardcoded), CSS flush. +- **`REST_Controller`** / **`Abilities_Provider`** — thin HTTP/MCP wrappers over the above. + +### Endpoint / ability inventory (pre-fork) +Pages (list/get/structure/update/create/build), elements (get/add/update/remove/duplicate/ +move), bulk (`patch-bulk`, `column-width`, `find`), `section`, templates (list/create), +kit (get/update), widgets (list/schema/defaults), media import, flush-css. Permissions: +reads gate on `read`, writes on `edit_posts`. + +### The gaps found in the read (what this fork addresses) +1. **Write API with thin safety.** No structural validation of the element tree before it + replaces a page; no snapshot/rollback; recursion has no depth/size bound. +2. **Media import LFI.** `import_image` `copy()`-ed *any* server path — a path-traversal / + Local File Inclusion vector. Dedup was title-only (different images, same title → + collapsed into one). +3. **REST↔MCP drift.** The 1.3.0 bulk/find/section additions landed on REST only; the MCP + ability surface lagged. Also a real bug: the `add-element` *ability* inserted into a + parent's `elements` without ensuring the key existed (the REST twin guarded it). +4. **Design-system exposure.** Nothing surfaced the Kit's global colors/fonts for + `__globals__` referencing — the #1 lever for *professional*, brand-consistent output. +5. **PHP floor mismatch.** Header says `Requires PHP: 7.4`, code used `str_starts_with()` + (PHP 8.0+). +6. **No tests, no health probe.** + +--- + +## 2. Plan — prioritized + +| Pri | Area | Item | Status | +|-----|------|------|--------| +| P0 | Security | Media path traversal/LFI guard | ✅ done | +| P0 | Security | Element-tree validation (shape + depth + size) on all writes | ✅ done | +| P0 | Safety | Save snapshot + `restore` (undo a bad write) | ✅ done | +| P1 | Correctness | Media dedup by content hash; unique destination filename | ✅ done | +| P1 | Correctness | Fix `add-element` ability undefined-`elements` crash | ✅ done | +| P1 | Parity | Bring MCP abilities to REST parity (find/bulk/restore/globals) | ✅ done | +| P1 | Quality | `GET /kit/globals` for `__globals__` design-system referencing | ✅ done | +| P2 | Compat | PHP 7.4 `str_starts_with` polyfill | ✅ done | +| P2 | Ops | Public `GET /health` probe | ✅ done | +| P2 | Ops | Dependency-free PHP test harness | ✅ done | +| P2 | Docs | README attribution + CHANGELOG + skill.md sync + version bump | ✅ done | +| — | Deferred | Mapper-side `__globals__` / responsive overrides | n/a (Python client) | +| — | Deferred | Per-page write lock (PATCH race is documented, not enforced) | deferred | +| — | Deferred | Multi-level undo history (only one backup slot today) | deferred | + +--- + +## 3. What was implemented (1.4.0) + +New file **`includes/class-validator.php`**: +- `validate_tree()` — structural + bounded (depth 30, 5000 elements) validation of any + element tree before it replaces `_elementor_data`. +- `resolve_media_path()` — canonicalize + confirm-inside-uploads + image-MIME-only; + rejects `../`, absolute escapes, remote URLs, non-images. +- `is_valid_element_id()` — for ids arriving in request bodies. + +**`class-elementor-data.php`** — `save_page_data()` snapshots prior state; new +`restore_backup()`; `get_kit_globals()`; `import_image()` now content-hash dedups and uses +`wp_unique_filename()`. + +**`class-rest-controller.php`** — validation wired into `update_page`/`create_page`/ +`build_page`/`add_element`; id checks in `patch-bulk`; resolved paths in `import_media`/ +`build_page`; new routes `POST /page/{id}/restore`, `GET /kit/globals`, `GET /health`; +uniform `error_response()`. + +**`class-abilities-provider.php`** — fixed the `add-element` crash; added `find-elements`, +`patch-elements-bulk`, `restore-page`, `get-kit-globals`; reused the Validator across write +abilities. + +**`neoservice-elementor-api.php`** — loads Validator; `str_starts_with` polyfill; version → 1.4.0. + +**`tests/`** — `run.php` + `wp-stubs.php` + 2 suites, 41 assertions, runs on bare PHP. + +Verification done locally: `php -l` clean on all 10 PHP files; `php tests/run.php` → 41/41. + +--- + +## 4. What needs a live WordPress + Elementor to validate (post-install) + +The static layer (lint + pure-logic unit tests) is green, but these runtime paths cannot be +exercised without a real install and **must** be checked on the pilot site: + +1. **Save + restore round-trip** — write a page, confirm the backup meta is set, call + `/page/{id}/restore`, confirm the page reverts and still opens cleanly in the Elementor editor. +2. **Tree validation in situ** — a deliberately malformed payload returns 400 (not a 500 / + white screen); a valid payload still saves and renders. +3. **Media path guard** — an in-uploads image imports; an out-of-uploads path and a `../` + path are rejected; content-hash dedup returns the same attachment on re-import. +4. **`/kit/globals`** — returns the real Kit colors/fonts, and a widget using the resulting + `__globals__` references renders with the global value (the single most important + professional-quality check). +5. **`/health`** — reachable unauthenticated and reports correct versions / active state. +6. **MCP parity** — the four new abilities register and execute through the MCP Adapter. +7. **PHP 7.4** — confirm the polyfill path on an actual 7.4 runtime (CI matrix or a 7.4 box). + +--- + +## 5. Deferred / future work +- **Per-page write lock.** The PATCH race is *documented* but not enforced; a transient + lock around `save_page_data` would make concurrent writes safe by construction. +- **Multi-level undo.** One backup slot today; a small ring buffer would allow deeper undo. +- **Mapper-side `__globals__` + responsive overrides.** These live in the *Python client* + that consumes this plugin (`int-elementor-design-to-page`), not in the plugin — the + plugin now *exposes* the globals (`/kit/globals`); the client decides when to reference them. +- **Upstream PRs.** The LFI guard, the `add-element` crash fix, the tree validation, and the + PHP 7.4 polyfill are generic, non-fork-specific bug/security fixes — good upstream PR + candidates to `bvisible/elementor-mcp-api`. diff --git a/README.md b/README.md index 1836d70..144d619 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,14 @@ WordPress plugin that exposes a REST API + MCP (Model Context Protocol) abilitie Build, edit, and manage Elementor pages programmatically — designed to be used by AI agents (Claude, GPT, etc.) or any HTTP client. +> **Fork of [bvisible/elementor-mcp-api](https://github.com/bvisible/elementor-mcp-api)** (GPL-3.0). +> This fork hardens the plugin for unattended AI-driven writes and closes gaps between the REST and MCP surfaces. See [CHANGELOG.md](CHANGELOG.md) for the full list. Highlights since the upstream 1.3.0 base: +> - **Security:** input/tree validation on every write, a media-import path guard (closes a path-traversal / LFI vector), and request-body element-id validation. +> - **Safety:** every page save now snapshots the prior state — a bad write is undoable via `POST /page/{id}/restore`. +> - **MCP parity:** the Abilities surface now matches REST (adds `find-elements`, `patch-elements-bulk`, `restore-page`, `get-kit-globals`) and fixes an `add-element` crash on childless parents. +> - **Design system:** `GET /kit/globals` exposes the Kit's global colors/fonts so generated pages can reference `__globals__` instead of hardcoding inline hex. +> - **Ops:** public `GET /health` probe; a dependency-free PHP test harness (`php tests/run.php`). + ## Features - **Full CRUD** on Elementor pages, elements, and templates @@ -42,8 +50,9 @@ Base URL: `https://your-site.com/wp-json/neoservice/v1` | GET | `/pages` | List all Elementor pages | | GET | `/page/{id}` | Full page data (elements tree) | | GET | `/page/{id}/structure` | Lightweight structure (IDs, types, hints) | -| PUT | `/page/{id}` | Replace all page data | +| PUT | `/page/{id}` | Replace all page data (validated) | | POST | `/page` | Create a new page | +| POST | `/page/{id}/restore` | Roll back the last save (one level) | | POST | `/build-page` | Create or update a full page | ### Elements @@ -70,6 +79,7 @@ Base URL: `https://your-site.com/wp-json/neoservice/v1` |--------|----------|-------------| | GET | `/kit` | Get global kit settings | | PUT | `/kit` | Update global kit settings | +| GET | `/kit/globals` | Global colors + fonts (flat, for `__globals__`) | ### Widgets @@ -85,6 +95,12 @@ Base URL: `https://your-site.com/wp-json/neoservice/v1` |--------|----------|-------------| | POST | `/flush-css` | Flush Elementor CSS cache | +### Health + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/health` | Public probe: plugin/Elementor versions + active state (no auth) | + ## Quick Start ```bash @@ -112,7 +128,7 @@ This plugin can expose its capabilities via the Model Context Protocol for direc 1. Install [WordPress Abilities API](https://github.com/bvisible/wordpress-abilities-api) 2. Install [WordPress MCP Adapter](https://github.com/bvisible/wordpress-mcp-adapter) -3. The plugin auto-registers 20 abilities — no configuration needed +3. The plugin auto-registers its abilities — no configuration needed. This fork keeps the MCP surface at parity with REST (adds `find-elements`, `patch-elements-bulk`, `restore-page`, `get-kit-globals`). MCP endpoint: `https://your-site.com/wp-json/mcp/mcp-adapter-default-server` @@ -143,7 +159,31 @@ This copies the skill to `~/.claude/skills/elementor-builder/`. Restart Claude C - **Flush CSS**: Always call `/flush-css` after visual changes — Elementor caches CSS aggressively. - **Element IDs**: Always provide valid 8-character hex IDs when creating elements. - **PATCH merges settings**: Only send the settings you want to change, not the full settings object. +- **Saves are snapshotted**: every write keeps a one-level backup of the prior `_elementor_data`. Undo a bad write with `POST /page/{id}/restore` (or the `restore-page` ability). + +## Security + +This is a **write-capable API driven by AI agents**, so it ships with guard rails: + +- **Permissions** — reads require the `read` capability, writes require `edit_posts`. Authenticate with WordPress Application Passwords. +- **Tree validation** — every full-page write (`update_page`, `create_page`, `build_page`, `add_element` and their MCP twins) is structurally validated and bounded (max depth 30, max 5000 elements) before it touches the database. Malformed payloads are rejected with a clear error instead of bricking a page. +- **Media path guard** — media import resolves and confirms the source path is inside the WordPress uploads directory and is a real image. Path traversal (`../`), absolute paths outside uploads, remote URLs, and non-image MIME types are rejected. Stage assets in the uploads directory before importing. +- **Element-id validation** — element ids arriving in request bodies are validated (URL routes were already pattern-constrained). + +Keep credentials least-privileged: a dedicated editor account is preferable to an administrator. + +## Testing + +A dependency-free PHP test harness covers the plugin's pure-logic surface (validation, media-path guard, element factory): + +```bash +php tests/run.php +``` + +Exit code 0 = all pass. No composer or PHPUnit required. Runtime behaviour that needs a live WordPress + Elementor (data save, kit reads, REST wiring) must be verified on a real install. ## License GPL-3.0 — see [LICENSE](LICENSE) + +This fork preserves the original GPL-3.0 license and attribution to **[bvisible/elementor-mcp-api](https://github.com/bvisible/elementor-mcp-api)**. Improvements in this fork are likewise GPL-3.0. diff --git a/claude-skill/skill.md b/claude-skill/skill.md index 6a459e6..0208f6e 100644 --- a/claude-skill/skill.md +++ b/claude-skill/skill.md @@ -126,6 +126,9 @@ Never skip verification — the API may succeed but CSS may cache old values. | POST | `/template` | Create template | | GET | `/kit` | Get global kit settings | | PUT | `/kit` | Update global kit settings | +| GET | `/kit/globals` | Global colors + fonts (flat) for `__globals__` references | +| POST | `/page/{id}/restore` | Roll back the last save (one level) | +| GET | `/health` | Public probe: plugin/Elementor versions + active state (no auth) | | GET | `/widgets` | List all available widgets | | GET | `/widget/{name}/schema` | Widget control schema | | GET | `/widget/{name}/defaults` | Ready-to-use element JSON with defaults | @@ -283,6 +286,37 @@ Hero + two-column (info left with icon-list, form right) + Google Maps on accent ## Design Best Practices (Learned from Experience) +### Design System First — use `__globals__`, not inline hex (MOST IMPORTANT) +A *professional* page references the site's global colors and fonts so the whole site +stays consistent and a rebrand is one edit, not 200. Read the globals first: +```bash +curl -s -u "$AUTH" "$API/kit/globals" | python3 -m json.tool +# → {"colors":[{"_id":"primary","title":"Primary","color":"#..."}], +# "typography":[{"_id":"primary","title":"Primary","family":"Inter","weight":"600"}]} +``` +Then reference them from a widget via the `__globals__` object instead of hardcoding hex: +```json +{ + "elType": "widget", "widgetType": "heading", + "settings": { + "title": "Section Title", + "__globals__": { + "title_color": "globals/colors?id=primary", + "typography_typography": "globals/typography?id=primary" + } + } +} +``` +Elementor resolves `globals/colors?id=` and `globals/typography?id=` from the Kit +at render time. Prefer this over inline `title_color: "#..."` for anything that should +track the brand. Inline hex is fine only for one-off, intentionally-off-brand accents. + +### Undo a bad write +Every save snapshots the previous page. If a change looks wrong, roll back one level: +```bash +curl -s -X POST -u "$AUTH" "$API/page/{PAGE_ID}/restore" +``` + ### Background Colors - **Use strictly 2 background colors** for content sections: white + one accent (e.g., cream, light grey). More than 2 creates an ugly rainbow ("arc-en-ciel") effect. - Hero and contact sections can use a dark color as a third distinct zone. From 7b82c22ac637721b926b9690c08ef44077fdc3f7 Mon Sep 17 00:00:00 2001 From: Marcello Alarcon Date: Tue, 9 Jun 2026 17:41:28 -0300 Subject: [PATCH 5/9] fix(security): reject SVG on media import (stored XSS vector) MUST-FIX-1 from the independent security audit. SVG is XML that can carry '); + $res = Validator::resolve_media_path($svg); + $t->true(is_wp_error($res) && $res->get_error_code() === 'unsupported_media_type', 'SVG in uploads rejected (XSS)'); + @unlink($svg); + // missing path $res = Validator::resolve_media_path(''); $t->true(is_wp_error($res) && $res->get_error_code() === 'missing_path', 'empty path rejected'); From 108252bc9022137982d4a6977e8ccc1c59d42c82 Mon Sep 17 00:00:00 2001 From: Marcello Alarcon Date: Tue, 9 Jun 2026 17:50:44 -0300 Subject: [PATCH 6/9] feat(security): payload-size ceiling on write requests (SHOULD-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Validator::check_body_size() — rejects a raw write-request body over ~4 MB (MAX_BODY_BYTES) with a 413 before it is JSON-decoded, so a runaway/malicious payload is never parsed into memory. Wired into the REST writes in a follow-up commit; this commit lands the pure-logic guard + 4 assertions (empty, 1KB, exact limit, over-limit). Co-Authored-By: Claude Opus 4.8 (1M context) --- includes/class-validator.php | 21 +++++++++++++++++++++ tests/test-validator.php | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/includes/class-validator.php b/includes/class-validator.php index 0ab5c2e..1b1e1ae 100644 --- a/includes/class-validator.php +++ b/includes/class-validator.php @@ -22,9 +22,30 @@ class Validator { /** Maximum total number of elements in a single tree (DoS guard). */ const MAX_ELEMENTS = 5000; + /** Maximum raw write-request body size in bytes (DoS guard, ~4 MB). */ + const MAX_BODY_BYTES = 4194304; + /** Allowed top-level element types. */ const ALLOWED_ELTYPES = ['container', 'section', 'column', 'widget']; + /** + * Reject an oversized raw request body before it is JSON-decoded (a malicious or + * runaway payload should not be parsed into memory at all). + * + * @param string $raw_body The raw request body. + * @return true|\WP_Error + */ + public static function check_body_size(string $raw_body) { + if (strlen($raw_body) > self::MAX_BODY_BYTES) { + return new \WP_Error( + 'payload_too_large', + sprintf('Request body exceeds the %d-byte limit.', self::MAX_BODY_BYTES), + ['status' => 413] + ); + } + return true; + } + /** * Validate an Elementor element tree (the array stored in `_elementor_data`). * diff --git a/tests/test-validator.php b/tests/test-validator.php index ba27e42..0d969a7 100644 --- a/tests/test-validator.php +++ b/tests/test-validator.php @@ -70,6 +70,13 @@ $r = Validator::validate_tree($many); $t->true(is_wp_error($r) && $r->get_error_code() === 'tree_too_large', 'oversized tree rejected'); + // ── check_body_size (payload ceiling, SHOULD #6) ───────── + $t->true(Validator::check_body_size('') === true, 'empty body within limit'); + $t->true(Validator::check_body_size(str_repeat('x', 1024)) === true, '1KB body within limit'); + $t->true(Validator::check_body_size(str_repeat('x', Validator::MAX_BODY_BYTES)) === true, 'body at exact limit accepted'); + $r = Validator::check_body_size(str_repeat('x', Validator::MAX_BODY_BYTES + 1)); + $t->true(is_wp_error($r) && $r->get_error_code() === 'payload_too_large', 'over-limit body rejected'); + // ── is_valid_element_id ────────────────────────────────── $t->true(Validator::is_valid_element_id('f8703b57'), 'hex id accepted'); $t->true(Validator::is_valid_element_id('ABC123'), 'mixed-case alnum id accepted'); From 6c134971c869a6594ed2b4ca41b02362cc9458d9 Mon Sep 17 00:00:00 2001 From: Marcello Alarcon Date: Tue, 9 Jun 2026 17:51:01 -0300 Subject: [PATCH 7/9] feat(security): tighten capabilities, per-post + per-surface (MUST-FIX-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write API was gated only on the blanket edit_posts, leaving an Author able to rewrite any page and to touch site-global config. Harden across BOTH the REST and MCP surfaces (closing the same REST↔MCP drift class as commit d90bb98): (a) Per-post REST checks — check_edit_permission()/check_read_permission() now do current_user_can('edit_post'/'read_post', $id) when the route carries an id; create routes (no id) require edit_pages. build_page enforces edit_post on the body-supplied page_id when updating an existing page. (b) MCP per-post checks INSIDE each execute_callback — an Ability's permission_callback never sees the input, so every write ability now calls guard_edit_post($post_id) (and reads call guard_read_post) after resolving the id, returning 403 otherwise. Without this, closing only REST left the MCP door open. (c) Site-global writes → manage_options — update_kit / create_template (REST routes moved to an $admin permission group; abilities call guard_admin()) and the register_post_meta auth_callback for _elementor_data. These are whole-site config, not per-page content. (d) Raw-HTML gate — the direct-meta fallback in save_page_data() (which bypasses Elementor's own save pipeline and can persist inline

ok

'; + + $make_tree = function () use ($payload): array { + return [[ + 'id' => 'aaaa1111', 'elType' => 'container', 'settings' => [], + 'elements' => [ + ['id' => 'bbbb2222', 'elType' => 'widget', 'widgetType' => 'text-editor', + 'settings' => ['editor' => $payload]], + ['id' => 'cccc3333', 'elType' => 'widget', 'widgetType' => 'html', + 'settings' => ['html' => $payload]], + // Repeater-style nesting: items carry their own settings arrays. + ['id' => 'dddd4444', 'elType' => 'widget', 'widgetType' => 'icon-list', + 'settings' => ['icon_list' => [['_id' => 'e5f6a7b8', 'text' => $payload]]]], + // v1.4.3: tab_content rides the `tabs` repeater (tabs/accordion/toggle). + ['id' => 'eeee5555', 'elType' => 'widget', 'widgetType' => 'accordion', + 'settings' => ['tabs' => [['_id' => 'f1a2b3c4', 'tab_title' => 'T1', + 'tab_content' => $payload]]]], + // v1.4.3: alert widget carries HTML in alert_description. + ['id' => 'ffff6666', 'elType' => 'widget', 'widgetType' => 'alert', + 'settings' => ['alert_title' => 'Heads up', 'alert_description' => $payload]], + ], + ]]; + }; + + $saved_editor = function (int $post_id, int $child, string $key): string { + $raw = $GLOBALS['__test_meta'][$post_id]['_elementor_data'] ?? ''; + $tree = is_string($raw) ? json_decode($raw, true) : $raw; + $val = $tree[0]['elements'][$child]['settings'][$key] ?? null; + if (is_array($val)) { // repeater + $val = $val[0]['text'] ?? ''; + } + return (string) $val; + }; + + // ── (a) caller WITHOUT unfiltered_html → script stripped, benign HTML kept ── + $GLOBALS['__test_meta'] = []; + $GLOBALS['__test_caps'] = ['unfiltered_html' => false]; + + $ok = Elementor_Data::save_page_data(11, $make_tree()); + $t->true($ok === true, 'kses gate: save succeeds for caller without unfiltered_html (sanitize, not block)'); + + $editor = $saved_editor(11, 0, 'editor'); + $t->true(stripos($editor, ' stripped from text-editor `editor`'); + $t->true(stripos($editor, 'onclick') === false, 'kses gate: on* event handler stripped from `editor`'); + $t->true(strpos($editor, 'true(stripos($html, ' stripped from HTML widget `html`'); + + $repeater = $saved_editor(11, 2, 'icon_list'); + $t->true(stripos($repeater, ' stripped inside repeater item `text`'); + + // v1.4.3 allowlist additions: tab_content (tabs repeater) + alert_description. + $tree11 = json_decode($GLOBALS['__test_meta'][11]['_elementor_data'], true); + $tab_content = (string) ($tree11[0]['elements'][3]['settings']['tabs'][0]['tab_content'] ?? ''); + $t->true(stripos($tab_content, ' stripped from accordion `tab_content`'); + $t->true(stripos($tab_content, 'onclick') === false, + 'kses gate v1.4.3: on* handler stripped from `tab_content`'); + $t->true(strpos($tab_content, 'ok') !== false, + 'kses gate v1.4.3: benign text preserved in `tab_content`'); + $alert_desc = (string) ($tree11[0]['elements'][4]['settings']['alert_description'] ?? ''); + $t->true(stripos($alert_desc, ' stripped from alert `alert_description`'); + + // Non-HTML settings keys are untouched. + $raw = json_decode($GLOBALS['__test_meta'][11]['_elementor_data'], true); + $t->true(($raw[0]['elements'][0]['widgetType'] ?? '') === 'text-editor', + 'kses gate: structural fields (widgetType) untouched'); + + // ── (b) caller WITH unfiltered_html → content preserved byte-identical ── + $GLOBALS['__test_meta'] = []; + $GLOBALS['__test_caps'] = ['unfiltered_html' => true]; + + $ok = Elementor_Data::save_page_data(12, $make_tree()); + $t->true($ok === true, 'kses gate: admin save succeeds'); + $t->true($saved_editor(12, 0, 'editor') === $payload, + 'kses gate: unfiltered_html caller keeps `editor` content intact (script preserved)'); + $t->true($saved_editor(12, 1, 'html') === $payload, + 'kses gate: unfiltered_html caller keeps `html` content intact'); + $tree12 = json_decode($GLOBALS['__test_meta'][12]['_elementor_data'], true); + $t->true(($tree12[0]['elements'][3]['settings']['tabs'][0]['tab_content'] ?? null) === $payload, + 'kses gate v1.4.3: unfiltered_html caller keeps `tab_content` byte-identical'); + + // ── kses_widget_html helper: pure-function behaviour ── + $sanitized = Elementor_Data::kses_widget_html($make_tree()); + $t->true(stripos(json_encode($sanitized), ' survives anywhere in the tree'); + $t->true(($sanitized[0]['id'] ?? '') === 'aaaa1111' && ($sanitized[0]['elType'] ?? '') === 'container', + 'kses_widget_html: tree structure (ids, elType, nesting) preserved'); + + $GLOBALS['__test_meta'] = []; + $GLOBALS['__test_caps'] = []; +}; diff --git a/tests/test-rest-security.php b/tests/test-rest-security.php new file mode 100644 index 0000000..f4f7c0d --- /dev/null +++ b/tests/test-rest-security.php @@ -0,0 +1,121 @@ + 'aaaa1111', 'elType' => 'container', 'elements' => []]; + $cursor =& $deep; + for ($i = 0; $i < Validator::MAX_DEPTH + 5; $i++) { + $cursor['elements'] = [['id' => 'bbbb2222', 'elType' => 'container', 'elements' => []]]; + $cursor =& $cursor['elements'][0]; + } + unset($cursor); + $res = $controller->add_section(new WP_REST_Request(['id' => 1], ['section' => $deep])); + $t->true($res->get_status() === 400, 'add_section: tree deeper than MAX_DEPTH rejected with 400'); + $t->true(($res->get_data()['code'] ?? '') === 'tree_too_deep', 'add_section: deep tree error code is tree_too_deep'); + + // ── Fix 1: add_section — element-count ceiling ─────────── + $reset(); + $children = []; + for ($i = 0; $i <= Validator::MAX_ELEMENTS; $i++) { + $children[] = ['id' => 'cccc3333', 'elType' => 'widget', 'widgetType' => 'heading']; + } + $fat = ['id' => 'dddd4444', 'elType' => 'container', 'elements' => $children]; + $res = $controller->add_section(new WP_REST_Request(['id' => 1], ['section' => $fat])); + $t->true($res->get_status() === 400, 'add_section: tree above MAX_ELEMENTS rejected with 400'); + $t->true(($res->get_data()['code'] ?? '') === 'tree_too_large', 'add_section: fat tree error code is tree_too_large'); + + // ── Fix 1: add_section — invalid elType now caught ─────── + $reset(); + $res = $controller->add_section(new WP_REST_Request(['id' => 1], ['section' => ['id' => 'eeee5555', 'elType' => 'bogus']])); + $t->true($res->get_status() === 400 && ($res->get_data()['code'] ?? '') === 'invalid_eltype', + 'add_section: invalid elType rejected (validate_tree now runs)'); + + // ── Fix 1: add_section — raw-body ceiling ──────────────── + $reset(); + $res = $controller->add_section(new WP_REST_Request( + ['id' => 1], + ['section' => ['id' => 'ffff6666', 'elType' => 'container']], + $oversized_body + )); + $t->true($res->get_status() === 413, 'add_section: body over MAX_BODY_BYTES rejected with 413'); + $t->true(($res->get_data()['code'] ?? '') === 'payload_too_large', 'add_section: oversized body error code is payload_too_large'); + + // ── Fix 1: positive control — valid section still inserts (201) ── + $reset(); + $res = $controller->add_section(new WP_REST_Request( + ['id' => 1], + ['section' => ['id' => 'abcd1234', 'elType' => 'container', 'elements' => []]] + )); + $t->true($res->get_status() === 201 && ($res->get_data()['success'] ?? false) === true, + 'add_section: well-formed section still accepted (no regression)'); + + // ── Fix 2: oversized raw body on the 3 previously-unguarded REST writes ── + $page_tree = [['id' => 'ab12cd34', 'elType' => 'container', 'settings' => [], 'elements' => []]]; + + $reset(); + $GLOBALS['__test_meta'][1]['_elementor_data'] = json_encode($page_tree); + $res = $controller->update_element(new WP_REST_Request( + ['id' => 1, 'element_id' => 'ab12cd34'], + ['settings' => ['title' => 'x']], + $oversized_body + )); + $t->true($res->get_status() === 413 && ($res->get_data()['code'] ?? '') === 'payload_too_large', + 'update_element: oversized body rejected with 413 payload_too_large'); + + $reset(); + $GLOBALS['__test_meta'][1]['_elementor_data'] = json_encode($page_tree); + $res = $controller->move_element(new WP_REST_Request( + ['id' => 1, 'element_id' => 'ab12cd34'], + ['position' => 0], + $oversized_body + )); + $t->true($res->get_status() === 413 && ($res->get_data()['code'] ?? '') === 'payload_too_large', + 'move_element: oversized body rejected with 413 payload_too_large'); + + $reset(); + $GLOBALS['__test_meta'][1]['_elementor_data'] = json_encode($page_tree); + $res = $controller->set_column_width(new WP_REST_Request( + ['id' => 1, 'element_id' => 'ab12cd34'], + ['percent' => 50], + $oversized_body + )); + $t->true($res->get_status() === 413 && ($res->get_data()['code'] ?? '') === 'payload_too_large', + 'set_column_width: oversized body rejected with 413 payload_too_large'); + + // ── Fix 2: positive control — normal-sized update still works ── + $reset(); + $GLOBALS['__test_meta'][1]['_elementor_data'] = json_encode($page_tree); + $res = $controller->set_column_width(new WP_REST_Request( + ['id' => 1, 'element_id' => 'ab12cd34'], + ['percent' => 25] + )); + $t->true($res->get_status() === 200 && ($res->get_data()['success'] ?? false) === true, + 'set_column_width: normal payload still accepted (no regression)'); + + $reset(); +}; diff --git a/tests/test-template-safety.php b/tests/test-template-safety.php new file mode 100644 index 0000000..296a78f --- /dev/null +++ b/tests/test-template-safety.php @@ -0,0 +1,137 @@ +create_template(new WP_REST_Request([], [ + 'title' => 'Header X', 'type' => 'header', 'data' => [], + ])); + $inserted = end($GLOBALS['__test_inserted_posts']); + $t->true($res->get_status() === 201, 'create_template REST: no status → 201'); + $t->true($inserted['post_status'] === 'draft', 'create_template REST: no status → post inserted as draft'); + $t->true(($res->get_data()['status'] ?? '') === 'draft', 'create_template REST: response echoes status draft'); + + // ── Fix 1 (REST): explicit publish still honored (compat) ── + $reset(); + $res = $controller->create_template(new WP_REST_Request([], [ + 'title' => 'Header Y', 'type' => 'header', 'data' => [], 'status' => 'publish', + ])); + $inserted = end($GLOBALS['__test_inserted_posts']); + $t->true($res->get_status() === 201 && $inserted['post_status'] === 'publish', + 'create_template REST: explicit status=publish still publishes (compat)'); + + // ── Fix 1 (REST): invalid status rejected, nothing inserted ── + $reset(); + $res = $controller->create_template(new WP_REST_Request([], [ + 'title' => 'Header Z', 'type' => 'header', 'data' => [], 'status' => 'pending', + ])); + $t->true($res->get_status() === 400, 'create_template REST: invalid status → 400'); + $t->true(empty($GLOBALS['__test_inserted_posts']), 'create_template REST: invalid status → no post inserted'); + + // ── Fix 1 (data layer): unknown status clamped to draft ── + $reset(); + Elementor_Data::create_template('T', 'header', [], ['include/general'], 'future'); + $inserted = end($GLOBALS['__test_inserted_posts']); + $t->true($inserted['post_status'] === 'draft', + 'Elementor_Data::create_template: unknown status clamped to draft (defense in depth)'); + + // ── Fix 1 (ability): default draft / explicit publish / invalid ── + $GLOBALS['__test_abilities'] = []; + Abilities_Provider::register(); + $create = $GLOBALS['__test_abilities']['neoservice/create-template']['execute_callback']; + + $reset(); + $res = $create(['title' => 'Footer A', 'type' => 'footer', 'data' => []]); + $inserted = end($GLOBALS['__test_inserted_posts']); + $t->true(!is_wp_error($res) && $inserted['post_status'] === 'draft', + 'create-template ability: no status → draft'); + + $reset(); + $res = $create(['title' => 'Footer B', 'type' => 'footer', 'data' => [], 'status' => 'publish']); + $inserted = end($GLOBALS['__test_inserted_posts']); + $t->true(!is_wp_error($res) && $inserted['post_status'] === 'publish', + 'create-template ability: explicit publish honored'); + + $reset(); + $res = $create(['title' => 'Footer C', 'type' => 'footer', 'data' => [], 'status' => 'private']); + $t->true(is_wp_error($res) && $res->get_error_code() === 'invalid_status', + 'create-template ability: invalid status → WP_Error invalid_status'); + $t->true(empty($GLOBALS['__test_inserted_posts']), 'create-template ability: invalid status → no post inserted'); + + $GLOBALS['__test_abilities'] = []; + + // ── Fix 3: delete_template — trash by default ──────────── + $reset(); + $GLOBALS['__test_posts'][501] = (object) ['ID' => 501, 'post_type' => 'elementor_library']; + $res = $controller->delete_template(new WP_REST_Request(['id' => 501])); + $t->true($res->get_status() === 200, 'delete_template: existing template → 200'); + $t->true(($res->get_data()['mode'] ?? '') === 'trash', 'delete_template: default mode is trash'); + $t->true($GLOBALS['__test_trashed'] === [501], 'delete_template: wp_trash_post called (restorable)'); + $t->true(empty($GLOBALS['__test_deleted']), 'delete_template: default does NOT permanently delete'); + + // ── Fix 3: force=true → permanent + conditions cleanup ─── + $reset(); + $GLOBALS['__test_posts'][502] = (object) ['ID' => 502, 'post_type' => 'elementor_library']; + $GLOBALS['__test_options']['elementor_pro_theme_builder_conditions'] = [ + 'header' => [502 => ['include/general'], 777 => ['include/general']], + ]; + $res = $controller->delete_template(new WP_REST_Request(['id' => 502], ['force' => 'true'])); + $t->true($res->get_status() === 200 && ($res->get_data()['mode'] ?? '') === 'permanent', + 'delete_template: force=true → permanent mode'); + $t->true($GLOBALS['__test_deleted'] === [['id' => 502, 'force' => true]], + 'delete_template: wp_delete_post(id, true) called on force'); + $conds = $GLOBALS['__test_options']['elementor_pro_theme_builder_conditions']; + $t->true(!isset($conds['header'][502]) && isset($conds['header'][777]), + 'delete_template: force removes ONLY this template from the conditions map'); + + // ── Fix 3: missing post → 404, nothing touched ─────────── + $reset(); + $res = $controller->delete_template(new WP_REST_Request(['id' => 999])); + $t->true($res->get_status() === 404, 'delete_template: missing post → 404'); + $t->true(empty($GLOBALS['__test_trashed']) && empty($GLOBALS['__test_deleted']), + 'delete_template: missing post → no trash/delete call'); + + // ── Fix 3: wrong post type (a real page) is NEVER touched ── + $reset(); + $GLOBALS['__test_posts'][503] = (object) ['ID' => 503, 'post_type' => 'page']; + $res = $controller->delete_template(new WP_REST_Request(['id' => 503], ['force' => 'true'])); + $t->true($res->get_status() === 404, 'delete_template: non-elementor_library post → 404'); + $t->true(empty($GLOBALS['__test_trashed']) && empty($GLOBALS['__test_deleted']), + 'delete_template: non-elementor_library post → never trashed/deleted'); + + $reset(); +}; diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index 6e6cc53..c010f9d 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -42,6 +42,181 @@ function wp_upload_dir(): array { } } +/** + * ── Runtime stubs for the security suites (REST controller, Abilities, Data) ── + * These let the v1.4.2 security fixes (validation ceilings on add_section, the + * MAX_BODY_BYTES guards, and the unfiltered_html kses gate) be exercised without + * a live WordPress. Behaviour-approximating only — real WP semantics are richer. + */ + +// The Elementor_Data fallback save path references this constant directly. +if (!defined('ELEMENTOR_VERSION')) { + define('ELEMENTOR_VERSION', '3.35.7'); +} + +if (!defined('ABSPATH')) { + define('ABSPATH', sys_get_temp_dir() . '/'); +} + +if (!class_exists('WP_REST_Request')) { + /** Minimal request double: URL params via ArrayAccess, JSON body, raw body. */ + class WP_REST_Request implements ArrayAccess { + private array $url_params; + private array $json; + private string $body; + public function __construct(array $url_params = [], array $json = [], ?string $body = null) { + $this->url_params = $url_params; + $this->json = $json; + $this->body = $body ?? (json_encode($json) ?: ''); + } + public function get_body(): string { return $this->body; } + public function get_json_params(): array { return $this->json; } + #[\ReturnTypeWillChange] + public function get_param($key) { return $this->json[$key] ?? ($this->url_params[$key] ?? null); } + #[\ReturnTypeWillChange] + public function offsetExists($offset) { return isset($this->url_params[$offset]); } + #[\ReturnTypeWillChange] + public function offsetGet($offset) { return $this->url_params[$offset] ?? null; } + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) { $this->url_params[$offset] = $value; } + #[\ReturnTypeWillChange] + public function offsetUnset($offset) { unset($this->url_params[$offset]); } + } +} + +if (!class_exists('WP_REST_Response')) { + class WP_REST_Response { + private $data; + private int $status; + public function __construct($data = null, int $status = 200) { + $this->data = $data; + $this->status = $status; + } + #[\ReturnTypeWillChange] + public function get_data() { return $this->data; } + public function get_status(): int { return $this->status; } + } +} + +/** + * Capability gate controlled per-test: $GLOBALS['__test_caps']['unfiltered_html'] = false; + * Unlisted capabilities default to GRANTED so unrelated guards stay out of the way. + */ +if (!function_exists('current_user_can')) { + function current_user_can(string $cap, ...$args): bool { + return $GLOBALS['__test_caps'][$cap] ?? true; + } +} + +/** In-memory post-meta store: $GLOBALS['__test_meta'][post_id][key] = value. */ +if (!function_exists('get_post_meta')) { + function get_post_meta(int $post_id, string $key = '', bool $single = false) { + return $GLOBALS['__test_meta'][$post_id][$key] ?? ''; + } +} +if (!function_exists('update_post_meta')) { + function update_post_meta(int $post_id, string $key, $value): bool { + $GLOBALS['__test_meta'][$post_id][$key] = $value; + return true; + } +} +if (!function_exists('delete_post_meta')) { + function delete_post_meta(int $post_id, string $key): bool { + unset($GLOBALS['__test_meta'][$post_id][$key]); + return true; + } +} + +if (!function_exists('wp_json_encode')) { + function wp_json_encode($data, int $options = 0, int $depth = 512) { + return json_encode($data, $options, $depth); + } +} +if (!function_exists('wp_slash')) { + function wp_slash($value) { return $value; } +} + +/** + * Test approximation of wp_kses_post: strips #is', '', (string) $content); + $content = preg_replace('#]*>#i', '', $content); + $content = preg_replace('#\son\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $content); + return $content; + } +} + +/** Ability registry collector: $GLOBALS['__test_abilities'][name] = definition. */ +if (!function_exists('wp_register_ability')) { + function wp_register_ability(string $name, array $definition): void { + $GLOBALS['__test_abilities'][$name] = $definition; + } +} +if (!function_exists('wp_register_ability_category')) { + function wp_register_ability_category(string $name, array $definition): void {} +} + +// Misc one-liners reached by the handlers under test. +if (!function_exists('sanitize_text_field')) { + function sanitize_text_field($str): string { return trim(strip_tags((string) $str)); } +} +if (!function_exists('sanitize_title')) { + function sanitize_title($title): string { + return strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', (string) $title), '-')); + } +} +if (!function_exists('get_permalink')) { + function get_permalink($post_id): string { return "http://example.test/?p=$post_id"; } +} +if (!function_exists('get_the_title')) { + function get_the_title($post_id): string { return "Post $post_id"; } +} +if (!function_exists('wp_insert_post')) { + /** Records each insert in $GLOBALS['__test_inserted_posts'] so tests can assert args. */ + function wp_insert_post(array $args) { + static $next_id = 1000; + $id = ++$next_id; + $GLOBALS['__test_inserted_posts'][$id] = $args; + return $id; + } +} + +/** In-memory post store for delete paths: $GLOBALS['__test_posts'][id] = (object). */ +if (!function_exists('get_post')) { + function get_post(int $post_id) { + return $GLOBALS['__test_posts'][$post_id] ?? null; + } +} +if (!function_exists('wp_trash_post')) { + function wp_trash_post(int $post_id) { + $GLOBALS['__test_trashed'][] = $post_id; + return $GLOBALS['__test_posts'][$post_id] ?? false; + } +} +if (!function_exists('wp_delete_post')) { + function wp_delete_post(int $post_id, bool $force = false) { + $GLOBALS['__test_deleted'][] = ['id' => $post_id, 'force' => $force]; + $post = $GLOBALS['__test_posts'][$post_id] ?? false; + unset($GLOBALS['__test_posts'][$post_id]); + return $post; + } +} +if (!function_exists('get_option')) { + function get_option(string $name, $default = false) { + return $GLOBALS['__test_options'][$name] ?? $default; + } +} +if (!function_exists('update_option')) { + function update_option(string $name, $value): bool { + $GLOBALS['__test_options'][$name] = $value; + return true; + } +} + if (!function_exists('wp_check_filetype')) { function wp_check_filetype(string $filename): array { $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));