Security (medium): WP-Cron loopback rewritten to the visitor's onion host starves scheduled jobs #8

Closed
opened 2026-10-15 22:36:02 -07:00 by eric · 0 comments
Owner

Severity: medium (likelihood medium, impact medium)
Found by: security audit run 3 (commit 01195c8), independently verified in a disposable local WordPress 7.1.3 lab
Fingerprint: multi-domain-redux:HookBindings.register:site_url-rewrites-spawn-cron-loopback-to-request-host

Summary

Core spawn_cron() builds its server-to-self request from site_url('wp-cron.php'), and the plugin rewrites site_url to the host the visitor arrived on. When a front-end request arrives with Host set to the configured onion name, core first takes the 60-second doing_cron lock and then sends a non-blocking POST to http://<onion>/wp-cron.php. If the PHP host cannot resolve .onion (RFC 7686 names normally do not resolve without a Tor proxy), that request never reaches the site, so the due jobs do not run, and requests on the primary host made while the lock is held do not spawn cron either. Anyone who reaches the vhost with that Host header (a real Tor visitor, or a clearnet client sending Host: <onion> to a server that accepts any Host) can take the spawn slot again each time the lock expires, delaying scheduled posts, update and security checks and other cron work for as long as the requests continue. A site with heavy onion traffic hits this without any attacker. In the lab, one onion request left a due dummy event unrun for the whole 60-second window, and a primary-host request after the lock expired ran it.

Root cause

HookBindings::register adds UrlRewriter::rewrite to the 'site_url' filter for every context, including core's server-originated cron loopback, and UrlRewriter replaces the configured host with the request-resolved host and scheme (onion forced to http). Core's spawn_cron target therefore follows the visitor's Host header instead of a host the server can reach (the stored siteurl or the primary host).

Expected behaviour

Only URLs shown to the visitor should follow the request host. Server-to-self requests such as the spawn_cron loopback should always target the stored siteurl (or the primary host), so any request host triggers a cron run that actually executes.

Source trace

  1. src/Request/HostHeader.php:30 (HostHeader::fromServer): Reads HTTP_HOST from the unauthenticated request; normalize() accepts a 56-character v3 onion label.
  2. src/Request/HostResolver.php:61 (HostResolver::resolve): The onion matches a configured host and becomes the current host; lines 67-68 force the scheme to http for onion hosts.
  3. src/Rewrite/HookBindings.php:57 (HookBindings::register): rewriteUrlFilter is added at priority 99 to every URL_FILTERS entry, including 'site_url' (line 21), with no exception for cron or loopback contexts.
  4. src/Rewrite/UrlRewriter.php:61 (UrlRewriter::rewrite): Returns http://<onion>/wp-cron.php; in WP 7.1.3 spawn_cron (wp-includes/cron.php:959-999) has already set the doing_cron lock and then wp_remote_post()s this unreachable URL with blocking=false.

Conditions

  • system configuration: An onion host is configured in the plugin, the stored siteurl host is one of the configured hosts, and WP-Cron runs from page loads (no DISABLE_WP_CRON with a system cron runner; ALTERNATE_WP_CRON off).
  • environmental dependency: The PHP host's outbound WP_Http cannot resolve or route the configured .onion name (no Tor-aware resolver and no WP_PROXY), which is normal for an onion service whose Tor daemon forwards to a local web server.
  • network routing: The request reaches the WordPress vhost with Host set to the onion name, through Tor or via a clearnet listener that accepts any Host (e.g. a default_server).
  • timing dependency: A request only takes the lock if no lock younger than 60s exists and a job is due; continuous starvation needs the first request after each lock expiry, i.e. roughly one request per minute (more to beat legitimate traffic).
  • third party dependency: WordPress core spawn_cron behaviour (lock set before the loopback, 60-second WP_CRON_LOCK_TIMEOUT, non-blocking POST to site_url) was read directly from the lab's WP 7.1.3 wp-includes/cron.php.

Lab reproduction (local, dummy data only)

Principal: Unauthenticated network client, or an ordinary Tor visitor, sending a normal GET with Host set to the site's configured onion name.

  1. Configure the plugin with www.example.test as primary and a valid v3 onion name the PHP host cannot resolve (lab: 'b' x56 + .onion; gethostbyname returns the name unchanged).
  2. From a wp-cli process with DISABLE_WP_CRON defined (so the driver never spawns cron), clear the doing_cron transient and schedule one dummy single event due 3 seconds later; wait until it is due.
  3. Send one GET / to the web server with Host: <onion>; read the cron option and _transient_doing_cron directly from wp_options at 10, 20 and 30 seconds.
  4. At about 30 seconds send one GET / with Host: www.example.test and check again.
  5. After the lock is older than 60 seconds send one GET / with Host: www.example.test and check again.
  6. Remove the dummy event and lock, delete the plugin option and deactivate the plugin.

