diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bb56115 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,173 @@ +# 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.4] — 2026-06-10 + +Template-safety hardening (gap found in the orphan-command review). A theme-builder +template created via `POST /template` used to go in as `publish` with default conditions +`["include/general"]` — i.e. a `header`/`footer` went LIVE site-wide at creation time. + +### Security +- **`create_template` defaults to `draft` (REST + MCP ability + data layer).** Publishing + now requires an explicit `status: "publish"` in the body/input; any other value is + rejected (REST: 400, ability: `WP_Error invalid_status`). `Elementor_Data::create_template` + gained an optional `$status = 'draft'` parameter and clamps unknown values to `draft` + (defense in depth — the raw endpoint can no longer publish by omission). + +### Added +- **`DELETE /template/{id}`** (admin-only, `manage_options`) — rollback path for + `create_template`. Trash by default (restorable); `?force=true` is permanent and also + unregisters the template from the `elementor_pro_theme_builder_conditions` map (no + orphaned condition entries). 404 when the post is missing or is not an + `elementor_library` post — never touches other post types. +- `create_template` REST response now echoes the effective `status` (additive). + +### Tests +- New suite `tests/test-template-safety.php` reproducing each gap before the fix: + draft default, explicit publish, invalid status rejection (REST + ability), + delete-template trash/permanent/404/wrong-post-type, conditions-map cleanup. + Stubs gained recording `wp_insert_post` and `get_post`/`wp_trash_post`/`wp_delete_post`. + +## [1.4.3] — 2026-06-10 + +Micro-fix closing the residual LOW finding from the 2026-06-10 @vault re-audit. Additive +allowlist change only; no walk logic or endpoint signature touched. + +### Security +- **kses allowlist amplified (LOW).** `Elementor_Data::HTML_SETTING_KEYS` agora cobre + `tab_content` (tabs/accordion/toggle core), `alert_title`/`alert_description` (alert) + e `inner_text` (widgets Pro) — keys que carregam HTML e ficavam fora da sanitização + `wp_kses_post` quando o caller não tem `unfiltered_html` (vetor de stored-XSS). + Testes novos em `tests/test-kses-gate.php` cobrem `tab_content` (via repeater `tabs`) + e `alert_description`, nos dois sentidos (strip sem capability, byte-idêntico com). + +## [1.4.2] — 2026-06-10 + +Second security-audit pass (@vault). Three server-side hardening fixes — all additive +(validation/sanitization only); no endpoint or ability signature changed. + +### Security +- **Stored-XSS via dead `unfiltered_html` gate — fixed (HIGH).** The capability gate in + `Elementor_Data::save_page_data()` only existed on the direct-meta fallback, which never + runs in production (Elementor active → native save path wins), so a caller WITHOUT + `unfiltered_html` could persist `
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, ''); + $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'); + + // 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..c010f9d --- /dev/null +++ b/tests/wp-stubs.php @@ -0,0 +1,232 @@ +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', + ]; + } +} + +/** + * ── 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('#?script\b[^>]*>#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)); + $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]; + } +}