| 1 |
<?php |
| 2 |
/** |
| 3 |
* Whether the Cloudflare edge in front of the site obeys "do not store". |
| 4 |
* |
| 5 |
* xSpeed marks first renders, bypassed pages and per-visitor pages |
| 6 |
* `no-store` for the edge. That only works when the edge reads the site's |
| 7 |
* headers. A Cloudflare Cache Rule whose Edge TTL ignores the origin, or |
| 8 |
* Cloudflare Enterprise bought from xCloud with Edge Page Caching in |
| 9 |
* `override_origin` mode, stores every HTML response anyway, so a cart or |
| 10 |
* account page can be served to the next visitor. Nothing on the site can |
| 11 |
* read those settings, so this asks the edge directly: it requests a page |
| 12 |
* that answers `no-store` twice, and a cached second answer means the edge |
| 13 |
* ignores the site. |
| 14 |
* |
| 15 |
* The round-trips run from cron. Health only reads the stored verdict. |
| 16 |
* |
| 17 |
* @package XSpeed |
| 18 |
*/ |
| 19 |
|
| 20 |
namespace XSpeed; |
| 21 |
|
| 22 |
defined( 'ABSPATH' ) || exit; |
| 23 |
|
| 24 |
final class Edge_Mode_Probe { |
| 25 |
|
| 26 |
/** Stored verdict. */ |
| 27 |
public const TRANSIENT = 'xspeed_edge_mode_probe'; |
| 28 |
|
| 29 |
/** Token the probe page answers to, set for the length of one probe. */ |
| 30 |
private const TOKEN_TRANSIENT = 'xspeed_edge_mode_probe_token'; |
| 31 |
|
| 32 |
/** Query parameter the probe page answers on. */ |
| 33 |
public const PARAM = 'xspeed_edge_probe'; |
| 34 |
|
| 35 |
/** Cron hook for the background run. */ |
| 36 |
public const CRON_HOOK = 'xspeed_edge_mode_probe_refresh'; |
| 37 |
|
| 38 |
/** The edge stored a `no-store` page: it ignores the site's headers. */ |
| 39 |
public const IGNORES_ORIGIN = 'ignores_origin'; |
| 40 |
|
| 41 |
/** The edge did not store it. */ |
| 42 |
public const RESPECTS_ORIGIN = 'respects_origin'; |
| 43 |
|
| 44 |
/** No Cloudflare in front, as far as the probe could see. */ |
| 45 |
public const NOT_CLOUDFLARE = 'not_cloudflare'; |
| 46 |
|
| 47 |
/** The probe could not finish. */ |
| 48 |
public const UNKNOWN = 'unknown'; |
| 49 |
|
| 50 |
/** Answer the probe request, before anything else renders. */ |
| 51 |
public static function boot(): void { |
| 52 |
add_action( 'init', array( __CLASS__, 'maybe_answer' ), 0 ); |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* The verdict from the two answers' Cloudflare headers. |
| 57 |
* |
| 58 |
* Pure, so the rule is tested without a network. |
| 59 |
* |
| 60 |
* @param array{ray:string,status:string} $first First answer. |
| 61 |
* @param array{ray:string,status:string} $second Second answer. |
| 62 |
*/ |
| 63 |
public static function verdict( array $first, array $second ): string { |
| 64 |
if ( '' === $first['ray'] && '' === $second['ray'] ) { |
| 65 |
return self::NOT_CLOUDFLARE; |
| 66 |
} |
| 67 |
$stored = array( 'HIT', 'STALE', 'UPDATING', 'REVALIDATED' ); |
| 68 |
return in_array( strtoupper( $second['status'] ), $stored, true ) ? self::IGNORES_ORIGIN : self::RESPECTS_ORIGIN; |
| 69 |
} |
| 70 |
|
| 71 |
/** |
| 72 |
* The stored verdict. A cold one schedules a background run and reads |
| 73 |
* as unknown, so a dashboard load never waits on the edge. |
| 74 |
* |
| 75 |
* @return array{verdict:string,checked_at:int} |
| 76 |
*/ |
| 77 |
public static function cached(): array { |
| 78 |
$cached = get_transient( self::TRANSIENT ); |
| 79 |
if ( is_array( $cached ) && isset( $cached['verdict'] ) ) { |
| 80 |
return array( |
| 81 |
'verdict' => (string) $cached['verdict'], |
| 82 |
'checked_at' => (int) ( $cached['checked_at'] ?? 0 ), |
| 83 |
); |
| 84 |
} |
| 85 |
self::schedule(); |
| 86 |
return array( |
| 87 |
'verdict' => self::UNKNOWN, |
| 88 |
'checked_at' => 0, |
| 89 |
); |
| 90 |
} |
| 91 |
|
| 92 |
/** Queue one background run, unless one is pending. */ |
| 93 |
public static function schedule(): void { |
| 94 |
if ( ! function_exists( 'wp_next_scheduled' ) || wp_next_scheduled( self::CRON_HOOK ) ) { |
| 95 |
return; |
| 96 |
} |
| 97 |
wp_schedule_single_event( time() + 30, self::CRON_HOOK ); |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Run the probe: two requests through whatever is in front of the site. |
| 102 |
* |
| 103 |
* @return array{verdict:string,checked_at:int} |
| 104 |
*/ |
| 105 |
public static function run(): array { |
| 106 |
$token = function_exists( 'wp_generate_password' ) ? wp_generate_password( 16, false ) : bin2hex( random_bytes( 8 ) ); |
| 107 |
set_transient( self::TOKEN_TRANSIENT, $token, 2 * MINUTE_IN_SECONDS ); |
| 108 |
|
| 109 |
$url = add_query_arg( self::PARAM, $token, home_url( '/' ) ); |
| 110 |
$first = self::fetch( $url ); |
| 111 |
$second = null === $first ? null : self::fetch( $url ); |
| 112 |
delete_transient( self::TOKEN_TRANSIENT ); |
| 113 |
|
| 114 |
if ( null === $first || null === $second ) { |
| 115 |
$result = array( |
| 116 |
'verdict' => self::UNKNOWN, |
| 117 |
'checked_at' => time(), |
| 118 |
); |
| 119 |
set_transient( self::TRANSIENT, $result, HOUR_IN_SECONDS ); |
| 120 |
return $result; |
| 121 |
} |
| 122 |
|
| 123 |
$result = array( |
| 124 |
'verdict' => self::verdict( $first, $second ), |
| 125 |
'checked_at' => time(), |
| 126 |
); |
| 127 |
set_transient( self::TRANSIENT, $result, 12 * HOUR_IN_SECONDS ); |
| 128 |
return $result; |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* One request, or null when it failed. |
| 133 |
* |
| 134 |
* @param string $url Probe URL. |
| 135 |
* @return array{ray:string,status:string}|null |
| 136 |
*/ |
| 137 |
private static function fetch( string $url ): ?array { |
| 138 |
$res = wp_remote_get( |
| 139 |
$url, |
| 140 |
array( |
| 141 |
'timeout' => 10, |
| 142 |
'redirection' => 0, |
| 143 |
'headers' => Self_Traffic::headers( array( 'User-Agent' => 'xSpeed Health Probe/1.0' ) ), |
| 144 |
'cookies' => array(), |
| 145 |
'sslverify' => ! Cookie_Inspector::is_local_host( home_url( '/' ) ), |
| 146 |
) |
| 147 |
); |
| 148 |
if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { |
| 149 |
return null; |
| 150 |
} |
| 151 |
return array( |
| 152 |
'ray' => (string) wp_remote_retrieve_header( $res, 'cf-ray' ), |
| 153 |
'status' => (string) wp_remote_retrieve_header( $res, 'cf-cache-status' ), |
| 154 |
); |
| 155 |
} |
| 156 |
|
| 157 |
/** |
| 158 |
* Answer the probe with a small HTML page marked `no-store` everywhere. |
| 159 |
* Only while a probe is running, and only for its token. |
| 160 |
*/ |
| 161 |
public static function maybe_answer(): void { |
| 162 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- an anonymous probe; the token below is the check. |
| 163 |
$given = isset( $_GET[ self::PARAM ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::PARAM ] ) ) : ''; |
| 164 |
if ( ! self::answers( $given ) ) { |
| 165 |
return; |
| 166 |
} |
| 167 |
self::send_answer(); |
| 168 |
exit; |
| 169 |
} |
| 170 |
|
| 171 |
/** |
| 172 |
* Whether a request carrying this token is the running probe. |
| 173 |
* |
| 174 |
* @param string $given Token from the query string. |
| 175 |
*/ |
| 176 |
public static function answers( string $given ): bool { |
| 177 |
if ( '' === $given ) { |
| 178 |
return false; |
| 179 |
} |
| 180 |
$token = get_transient( self::TOKEN_TRANSIENT ); |
| 181 |
return is_string( $token ) && '' !== $token && hash_equals( $token, $given ); |
| 182 |
} |
| 183 |
|
| 184 |
/** The probe page's headers and body. */ |
| 185 |
public static function send_answer(): void { |
| 186 |
if ( ! headers_sent() ) { |
| 187 |
status_header( 200 ); |
| 188 |
header( 'Content-Type: text/html; charset=utf-8' ); |
| 189 |
header( 'Cache-Control: no-store, private' ); |
| 190 |
header( 'CDN-Cache-Control: no-store' ); |
| 191 |
header( 'Cloudflare-CDN-Cache-Control: no-store' ); |
| 192 |
} |
| 193 |
echo '<!doctype html><title>xSpeed edge probe</title>'; |
| 194 |
} |
| 195 |
|
| 196 |
/** |
| 197 |
* Health row when the edge stores pages it was told not to. Null |
| 198 |
* otherwise: a site whose edge obeys has nothing to act on. |
| 199 |
* |
| 200 |
* @param string $verdict Stored verdict. |
| 201 |
* @param bool $xcloud_cfe Cloudflare Enterprise comes from xCloud's |
| 202 |
* purge plugin on this site. |
| 203 |
* @return array{id:string,tone:string,label:string,detail:string}|null |
| 204 |
*/ |
| 205 |
public static function health_row( string $verdict, bool $xcloud_cfe ): ?array { |
| 206 |
if ( self::IGNORES_ORIGIN !== $verdict ) { |
| 207 |
return null; |
| 208 |
} |
| 209 |
$fix = $xcloud_cfe |
| 210 |
? 'This domain\'s Cloudflare Enterprise comes from xCloud. Ask xCloud to set its edge caching mode to respect origin (edge_ttl_mode: respect_origin), or turn Edge Page Caching off in xCloud.' |
| 211 |
: 'A Cloudflare Cache Rule probably sets Edge TTL to "Ignore cache-control header and use this TTL". Set it to "Respect origin TTL", or leave HTML out of that rule.'; |
| 212 |
return array( |
| 213 |
'id' => 'edge_mode', |
| 214 |
'tone' => Health::WARN, |
| 215 |
'label' => 'Cloudflare stores pages marked do-not-store', |
| 216 |
'detail' => 'A test page sent with "do not store" came back from Cloudflare\'s cache, so cart, account and other per-visitor pages can be served to the wrong visitor. ' . $fix, |
| 217 |
); |
| 218 |
} |
| 219 |
} |
| 220 |
|