# 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.