Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
21 KiB
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 ofvendor/wp-media/mcp-oauthandvendor/wordpress/mcp-adapter. - Ran read-only DDEV
wp eval-fileprobes. - Ran one live, self-cleaning run of the real
lit/content-createability 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_roleswas widened in-process only, through the filter, never in the database.
- Read all of
No Critical or High findings. The Divi value pipeline itself held. The two most relevant problems are ways around it:
- the raw
contentfield; - 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:124refusescontentonly when the post already uses Divi. Nothing refuses content that introduces Divi markup.- Compare
src/Integrations/Divi/Compose/ValueCleaner.php:57(BUILDER_ONLY).
Scenario
- An Author, or an Editor on multisite or with
DISALLOW_UNFILTERED_HTML, callslit/content-create(orlit/content-updateon a non-Divi post). - The
contentis Divi 5 block markup that sets:- a module's
css.desktop.value.freeForm(e.g.body{…}); module.decoration.attributes(custom HTML attributes).
- a module's
- kses (
filter_block_content) and Divi's ownwp_insert_post_datasecurity filters keep both. Onlyon*attributes are stripped, by Divi. - 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
<div class="et_pb_text_0 … et_pb_module et_flex_module" data-probe="1">.
- Read-only kses probe (
kses6.php):onmouseoveris removed by Divi and never rendered. No script execution found. Divi renders ajavascript:linkUrlashref="".
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 acontentthat contains<!-- wp:divi/(and[et_pb_), with a hint to usedivi/ the Divi tools. - Alternative: parse it with
Parserand run every Divi block throughComposer/ValueCleaner(heavier). - At minimum, do this for users without
unfiltered_html.
M2 – Medium: "nur lesen" / disabled Divi group does not stop Divi writes (content-create divi)
Where
src/Integrations/WordPress/WordPressIntegration.php:83passes$this->divi(the ContentComposer) unconditionally.ContentCreateAbility.php:113-119, 145write the Divi layout whenever Divi 5 is loaded.ExposurePolicy::disabledByReadonly()andenabled()only look at the ability's own group ("wordpress").
Scenario
- The admin sets group Divi to "nur lesen", or disables
lit-divi-insert, or disables the whole group. - The tools tab, readme ("schaltet alle schreibenden Tools einer Gruppe … ab", switches off all writing tools of a group) and acceptance note §7 all promise that no Divi writes happen.
- Claude can still create complete Divi layouts via
lit-content-createwithdivi: [...]. That goes through the same kses window.
Related cross-group writes that still work with Divi "nur lesen":
lit/post-duplicate,lit/post-replace-with-copy,lit/revision-restoreon Divi pages. These use stored content, so the impact is lower.
Evidence: by reading. The wiring is unconditional; ExposurePolicy has no cross-group notion.
Fix proposal
- Give
ContentCreateAbilitya callablediviAllowed()that is true only whenExposurePolicy::mode()oflit/divi-insertis notdisabled. That mode already includes the Divi group's read-only switch. - If Divi writes are not allowed, answer
diviwithunsupported, and drop thediviproperty from the input schema. - Document in the Tools tab that duplicate/replace/restore copy stored Divi content regardless of the Divi group.
M3 – Medium: URL import SSRF – DNS rebinding TOCTOU, core-version-dependent blocklist, error oracle
Where
src/Integrations/Media/MediaImporter.php:55-62(wp_http_validate_urlpre-check).:119-139(wp_safe_remote_get+CURLOPT_IPRESOLVE_V4).:142/:339-346(the rawWP_Errormessage is returned).
Scenario 1: DNS rebinding
- A prompt-injected Claude (e.g. a web page Claude read says "import this image") calls
lit-media-upload-from-urlwith a hostname. wp_http_validate_url()resolves the host withgethostbyname()and sees a public IP. cURL then resolves again; with TTL 0 it gets 127.0.0.1 or another internal host.- Redirects are re-validated by core (
WP_Http::validate_redirects, verified in 7.1.2), but each redirect has the same TOCTOU gap. - Impact:
- blind GET requests to internal services on ports 80/443/8080;
- internal images/PDFs (e.g. internal dashboards rendering PNG) get copied into the public media library.
Scenario 2: version-dependent blocklist
- On DDEV (core 7.1.2),
wp_http_validate_url()blocks 169.254/16, 100.64/10, TEST-NETs, multicast etc. Verified:ssrf1.php, all 17 internal probes return false. - The plugin declares "Requires at least: 6.9". Older cores only block 0/8, 10/8, 127/8, 172.16/12 and 192.168/16.
- If 6.9 lacks the newer ranges,
http://169.254.169.254/(cloud metadata) is reachable. Not verified on 6.9.
Scenario 3: error oracle
Download fehlgeschlagen: <cURL/WP message>andHTTP-Status %dlet the caller tell apart:- refused;
- timeout;
- 404;
- "wrong file type".
Evidence: by reading, plus the DDEV validation probe.
Fix proposal
- In the existing
http_api_curlhook:- resolve the host once;
- check the IP with the plugin's own full private/reserved list (
filter_var(..., FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE), plus 100.64/10); - pin it with
CURLOPT_RESOLVE(host:port:ip).
- Set
'redirection' => 0and follow at most 3 redirects manually, applying the same resolve, check and pin to each. - Return a generic "Download fehlgeschlagen" message and log the detail.
M4 – Medium (only when Pods field restrictions are used): Pods field access restrictions are ignored
Where
src/Integrations/Pods/PodsFieldSource.php:73-114(read/write throughpods()->field()andpods()->save()).PostWriter.php:226(META_SOURCESexcludes pods, so there is no per-field capability check).PostPresenter::isHiddenField()only hides thepasswordtype.
Scenario
- Pods fields can be restricted per field:
admin_only;restrict_role;restrict_capability;read_only;hidden.
- Pods enforces these only in its forms (
PodsForm::permission→pods_permission). - Its PHP API does not:
PodsAPI::save_pod_itemdefaultsfrom = 'save', and'save' === $params->fromskipspods_permission(pods/classes/PodsAPI.php:4868, 4994). - An Editor or Author with
edit_poston the entry can therefore read (lit-content-get,lit-content-queryfield filter) and write (lit-content-update/create) fields the site owner restricted to admins or made read-only.
Evidence: by reading the plugin and the Pods source in wp-content/plugins/pods. Pods is not active on DDEV, so there was no live test.
Fix proposal
- In
PodsFieldSource::definition():- mark fields
READONLYwhenpods_has_permissions($field) && ! pods_permission($field)or whenread_onlyis set; - hide them (as
isHiddenFielddoes) whenhiddenis set or permission fails withoutread_only_restricted.
- mark fields
- Re-check in
write()beforesave().
L1 – Low: any logged-in user can complete OAuth and get a connection (application password + tokens)
Where
- The vendor flow (
mcp-oauthAuthorizeEndpoint/ConsentEndpoint: onlyis_user_logged_in()). - The plugin adds no gate:
Plugin.php:110boots it as-is. - The
lit_mcp_usegate only applies at tool execution (AbilityRegistrar.php:56-63) and tool listing (VisibilityFilter).
Scenario
- On sites with customer or subscriber accounts (WooCommerce, memberships), any customer can connect Claude.
- mcp-oauth then creates an application password for them, even where core would not offer app passwords to that user.
- It also issues access and refresh tokens, and mcp-adapter sessions in user meta.
- The customers can't run tools. But the connections appear in "Zugriff", and the site accumulates credentials and sessions for users who were never meant to have them.
Evidence: by reading.
Fix proposal: before mcp-oauth's authorize/consent handler, refuse with a clear message when ! current_user_can('lit_mcp_use'). A hook such as template_redirect on its rewrite query var would work, and so would a filter if mcp-oauth offers one.
L2 – Low: release/build hygiene – dev vendor, tests namespace, direct file access
Where
vendor/(dev install).composer.jsonautoload-dev.- All
src/**/*.php(noABSPATHguard). tests/Integration/divi-convert-fixture.php(the only integration script without aWP_CLIguard)..gitattributes(only relevant if the zip is made withgit archive, andvendor/is git-ignored).
Scenario and evidence
-
Dev vendor loaded at runtime. The current tree's Jetpack autoloader loads these files on every WP request:
mockery/library/helpers.php;Mockery.php;brain/monkey/inc/api.php;phpunit/.../Assert/Functions.php.
Source:
vendor/composer/jetpack_autoload_filemap.php. No release script exists (spec §U is still open). -
Tests namespace survives
--no-dev. Reproduced:composer install --no-devin a temp dir still givesLIT\MCP\Tests\ → tests/invendor/composer/jetpack_autoload_psr4.php:21. The Jetpack autoloader keeps rootautoload-dev. Iftests/ships, test classes are autoloadable in production. -
Direct HTTP access leaks paths and versions.
curl https://…/wp-content/plugins/WP-LIT-MCP/src/Admin/AccessTab.phpreturns a PHP fatal error with the full server path (display_errors on in DDEV).…/tests/Integration/divi-convert-fixture.php→ HTTP 200.…/vendor/composer/installed.json→ HTTP 200 (full dependency and version list).
Fix proposal
- Add a build script:
composer install --no-dev -o;- copy only
wp-lit-mcp.php,uninstall.php,readme.txt,src/,assets/,docs/guides/,vendor/; - no
tests/,docs/superpowers,.superpowers,composer.*,phpunit/phpcsconfigs.
- Add a CI check that the zip contains no
vendor/phpunit,vendor/mockeryortests/. - Add
defined('ABSPATH') || exit;to the src files (Plugin Check flags its absence), and theWP_CLIguard todivi-convert-fixture.php.
L3 – Low: tree_rev / rev lock is check-then-write (lost update)
Where
DiviWriter.php:116(check) →:161(save).ContentUpdateAbility.php:115→:156.MediaUpdateAbilitymodified.
Scenario: two parallel writes on the same page (two Claude sessions, or Claude plus the Visual Builder) both pass the rev check. The later wp_update_post() silently overwrites the earlier change. The restore-point revision limits the damage.
Evidence: by reading. There is no lock or conditional update.
Fix proposal:
- Take a short per-post lock around check and save (
wp_cache_add/add_option('lit_mcp_lock_<id>')with expiry, or MySQLGET_LOCK). - Re-read
post_contentinside the lock and compareRevagain right beforewp_update_post().
L4 – Low: deactivation and uninstall leave OAuth sessions valid
Where
Lifecycle.php:93-101.Uninstaller.php:28-61. This is documented as intentional.
Scenario
- Deactivating or deleting the plugin keeps these:
- the mcp-oauth application passwords;
mcp_refresh_jti_*meta;mcp_jwt_secret.
- Reactivating it within 30 days revives every refresh token. The admin may believe that removing the plugin cut Claude off.
- Plugin-owned post meta
_lit_mcp_copy_of/_lit_mcp_backup_ofalso remains.
Evidence: by reading.
Fix proposal:
- On uninstall, delete the connections
ConnectionRepositoryidentifies (marker meta plus httpsapp_id). - Either delete
_lit_mcp_*meta, or mention in readme and deactivation notice that connections survive. - Offer "alle Verbindungen widerrufen" (revoke all connections) in the Access tab.
I1 – Info: mcp-oauth logs MCP request bodies when WP_DEBUG and WP_DEBUG_LOG are on
Where: vendor/wp-media/mcp-oauth/inc/Transport/OAuthHttpTransport.php:128-137 (McpLogger::safe_request_body(), 8 KB).
Tool arguments (page content, private drafts, Base64 uploads) go to wp-content/debug.log, which is often web-reachable on customer staging sites.
Fix proposal: mention this in the readme, or turn it off (e.g. filter or constant, if mcp-oauth offers one; otherwise ask upstream).
I2 – Info: non-Divi writes re-kses Divi pages for users without unfiltered_html
These tools save through plain wp_update_post/wp_insert_post, so content_save_pre kses re-serializes the whole Divi layout:
lit/content-update(title/excerpt/fields);- publish/unpublish/schedule;
revision-restore;post-duplicate;post-replace-with-copy.
The byte-identity promise of spec §1.3/§5.2 does not hold for these tools. There is no security impact: it only ever sanitizes more.
Evidence: read-only probe kses2.php. All 7 stored Divi pages changed under editor-kses (e.g. an empty block becomes /-->).
I3 – Info: smaller observations
- 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::currentUserLosesAccessknows this; the UI text doesn't say it. - Media usage lists unreadable posts.
MediaUsageFinder.php:455-461returns 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. - All post types with a UI are exposed. Every
show_uipost type, including non-public ones (on DDEV:path), is exposed; only core, Divi internals andlit_mcp_excluded_post_typesare excluded. Consider documenting the filter for data-protection purposes (form submissions, personal data as CPTs). - Nested REST requests can block later calls. A
rest_do_request()to another route during a tool call makesRequestContextfalse for the rest of that HTTP request (RequestContext.php:281). This fails closed, so later JSON-RPC calls in the same batch getforbidden. Functional only. confirm: trueis 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 viarest_get_server()->get_routes()). - Core Abilities REST: our abilities have
show_in_rest=false(only the 3core/*abilities are in REST). - Transport and JWT:
OAuthHttpTransportrequires 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_usegate: dynamic cap viauser_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 tolit_mcp_use.- Exposure:
- disabled and "nur lesen" abilities are never registered, so
execute-abilityanswers "not found"; - DIRECT_ONLY abilities are
mcp.public=false, soexecute-abilityrefuses them; lit/guideis always direct.
- disabled and "nur lesen" abilities are never registered, so
- Annotations: every tool annotated
readonlyreally doesn't write (render-summary runsthe_contentside 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_postplus 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_metaper key.
- featured image, parent and relation fields:
- Query and search:
content-query/media-search/replace-textlimit the query to the user's own posts withoutedit_others_postsand 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/homeexplicitly blocked;- per-key validation (installed languages, valid timezones, published pages, image attachment).
- Meta writes:
update_post_metadouble-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_rawis 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.
- MIME allow-list intersected with the core-allowed types for the user, checked against the real content (
- 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).
- Throwables are mapped to
- 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_poston the parent. - Admin UI:
- every admin-post action has
manage_optionspluscheck_admin_referer(revoke nonce per UUID, clear-log); - settings go through
options.phpwithsettings_fieldsnonces and sanitize callbacks; closedpostboxesis core AJAX with its nonce;- notices and tabs come from fixed maps;
- everything is escaped (
esc_js+esc_attrfor confirm handlers).
- every admin-post action has
- 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::captureplus the onewp_update_postand is restored infinally, only when kses was active; - Divi's own
wp_insert_post_datasecurity filters stay (verified: they stripon*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.
- the kses window covers only
- 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
divi30 sections); - render summary strings bounded;
- text search limited to 200 characters.