Security (low): request host/scheme persisted into block navigation and post guid, replayed on every host #10
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: low (likelihood low, impact low)
Found by: security audit run 3 (commit
01195c8), independently verified in a disposable local WordPress 7.1.3 labFingerprint:
multi-domain-redux:HookBindings.register:block-render-output-not-rewritten-persisted-request-hostSummary
The plugin rewrites home_url (and the other URL filters) to the current request's host and scheme in every context, including core code paths that persist data, and the persisted values are later printed by emitters the plugin does not rewrite on output, so one request's host reaches visitors on every host. Sink (a), unauthenticated: on a block theme whose navigation block has no ref, with a classic menu and no published wp_navigation post (typical right after switching from a classic theme), the first front-end render runs core's navigation fallback, which converts the classic menu into a new wp_navigation post (post_author 0) whose link URLs are built with get_permalink() -> home_url(), already rewritten by the plugin to the requester's host and scheme. The navigation block renders that stored post_content through no plugin content filter, so every later visitor on every configured host gets those URLs in the site-wide header navigation. On a single-site lab, one anonymous request with Host set to the onion host made the clearnet www and apex homepages emit href="http://<onion>/sample-page/" in their header navigation, disclosing the onion address to every clearnet visitor and giving clearnet visitors an unusable link. The attacker chooses which configured host (and the request scheme) is stored, not an arbitrary domain, because UrlRewriter only substitutes configured hosts. The output gap also exists on its own: a canonical navigation post stored by core without the plugin is emitted with the primary host on the onion and apex hosts. Sink (b), authenticated: when an author/editor/admin opens post-new.php while on a non-primary configured host such as the onion, core stores the auto-draft guid as get_permalink() = http://<onion>/?p=N. The guid is permanent and not filtered by the plugin on output, so clearnet RSS readers and REST clients receive <guid>http://<onion>/?p=N</guid> and guid.rendered next to a correctly rewritten clearnet link, disclosing the onion address and its correlation to the clearnet site even when the operator publishes no onion hreflang.
Root cause
UrlRewriter::rewrite (src/Rewrite/UrlRewriter.php:61) maps any configured-host absolute URL to the current request's scheme and host, and is registered on home_url and the other URL filters (src/Rewrite/HookBindings.php:56-57) with no distinction for core code paths that persist data (the wp_navigation fallback built during a front-end render, and the guid set by wp_insert_post for a post-new.php auto-draft). On output, HookBindings::register attaches ContentRewriter only to the_content, widget_text_content and comment_text (src/Rewrite/HookBindings.php:64-66); block-rendered output (render_block) and get_the_guid are never rewritten, so a stored request-specific URL is printed verbatim on other hosts.
Expected behaviour
Every URL the site emits uses the visitor's own host and never exposes another alias (such as the onion host) to visitors on a different host, and data persisted as a side effect of one request (an anonymous visitor's or an author's) must not fix the host or scheme other visitors see.
Source trace
src/Request/HostResolver.php:58(HostResolver::resolve): Reads the client-supplied Host header from the $_SERVER copy made at line 56; the request scheme is read at line 59. For sink (a) this is an unauthenticated front-end request; for sink (b) the author's admin request.src/Request/HostResolver.php:61(HostResolver::resolve): Matches the Host against configured hosts with primary fallback (lines 61-64); onion forces http (67-69); the chosen host and scheme are memoized at lines 71-72.src/Rewrite/HookBindings.php:57(HookBindings::register): rewriteUrlFilter is registered on home_url and the other URL filters with no distinction for data-persisting contexts.src/Rewrite/UrlRewriter.php:61(UrlRewriter::rewrite): The permalink core computes becomes <request scheme>://<request host>/path, which core stores in the fallback wp_navigation post_content (url attribute) and, for a post-new.php auto-draft, in posts.guid.src/Rewrite/HookBindings.php:64(HookBindings::register): Only the_content, widget_text_content and comment_text are rewritten on output (lines 64-66); navigation-block render output and get_the_guid (RSS <guid>, REST guid.rendered) are not, so the stored host and scheme are emitted on every host.Conditions
Lab reproduction (local, dummy data only)
Principal: Sink (a): unauthenticated network client controlling the Host header (and scheme) of one front-end request. Sink (b): no attacker request; a legitimate author working on the onion host causes it and any clearnet feed or REST reader observes it.
Observed: Verifier v9 reproduced both sinks on single-site lab 3. (a) One anonymous GET with the onion Host created wp_navigation post 11 with post_author 0, guid http://<onion>/2026/10/16/v9menu/ and url "http://<onion>/sample-page/"; the www.example.test and example.test homepages then both emitted href="http://<onion>/sample-page/" in the header navigation while their feed links were correctly http://www.example.test/feed/ and http://example.test/feed/. Control: without the plugin core stored http://www.example.test/sample-page/, and with the plugin reactivated that URL was emitted unrewritten on the onion and example.test hosts. (b) An admin's post-new.php on the onion host produced auto-draft 18 with guid http://<onion>/?p=18, unchanged after publish; the www feed emitted <link>http://www.example.test/2026/10/16/v9-guid-probe/</link> with <guid isPermaLink="false">http://<onion>/?p=18</guid>, and REST guid.rendered was the onion URL while link was www; the www homepage contained no onion reference. Control on the www host: guid http://www.example.test/?p=21. Consistent with the earlier multisite reproductions by hunter w4b and verifier v7 and hunter w5a's guid run. The lab was restored. The scheme dimension (a stored plain-http URL served to https visitors) was not re-observed because the lab is http-only.
Fix
Rewrite block-rendered output at emit time like post content: hook render_block and widget_block_content to a string-guarded wrapper. Because ContentRewriter skips the visitor's own host (src/Rewrite/ContentRewriter.php:38-40), also drop that skip and normalise a stored scheme on the current host using a never-downgrade rule (keep https for clearnet hosts when the stored URL was https; onion stays http); same-host rewriting is idempotent when the scheme already matches. For guids, do not make the identifier depend on the visitor's host (feed readers treat it as a stable ID): pin guids to the primary host with its configured scheme on write (wp_insert_post_data) and, on output, map any configured-host guid to the primary (get_the_guid), which also repairs guids already stored. Optionally avoid persisting request-specific URLs during wp_navigation fallback creation, but the emit-time rewrite is the control that enforces the invariant. Regression tests: (1) a stored navigation-link url on host A rendered on host B emits host B; (2) a stored http:// url on clearnet host B rendered on B over https emits https; (3) a stored https:// url on B rendered on B over http stays https; (4) a post created during an onion-host admin request has a primary-host guid and the www feed and REST output contain no onion host.
Suggested change in
src/Rewrite/HookBindings.php:Suggested change in
src/Rewrite/ContentRewriter.php:Priority rationale