Security (medium): WP-Cron loopback rewritten to the visitor's onion host starves scheduled jobs #8
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: medium (likelihood medium, impact medium)
Found by: security audit run 3 (commit
01195c8), independently verified in a disposable local WordPress 7.1.3 labFingerprint:
multi-domain-redux:HookBindings.register:site_url-rewrites-spawn-cron-loopback-to-request-hostSummary
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
src/Request/HostHeader.php:30(HostHeader::fromServer): Reads HTTP_HOST from the unauthenticated request; normalize() accepts a 56-character v3 onion label.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.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.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
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.
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:Priority rationale