Observed: After the onion GET (HTTP 200) the doing_cron lock was set and the due dummy event was still scheduled at 10s, 20s and 30s. A primary-host GET at 30s did not spawn cron (event still scheduled, lock still set at age 34s). Once the lock was older than 60s, a primary-host GET ran the event within 4 seconds; a control round (lock cleared, primary-host GET) also ran it within 4 seconds. One repeat round was inconclusive (the event was consumed by an unidentified trigger before the onion request) and is not counted. Sustained starvation over many lock windows was not exercised (no load generation); it follows from the per-window mechanism.

Fix

Keep visitor-facing URL rewriting, but pin core's cron loopback to the stored siteurl: filter cron_request so the loopback URL keeps its doing_wp_cron query but uses the siteurl host and scheme regardless of the request host. Sites that cannot reach even their primary host should use DISABLE_WP_CRON with a system cron. Regression test: with the current host resolved to the onion, cron_request's url equals the stored siteurl + /wp-cron.php?doing_wp_cron=...

Suggested change in src/Rewrite/HookBindings.php:

// In register(), after the URL_FILTERS loop:
		add_filter( 'cron_request', array( $this, 'pinCronLoopback' ), 99, 1 );

// New method:
	/**
	 * Keep core's server-to-self cron loopback on the stored siteurl, not the visitor's host.
	 *
	 * @param mixed $request
	 */
	public function pinCronLoopback( $request ): mixed {
		if ( ! is_array( $request ) || ! isset( $request['url'] ) || ! is_string( $request['url'] ) ) {
			return $request;
		}
		$query          = (string) wp_parse_url( $request['url'], PHP_URL_QUERY );
		$request['url'] = untrailingslashit( (string) get_option( 'siteurl' ) ) . '/wp-cron.php' . ( '' !== $query ? '?' . $query : '' );
		return $request;
	}

Priority rationale

  • Likelihood: No authentication and one ordinary GET per lock window; requires a configured onion host, a PHP host that cannot resolve .onion (typical), page-load WP-Cron, and Tor access or a vhost that accepts the onion Host. Onion-heavy sites trigger it without an attacker.
  • Impact: While requests continue, all WP-Cron work site-wide is suppressed (scheduled posts, update and security checks, cleanup). It stops when the requests stop, causes no data loss or confidentiality impact, and is fully avoided by a system cron. Only one 60-second suppression was directly observed.
