# WP-LIT-MCP: whole-plugin security audit (S42 step 2) - **Scope:** branch etappe-2, HEAD 314b3d0. - **Environment:** WordPress 7.1.2, Divi 5.2.1 on DDEV. - **Method:** - Read all of `src/`, plus the relevant parts of `vendor/wp-media/mcp-oauth` and `vendor/wordpress/mcp-adapter`. - Ran read-only DDEV `wp eval-file` probes. - Ran one live, self-cleaning run of the real `lit/content-create` ability as a temporary Author: - user 67 and post 861 were created, then deleted; - the deletion was verified afterwards: no `lit_audit_*` user and no probe post remain. - No code was changed. The option `lit_mcp_allowed_roles` was widened in-process only, through the filter, never in the database. No Critical or High findings. The Divi value pipeline itself held. The two most relevant problems are ways *around* it: - the raw `content` field; - the tool-group switch. --- ## Findings (most severe first) ### M1 – Medium: raw `content` lets users without unfiltered_html write the "builder-only" Divi values (the S7/S26 gates don't apply) **Where** - `src/Integrations/WordPress/Content/PostWriter.php:28` (content → post_content) and `:58`. - `ContentCreateAbility.php:127-133`. - `ContentUpdateAbility.php:124` refuses `content` only when the post *already* uses Divi. Nothing refuses content that *introduces* Divi markup. - Compare `src/Integrations/Divi/Compose/ValueCleaner.php:57` (`BUILDER_ONLY`). **Scenario** 1. An Author, or an Editor on multisite or with `DISALLOW_UNFILTERED_HTML`, calls `lit/content-create` (or `lit/content-update` on a non-Divi post). 2. The `content` is Divi 5 block markup that sets: - a module's `css.desktop.value.freeForm` (e.g. `body{…}`); - `module.decoration.attributes` (custom HTML attributes). 3. kses (`filter_block_content`) and Divi's own `wp_insert_post_data` security filters keep both. Only `on*` attributes are stripped, by Divi. 4. The page is now a Divi layout. From then on every `lit-divi-*` tool works on it inside the kses window. The same values are refused with "nur im Divi-Builder" (only in the Divi builder) when sent through `lit-divi-insert`/`-update` (rulings S7/S26). A prompt-injected Claude session can therefore do CSS injection or attribute injection on the live site. This is exactly what S26 was meant to prevent. **Evidence** - Live run of `wp_get_ability('lit/content-create')->execute()` as a temporary Author (unfiltered_html=false): - `freeForm CSS stored: true`; - `data-probe stored: true`; - rendered `
`. - Read-only kses probe (`kses6.php`): `onmouseover` is removed by Divi and never rendered. No script execution found. Divi renders a `javascript:` `linkUrl` as `href=""`. **Parity note** The same user can store the same markup through core REST (`/wp/v2/posts`) or the code editor. So this is no privilege escalation beyond WordPress core. It is a bypass of the plugin's own, explicitly decided gate. The pipeline guarantee "every path from ability input to post_content goes through ValueCleaner/Composer/TreeEditor" does not hold for `content`. **Fix proposal** - In `PostWriter::prepare()` (create and update), refuse a `content` that contains ``). ### I3 – Info: smaller observations 1. **Multisite super admin bypasses the role gate.** A super admin always has `lit_mcp_use` (`WP_User::has_cap`), even if "Zugriff" allows no role. `AccessTab::currentUserLosesAccess` knows this; the UI text doesn't say it. 2. **Media usage lists unreadable posts.** `MediaUsageFinder.php:455-461` returns post ID and post type (not the title) for posts the user can't read. This lets a user learn that private posts exist. It is intentional, to block deletes. 3. **All post types with a UI are exposed.** Every `show_ui` post type, including non-public ones (on DDEV: `path`), is exposed; only core, Divi internals and `lit_mcp_excluded_post_types` are excluded. Consider documenting the filter for data-protection purposes (form submissions, personal data as CPTs). 4. **Nested REST requests can block later calls.** A `rest_do_request()` to another route during a tool call makes `RequestContext` false for the rest of that HTTP request (`RequestContext.php:281`). This fails closed, so later JSON-RPC calls in the same batch get `forbidden`. Functional only. 5. **`confirm: true` is not a human confirmation.** Claude sets it itself; under prompt injection it is no safeguard. Keep the destructive tools indirect by default. --- ## Checked and found sound - **RequestContext:** recomputed on every matched REST request, cleared after our route, and required by every ability. WP-CLI/STDIO and other routes fail closed. - **Default mcp-adapter server:** disabled; the only MCP route is `/mcp/mcp-oauth-server` (verified via `rest_get_server()->get_routes()`). - **Core Abilities REST:** our abilities have `show_in_rest=false` (only the 3 `core/*` abilities are in REST). - **Transport and JWT:** `OAuthHttpTransport` requires a Bearer JWT (HS256 pinned, exp, aud = our route, iss = `home_url()`, app password must still exist). The current user is set only from the JWT; cookie or Basic auth can't reach the tools. - **Revocation:** - AccessTab revoke deletes the app password, which the next request's check honours; - refresh-token rotation and reuse detection live in mcp-oauth; - revoke is limited to `edit_user`-able users and, on multisite, to site members. - **ConnectionUsage:** records only after a <400 response on our route, and only for `sub === current user`. - **`lit_mcp_use` gate:** dynamic cap via `user_has_cap` (no role DB changes); the option is sanitized to existing roles; the filter output is type-checked; the meta-tools' capability filters are set to `lit_mcp_use`. - **Exposure:** - disabled and "nur lesen" abilities are never registered, so `execute-ability` answers "not found"; - DIRECT_ONLY abilities are `mcp.public=false`, so `execute-ability` refuses them; - `lit/guide` is always direct. - **Annotations:** every tool annotated `readonly` really doesn't write (render-summary runs `the_content` side effects only). - **Per-object checks:** - `PostGuard::editable` (`edit_post`, managed type, trash rules) on every content, status, revision, render and Divi ability, in both permission and execute; - revisions are checked via the parent and `post_type === 'revision'`; - attachments via `MediaGuard` (`edit_post`/`delete_post`, type check); - menu items via item ID membership in the menu. - **Nested IDs:** - featured image, parent and relation fields: `read_post` plus type checks; - menu `object_id`: `read_post`, no private, unpublished only with a flag, taxonomy must be viewable; - Divi media/gallery and `select_post`: `read_post`; - terms: `assign_terms`/`edit_terms`; - meta: `edit_post_meta`/`add_post_meta` per key. - **Query and search:** - `content-query`/`media-search`/`replace-text` limit the query to the user's own posts without `edit_others_posts` and gate statuses; - hidden fields can't be used as a query filter. - **Settings:** - `manage_options`; - strict allow-list; - `admin_email`/`users_can_register`/`default_role`/`siteurl`/`home` explicitly blocked; - per-key validation (installed languages, valid timezones, published pages, image attachment). - **Meta writes:** - `update_post_meta` double-serializes a serialized string, so there is no object injection; - protected keys are refused through the meta caps; - clone and replace copy raw stored meta via `$wpdb` (no unserialize). - **Menu URLs:** only http/https/mailto/tel-style schemes, `/path` (not `//` or `/\`) or `#`; control characters and spaces are refused; `esc_url_raw` is applied. - **Media files:** - MIME allow-list intersected with the core-allowed types for the user, checked against the real content (`wp_check_filetype_and_ext`); - no SVG by default; - `sanitize_file_name` (no traversal, double extensions defused); - streamed size cap; - Base64 size checked before decoding; - temp files removed on every path; - download and preview only read files `realpath`-inside uploads; - caption and description go through `wp_kses_post`. - **Error handling:** - Throwables are mapped to `internal_error`, details only go to the PHP log; - the sideload error is hidden; - no SQL or path text reaches the client (apart from the M3 download messages). - **SQL:** every query is `$wpdb->prepare()`d. The only interpolations are table names and the constant status list; the log filters are whitelisted. - **Change log:** holds titles and references only, no content. Admin list output is escaped; revision links are gated by `edit_post` on the parent. - **Admin UI:** - every admin-post action has `manage_options` plus `check_admin_referer` (revoke nonce per UUID, clear-log); - settings go through `options.php` with `settings_fields` nonces and sanitize callbacks; - `closedpostboxes` is core AJAX with its nonce; - notices and tabs come from fixed maps; - everything is escaped (`esc_js`+`esc_attr` for confirm handlers). - **Site Health and admin notices:** static text only, escaped; the loopback check is cached and only runs on demand. - **Guides:** topic slug regex plus the glob'd file list (no traversal); filtered guides are site-owned. - **Divi pipeline integration:** - the kses window covers only `RevisionPoint::capture` plus the one `wp_update_post` and is restored in `finally`, only when kses was active; - Divi's own `wp_insert_post_data` security filters stay (verified: they strip `on*` attributes); - the insert, update, move, delete and duplicate-node tools only write through Composer/TreeEditor; - duplicate-node adds only the stored label plus a constant; - replace-text re-checks targets per post; - DynamicContentMap drops `loop_*` options and types come from the registry; the fixed rules (enable_html, meta keys, markers) remain. - **Uninstall and deactivation:** both remove their own options, transient, cron and table on every site of a network, never posts. - **Limits and DoS:** - cursor offset capped at 9 digits, page size ≤100; - batch caps (media-update 50, divi-update 50, insert/delete 20, replace-text 20 targets / dry run ≤100 pages, content-create `divi` 30 sections); - render summary strings bounded; - text search limited to 200 characters.