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, ' 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/test-validator.php b/tests/test-validator.php new file mode 100644 index 0000000..0d969a7 --- /dev/null +++ b/tests/test-validator.php @@ -0,0 +1,140 @@ +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'); + + // ── 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'); + $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'); + + // SVG rejected even inside uploads (XSS vector — MUST-FIX-1). + $svg = $uploads . '/icon.svg'; + file_put_contents($svg, ''); + $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('#]*>#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]; + } +}