**Severity:** medium (likelihood medium, impact medium) **Found by:** security audit run 3 (commit `01195c8`), independently verified in a disposable local WordPress 7.1.3 lab **Fingerprint:** `multi-domain-redux:HookBindings.register:site_url-rewrites-spawn-cron-loopback-to-request-host` ## Summary Core spawn_cron() builds its server-to-self request from site_url('wp-cron.php'), and the plugin rewrites site_url to the host the visitor arrived on. When a front-end request arrives with Host set to the configured onion name, core first takes the 60-second doing_cron lock and then sends a non-blocking POST to http://&lt;onion&gt;/wp-cron.php. If the PHP host cannot resolve .onion (RFC 7686 names normally do not resolve without a Tor proxy), that request never reaches the site, so the due jobs do not run, and requests on the primary host made while the lock is held do not spawn cron either. Anyone who reaches the vhost with that Host header (a real Tor visitor, or a clearnet client sending Host: &lt;onion&gt; to a server that accepts any Host) can take the spawn slot again each time the lock expires, delaying scheduled posts, update and security checks and other cron work for as long as the requests continue. A site with heavy onion traffic hits this without any attacker. In the lab, one onion request left a due dummy event unrun for the whole 60-second window, and a primary-host request after the lock expired ran it. ## Root cause HookBindings::register adds UrlRewriter::rewrite to the 'site_url' filter for every context, including core's server-originated cron loopback, and UrlRewriter replaces the configured host with the request-resolved host and scheme (onion forced to http). Core's spawn_cron target therefore follows the visitor's Host header instead of a host the server can reach (the stored siteurl or the primary host). ## Expected behaviour Only URLs shown to the visitor should follow the request host. Server-to-self requests such as the spawn_cron loopback should always target the stored siteurl (or the primary host), so any request host triggers a cron run that actually executes. ## Source trace 1. `src/Request/HostHeader.php:30` (HostHeader::fromServer): Reads HTTP_HOST from the unauthenticated request; normalize() accepts a 56-character v3 onion label. 2. `src/Request/HostResolver.php:61` (HostResolver::resolve): The onion matches a configured host and becomes the current host; lines 67-68 force the scheme to http for onion hosts. 3. `src/Rewrite/HookBindings.php:57` (HookBindings::register): rewriteUrlFilter is added at priority 99 to every URL_FILTERS entry, including 'site_url' (line 21), with no exception for cron or loopback contexts. 4. `src/Rewrite/UrlRewriter.php:61` (UrlRewriter::rewrite): Returns http://&lt;onion&gt;/wp-cron.php; in WP 7.1.3 spawn_cron (wp-includes/cron.php:959-999) has already set the doing_cron lock and then wp_remote_post()s this unreachable URL with blocking=false. ## Conditions - **system configuration**: An onion host is configured in the plugin, the stored siteurl host is one of the configured hosts, and WP-Cron runs from page loads (no DISABLE_WP_CRON with a system cron runner; ALTERNATE_WP_CRON off). - **environmental dependency**: The PHP host's outbound WP_Http cannot resolve or route the configured .onion name (no Tor-aware resolver and no WP_PROXY), which is normal for an onion service whose Tor daemon forwards to a local web server. - **network routing**: The request reaches the WordPress vhost with Host set to the onion name, through Tor or via a clearnet listener that accepts any Host (e.g. a default_server). - **timing dependency**: A request only takes the lock if no lock younger than 60s exists and a job is due; continuous starvation needs the first request after each lock expiry, i.e. roughly one request per minute (more to beat legitimate traffic). - **third party dependency**: WordPress core spawn_cron behaviour (lock set before the loopback, 60-second WP_CRON_LOCK_TIMEOUT, non-blocking POST to site_url) was read directly from the lab's WP 7.1.3 wp-includes/cron.php. ## Lab reproduction (local, dummy data only) **Principal:** Unauthenticated network client, or an ordinary Tor visitor, sending a normal GET with Host set to the site's configured onion name. 1. Configure the plugin with www.example.test as primary and a valid v3 onion name the PHP host cannot resolve (lab: 'b' x56 + .onion; gethostbyname returns the name unchanged). 2. From a wp-cli process with DISABLE_WP_CRON defined (so the driver never spawns cron), clear the doing_cron transient and schedule one dummy single event due 3 seconds later; wait until it is due. 3. Send one GET / to the web server with Host: &lt;onion&gt;; read the cron option and _transient_doing_cron directly from wp_options at 10, 20 and 30 seconds. 4. At about 30 seconds send one GET / with Host: www.example.test and check again. 5. After the lock is older than 60 seconds send one GET / with Host: www.example.test and check again. 6. Remove the dummy event and lock, delete the plugin option and deactivate the plugin. **Observed:** After the onion GET (HTTP 200) the doing_cron lock was set and the due dummy event was still scheduled at 10s, 20s and 30s. A primary-host GET at 30s did not spawn cron (event still scheduled, lock still set at age 34s). Once the lock was older than 60s, a primary-host GET ran the event within 4 seconds; a control round (lock cleared, primary-host GET) also ran it within 4 seconds. One repeat round was inconclusive (the event was consumed by an unidentified trigger before the onion request) and is not counted. Sustained starvation over many lock windows was not exercised (no load generation); it follows from the per-window mechanism. ## Fix Keep visitor-facing URL rewriting, but pin core's cron loopback to the stored siteurl: filter cron_request so the loopback URL keeps its doing_wp_cron query but uses the siteurl host and scheme regardless of the request host. Sites that cannot reach even their primary host should use DISABLE_WP_CRON with a system cron. Regression test: with the current host resolved to the onion, cron_request's url equals the stored siteurl + /wp-cron.php?doing_wp_cron=... Suggested change in `src/Rewrite/HookBindings.php`: ```php // In register(), after the URL_FILTERS loop: add_filter( 'cron_request', array( $this, 'pinCronLoopback' ), 99, 1 ); // New method: /** * Keep core's server-to-self cron loopback on the stored siteurl, not the visitor's host. * * @param mixed $request */ public function pinCronLoopback( $request ): mixed { if ( ! is_array( $request ) || ! isset( $request['url'] ) || ! is_string( $request['url'] ) ) { return $request; } $query = (string) wp_parse_url( $request['url'], PHP_URL_QUERY ); $request['url'] = untrailingslashit( (string) get_option( 'siteurl' ) ) . '/wp-cron.php' . ( '' !== $query ? '?' . $query : '' ); return $request; } ``` ## Priority rationale - Likelihood: No authentication and one ordinary GET per lock window; requires a configured onion host, a PHP host that cannot resolve .onion (typical), page-load WP-Cron, and Tor access or a vhost that accepts the onion Host. Onion-heavy sites trigger it without an attacker. - Impact: While requests continue, all WP-Cron work site-wide is suppressed (scheduled posts, update and security checks, cleanup). It stops when the requests stop, causes no data loss or confidentiality impact, and is fully avoided by a system cron. Only one 60-second suppression was directly observed.
eric closed this issue 2026-10-15 23:24:39 -07:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
eric/multi-domain-redux#8
No description provided.