Files
kevinveenbirkenbachandClaude Opus 5 b260cc5604 fix(wordpress): accept MCP requests under the internal service name
A client reaches the MCP endpoint by the provider's service name on the
container network, so the hop is plain HTTP. WordPress hands out an application
password only over SSL and, on an onion deployment, home_url() is
http://<onion>, so the gate stays shut: core never even attempts to validate the
credential and answers rest_not_logged_in. The client learns nothing about why.

Measured on a live deployment, same credential, same endpoint, the only
difference being the declaration of the scheme: 401 without it, 200 with it.
Deliberately wrong passwords produced the same rest_not_logged_in as correct
ones, which is what "never validated" looks like from outside.

The accommodation belongs on the provider's side, as 5e810c4a5a already settled
for Moodle: a probe that spoofs Host and X-Forwarded-Proto asserts the endpoint
works when spoofed and says nothing about how a real client reaches it. That is
exactly what happened here - the contract test sent the header and passed while
hermes and openclaw, which send only their Authorization, were refused in run
34699757369 on both compose and swarm.

WORDPRESS_INTERNAL_HOST is rendered from the same mcp.endpoint.service_key the
clients build their URL from, so both sides name one thing once. The filter
opens the gate for a request whose HTTP_HOST matches it, splits on ':' so a port
cannot defeat the match, and changes nothing when the variable is unset. Its
existing branches are untouched.

contract.py loses the X-Forwarded-Proto it used to send. The test now takes the
route a client takes, and can no longer be green while every client fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 15:17:43 +02:00
..
…
…

WordPress Must-Use Plugins 🔒

Scope 🎯

This directory holds PHP files that the web-app-wordpress role copies into the running container at /var/www/html/wp-content/mu-plugins/.

mu-plugins is a WordPress core convention for must-use plugins. WordPress automatically loads every top-level *.php file in that directory before normal plugins, without a database activation record. As a result, code shipped here:

  • MUST be loaded on every request as soon as the file is present.
  • MUST NOT be deactivated from the Plugins -> Must-Use admin screen.
  • MUST NOT be placed in a subdirectory, because WordPress does not recurse into mu-plugins.

See the WordPress Must-Use Plugins handbook for the upstream contract.

When to add code here 📋

Put a file in this directory only when the behavior it implements is part of the security or integration contract of this role and MUST remain in effect for every request. Everyday feature plugins SHOULD stay in normal wp-content/plugins/ and be managed through 05_enable_plugin.yml so operators can disable them per site if needed.

Each file in this directory SHOULD declare its purpose and the hooks it registers at the top of the file so readers can understand why it cannot be switched off.

OIDC -> RBAC mapping (infinito-oidc-rbac-mapper.php) 🎫

infinito-oidc-rbac-mapper.php implements the OIDC -> WordPress role contract:

  • Single-Site path: the claim MUST contain /roles/web-app-wordpress/<role> entries. The highest-privilege role across all matches wins (administrator > editor > author > contributor > subscriber). When no entry matches, the user's role is set to subscriber as a deterministic fallback.
  • Multisite path (auto-detected via is_multisite()): per-site roles come from /roles/web-app-wordpress/<canonical-domain>/<role> entries; the super-admin capability comes from /roles/web-app-wordpress/network-administrator. The mapper adds the user to any site they have a role for (add_user_to_blog) and removes them from sites it previously added them to (remove_user_from_blog) when a role for that site disappears from the claim. A user-meta marker (_infinito_oidc_added_blog_ids) records which blog memberships the mapper owns, so memberships added through wp-admin or the REST API outside the OIDC flow are never touched.

For the broader RBAC contract (LDAP layout, rbac.tenancy schema, the rbac_group_path lookup plugin), see rbac.md.

HTTP CA trust (infinito-http-ca-trust.php) 🔐

infinito-http-ca-trust.php points WordPress's HTTP API at the deployment's CA bundle so outbound calls to internal HTTPS endpoints (OIDC discovery, REST loopback) verify instead of failing.

HTTP onion SOCKS (infinito-http-onion-socks.php) 🧅

infinito-http-onion-socks.php sends server-side wp_remote_* calls whose host ends in .onion through the Tor SOCKS proxy named by WORDPRESS_OIDC_SOCKS_PROXY, which env.j2 sets only when the OIDC issuer is an onion, so a clearnet deployment never routes through Tor.

It hooks http_api_curl rather than http_request_args because the args array cannot express a proxy: WordPress core's WP_HTTP_Proxy hardcodes CURLPROXY_HTTP, so SOCKS5 is reachable only on the raw cURL handle. Without it, libcurl refuses an .onion host at name resolution under RFC 7686 — before /etc/hosts, extra_hosts or DNS is consulted — and the token exchange fails with cURL error 6, which WordPress surfaces as wp-login.php?login-error=http_request_failed.

Deployment 🚚

Every file here is declared as an addon under meta/addons/ with mechanism: mu_plugin and source: vendored, and the addon id is the file stem. 04_mu_plugins.yml loops over those declarations and copies <addon_id>.php into the container on every deploy; a new file therefore needs its meta/addons/<addon_id>.yml entry and a Playwright spec before it is installed.

Credits 🙏

Developed and maintained by Kevin Veen-Birkenbach. Learn more at veen.world. Part of the Infinito.Nexus Project. Licensed under the Infinito.Nexus Community License (Non-Commercial).