PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-edge-provider.php

class-edge-provider.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.3, at includes/class-edge-provider.php

662 lines 25.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * What cache is in front of this site, and how sure are we?
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * Engine infrastructure, sat beside Server rather than built as a Module: it
14 * owns no settings, routes or UI of its own, and class-cache.php is already
15 * seven thousand lines.
16 *
17 * The answer exists so `Cache::edge_headers_for()` can pick the vocabulary a
18 * hold is said in. That is a smaller job than it sounds, and it is worth
19 * being honest about why: every provider-specific set is a SUBSET of the
20 * generic one, with Akamai the single exception. Naming a provider mostly
21 * removes headers that were inert anyway. The protection comes from the
22 * `Cache-Control` pair every set carries, not from getting the provider
23 * right — which is exactly what makes it safe to guess from a request header
24 * an attacker can forge.
25 */
26 final class Edge_Provider {
27
28 /** One provider, named, on evidence we trust. */
29 public const CONFIRMED = 'confirmed';
30
31 /** Something is in front of us; which, we cannot say. */
32 public const PROXY = 'proxy';
33
34 /** No evidence of anything. Not the same as proof there is nothing. */
35 public const NONE = 'none';
36
37 /** The provider slug meaning "I will write the headers myself". */
38 public const CUSTOM = 'custom';
39
40 /**
41 * Sent on every hold, whatever the provider and whether or not one was
42 * confirmed.
43 *
44 * `private` is the form every shared cache understands, `s-maxage=0` the
45 * numeric form for one that only parses ages, and `no-cache` makes a
46 * browser revalidate rather than reuse an un-optimized render.
47 *
48 * Never `no-store`. `no-store` is what disables bfcache in Firefox and
49 * Safari and turns the back button into a refetch, and nothing here needs
50 * it: the audience is shared caches, not the browser. It is also what
51 * closes the forged-provider case — a visitor who fakes `CF-Ray` through
52 * some other CDN changes which inert targeted header goes out and nothing
53 * else, because this pair rides along regardless.
54 */
55 public const CACHE_CONTROL = 'private, no-cache, s-maxage=0';
56
57 /**
58 * The instruction to the ORIGIN SERVER, sent on every hold alongside
59 * whatever the CDN set turns out to be.
60 *
61 * This is not a CDN header and it does not belong to the provider
62 * vocabulary below. `X-Accel-Expires` is read by nginx itself — by
63 * `fastcgi_cache` and `proxy_cache` — and nginx consumes it and strips it
64 * from the response, so it never reaches a CDN or a browser to be
65 * misread. That is why it rides along instead of narrowing: a CDN and an
66 * nginx page cache are two LAYERS, not two candidates, and a site can
67 * have both at once.
68 *
69 * It is the only thing we can say that such a cache will act on. A host
70 * that runs a full-page cache in nginx configures it with
71 * `fastcgi_ignore_headers Cache-Control Expires Set-Cookie` — that
72 * directive exists precisely so WordPress's own `Cache-Control: no-cache`
73 * cannot defeat the cache, and it means CACHE_CONTROL above is discarded
74 * unread. `X-Accel-Expires` is deliberately NOT in that ignore list, and
75 * it outranks `Cache-Control` and `Expires` when nginx decides what to
76 * store; `0` means do not store this.
77 *
78 * Every set carries it, custom lists included — see
79 * custom_hold_headers() for why that one is not an exception.
80 *
81 * Bug history: this used to live in GENERIC, which the targeted sets
82 * REPLACE rather than extend. So a site behind a CDN we successfully
83 * identified — the overwhelmingly common case on managed nginx hosting,
84 * where Cloudflare sits in front of an nginx FastCGI cache — sent the CDN
85 * its refusal and told nginx nothing at all. The edge honoured the hold
86 * and the origin cache stored the very page we had refused to cache.
87 */
88 private const ORIGIN = array( 'X-Accel-Expires' => '0' );
89
90 /**
91 * The targeted headers that are inert wherever they are not understood,
92 * sent when no provider was confirmed.
93 *
94 * Two are deliberately absent, and the second one is the important one.
95 *
96 * `Edge-Control` is out because its behaviour on a non-Akamai proxy
97 * cannot be vouched for.
98 *
99 * `Surrogate-Control` is out because it is not inert on Cloudflare — it
100 * is destructive. Cloudflare's own documentation: "if the
101 * `Surrogate-Control` header is present within the response, Cloudflare
102 * ignores any `Cache-Control` directives, even if the `Surrogate-Control`
103 * header does not contain directives." So including it in the set we send
104 * when we are NOT sure who is listening would, on exactly the sites we
105 * failed to identify as Cloudflare, throw away the `Cache-Control` line
106 * that is doing the actual protecting. And it buys nothing in exchange:
107 * Fastly does not honour `Surrogate-Control: no-store` either — its
108 * supported parameters are `max-age`, `stale-if-error` and
109 * `stale-while-revalidate`, and what stops Fastly storing a response is
110 * `Cache-Control: private`.
111 *
112 * It is still sent to a positively identified Fastly or Varnish, where
113 * there is no Cloudflare to confuse and stock `builtin.vcl` does act on
114 * it. Being unsure is the case it must stay out of.
115 */
116 private const GENERIC = array(
117 'CDN-Cache-Control' => 'no-store',
118 );
119
120 /**
121 * The targeted header each provider reads, on top of CACHE_CONTROL.
122 *
123 * A provider absent from this map reads plain `Cache-Control` and nothing
124 * else, which is not a gap — it is the answer for CloudFront, Google
125 * Cloud CDN, KeyCDN, Bunny, Sucuri and Imperva alike.
126 */
127 private const TARGETED = array(
128 // `cf-edge-cache` is what Cloudflare APO reads. Their own plugin
129 // emits it on every request, and the comment there says why: the
130 // header doubles as a capability handshake, since APO is enabled for
131 // a zone on seeing it. So a site running APO without that plugin gets
132 // no instruction from `CDN-Cache-Control` alone.
133 //
134 // Only ever the refusal. The positive form — `cache,platform=wordpress`
135 // — is what turns APO on for a zone, and that is the site owner's
136 // decision to make in their dashboard, not ours to make from a
137 // response header.
138 //
139 // Safe to send alongside Cloudflare's own plugin: it emits on `init`
140 // (cloudflare.loader.php), we emit from mark() on `template_redirect`,
141 // and header() replaces by default — so where the two disagree ours
142 // is the later word. That is the right way round, because their test
143 // is `! is_user_logged_in()` and ours also knows about a cart cookie,
144 // an excluded URL and a device-split render.
145 'cloudflare' => array( 'CDN-Cache-Control' => 'no-store', 'cf-edge-cache' => 'no-cache' ),
146 // Google Cloud CDN reads CDN-Cache-Control and, where it is present,
147 // uses it exclusively and ignores the standard headers beneath it.
148 'google' => array( 'CDN-Cache-Control' => 'no-store' ),
149 // Not what protects the response — `Cache-Control: private` is, on
150 // both. Sent anyway because stock Varnish `builtin.vcl` does act on
151 // it and it costs nothing here, where Cloudflare is ruled out.
152 'fastly' => array( 'Surrogate-Control' => 'no-store' ),
153 'varnish' => array( 'Surrogate-Control' => 'no-store' ),
154 // `nginx` is deliberately absent: its header is ORIGIN, which every
155 // set carries anyway. Leaving an entry here would say that pinning
156 // `nginx` buys something the other providers do not get, and since
157 // the pin is the only way to reach that slug — detection never
158 // confirms nginx from a request — that would be misleading.
159 // Only meaningful on a property configured to honour origin cache
160 // headers, which is not the Akamai default. Pin-only for that reason;
161 // see the note on SIGNALS.
162 'akamai' => array( 'Edge-Control' => 'no-store' ),
163 );
164
165 /**
166 * Slugs that name an actual product, for matching a `CDN-Loop` token.
167 *
168 * Narrower than KNOWN on purpose: `generic` and `custom` name a policy
169 * rather than a company, and a request carrying `CDN-Loop: custom` would
170 * otherwise make us read the admin's own header box.
171 */
172 private const VENDORS = array(
173 'cloudflare',
174 'fastly',
175 'varnish',
176 'akamai',
177 'cloudfront',
178 'keycdn',
179 'bunny',
180 'sucuri',
181 'incapsula',
182 );
183
184 /**
185 * Providers a human is allowed to name, through the constant or the
186 * filter. `generic` means "something is there, send the blind set, stop
187 * guessing"; it is not a vendor.
188 */
189 private const KNOWN = array(
190 'cloudflare',
191 'fastly',
192 'varnish',
193 'nginx',
194 'akamai',
195 'cloudfront',
196 'google',
197 'keycdn',
198 'bunny',
199 'sucuri',
200 'incapsula',
201 'generic',
202 'custom',
203 );
204
205 /**
206 * Request headers that name their provider outright, in probe order.
207 *
208 * `CF-Ray` is checked by shape rather than presence because it is the one
209 * most likely to be forwarded verbatim by an unrelated CDN.
210 *
211 * Akamai is absent on purpose. `Akamai-Origin-Hop` appears nowhere in
212 * Akamai's documentation — their documented way for an origin to
213 * recognise an edge request is an opt-in cookie, configured per property
214 * — so detecting on it would be a guess dressed as a confirmation. And
215 * Akamai does not honour origin cache headers by default in any case:
216 * a property must have "Honor origin Cache-Control and Expires" turned on
217 * before anything we send matters. So Akamai is reachable by pin only,
218 * where a human has confirmed both facts for their own property.
219 *
220 * @var array<int,array{key:string,provider:string,pattern?:string}>
221 */
222 private const SIGNALS = array(
223 array( 'key' => 'HTTP_CF_RAY', 'provider' => 'cloudflare', 'pattern' => '/^[0-9a-f]{16}-[A-Z]{3}$/' ),
224 array( 'key' => 'HTTP_CF_CONNECTING_IP', 'provider' => 'cloudflare' ),
225 array( 'key' => 'HTTP_X_VARNISH', 'provider' => 'varnish' ),
226 // AWS documents this one explicitly: "CloudFront adds the header to
227 // the viewer request before forwarding the request to your origin."
228 array( 'key' => 'HTTP_X_AMZ_CF_ID', 'provider' => 'cloudfront' ),
229 // Documented as sent to the origin on every pull.
230 array( 'key' => 'HTTP_CDN_SERVERID', 'provider' => 'bunny' ),
231 array( 'key' => 'HTTP_X_SUCURI_CLIENTIP', 'provider' => 'sucuri' ),
232 array( 'key' => 'HTTP_INCAP_CLIENT_IP', 'provider' => 'incapsula' ),
233 array( 'key' => 'HTTP_X_PULL', 'provider' => 'keycdn', 'pattern' => '/keycdn/i' ),
234 );
235
236 /**
237 * Headers that prove something is in front of us without naming it well
238 * enough to act on.
239 *
240 * These were all in the list above, confirming a provider, until the
241 * vendor documentation was actually read. None of them is documented as a
242 * header the CDN adds to every origin request:
243 *
244 * `Fastly-FF` is the Fastly-to-Fastly marker, which in practice means
245 * shielding rather than "this request came from Fastly"; Fastly documents
246 * no header it adds to every origin request at all. `Fastly-Client-IP`
247 * and `Fastly-SSL` are in the same position.
248 *
249 * Confirming Fastly wrongly is not free, which is why these moved rather
250 * than being left alone: a confirmed Fastly is sent `Surrogate-Control`,
251 * and `Surrogate-Control` on a site actually behind Cloudflare makes
252 * Cloudflare discard every `Cache-Control` directive in the response.
253 * The guess would take out the header doing the protecting.
254 *
255 * @var string[]
256 */
257 private const PROXY_SIGNALS = array(
258 'HTTP_FASTLY_FF',
259 'HTTP_FASTLY_CLIENT_IP',
260 'HTTP_FASTLY_SSL',
261 );
262
263 /**
264 * Resolved answers, keyed by context.
265 *
266 * Memoised and never persisted. `Server::type()` writes its answer to an
267 * option because SERVER_SOFTWARE comes from the web server and can be
268 * trusted; a request header cannot, so nothing here outlives the request.
269 *
270 * @var array<string,array{provider:string,confidence:string,source:string}>
271 */
272 private static $memo = array();
273
274 /**
275 * Who is in front of us.
276 *
277 * @param string $context `request`, `store` or `bake`.
278 * @return array{provider:string,confidence:string,source:string}
279 */
280 public static function detect( string $context = 'request' ): array {
281 if ( isset( self::$memo[ $context ] ) ) {
282 return self::$memo[ $context ];
283 }
284
285 self::$memo[ $context ] = self::resolve( $context );
286
287 return self::$memo[ $context ];
288 }
289
290 /**
291 * The hold pairs for a provider, before sanitising.
292 *
293 * @param string $provider A slug from KNOWN, or '' for the blind set.
294 * @return array<string,string>
295 */
296 public static function hold_headers( string $provider ): array {
297 if ( self::CUSTOM === $provider ) {
298 return self::custom_hold_headers();
299 }
300
301 $targeted = self::TARGETED[ $provider ] ?? null;
302
303 if ( null === $targeted ) {
304 // Either a provider that reads plain Cache-Control and nothing
305 // else, or no provider at all. The two differ in how much we
306 // send, not in what protects the page.
307 $targeted = ( '' === $provider || 'generic' === $provider ) ? self::GENERIC : array();
308 }
309
310 // ORIGIN rides along rather than narrowing — see the constant. The
311 // CDN set answers "what is in front of the site"; ORIGIN answers
312 // "what is on the site's own box", and identifying the first tells
313 // us nothing about the second.
314 return array_merge( $targeted, self::ORIGIN, array( 'Cache-Control' => self::CACHE_CONTROL ) );
315 }
316
317 /**
318 * The pairs a site owner wrote out themselves.
319 *
320 * For an edge we do not know about, or one whose operator wants
321 * different wording than ours. Written one `Name: value` per line,
322 * because that is what the thing being configured actually looks like and
323 * anyone reaching for this has read their CDN's docs in that form.
324 *
325 * Two rules keep it from being a way to make things worse. An empty or
326 * unusable box falls back to the blind set rather than to silence: an
327 * operator who picked "custom" asked for MORE control over the hold, not
328 * for the hold to stop. And the baseline `Cache-Control` is added when
329 * they did not write one of their own, because that pair is what protects
330 * the page when the targeted header is not understood — but theirs wins
331 * if they did write one, since overriding an explicit choice is how a
332 * settings field becomes a lie.
333 *
334 * Not sanitised here: Cache::edge_headers_for() puts everything through
335 * sanitize_edge_headers() on the way out, so a value carrying CR/LF is
336 * dropped at the one place that cannot be bypassed.
337 *
338 * @return array<string,string>
339 */
340 private static function custom_hold_headers(): array {
341 // Stored as a list of lines, which is how every other multi-line
342 // field in the schema is shaped. A plain string is accepted too, so a
343 // wp-config filter or a CLI write does not have to know that.
344 $raw = self::setting( 'edge_custom_headers' );
345 if ( is_array( $raw ) ) {
346 $raw = implode( "\n", array_filter( $raw, 'is_string' ) );
347 }
348 $custom = self::parse_header_lines( is_string( $raw ) ? $raw : '' );
349
350 if ( array() === $custom ) {
351 // `custom` selected but nothing written yet. That is not an
352 // instruction, so fall back to the blind set exactly — ORIGIN
353 // included, or choosing `custom` and leaving the box empty would
354 // quietly drop the origin-cache refusal.
355 return array_merge( self::GENERIC, self::ORIGIN, array( 'Cache-Control' => self::CACHE_CONTROL ) );
356 }
357
358 /*
359 * A list the operator wrote still gets both baselines added under
360 * it, for the same reason: choosing `custom` asks for more control
361 * over what the CDN is told, not for the site's own origin cache to
362 * start keeping pages we refused.
363 *
364 * This was originally left as the operator's exact word, and a live
365 * xCloud site showed why that was wrong. Writing out the three
366 * headers a Cloudflare hold sends — and leaving `X-Accel-Expires`
367 * out, because nothing in the UI is shaped to make you think of it —
368 * is enough for nginx to store the first un-optimized render and
369 * replay it for the full `fastcgi_cache_valid` window. It seals
370 * itself in: the stored copy still carries `no-store`, so the CDN
371 * keeps bypassing and sends every visitor to the origin that is
372 * serving the stale page.
373 *
374 * Either baseline is still overridable by naming it. Someone who
375 * writes `X-Accel-Expires: 30` means 30, the same way someone who
376 * writes their own `Cache-Control` means that.
377 */
378
379 $has_cache_control = false;
380 $has_origin = false;
381 foreach ( array_keys( $custom ) as $name ) {
382 if ( 0 === strcasecmp( $name, 'Cache-Control' ) ) {
383 $has_cache_control = true;
384 }
385 if ( 0 === strcasecmp( $name, 'X-Accel-Expires' ) ) {
386 $has_origin = true;
387 }
388 }
389
390 if ( ! $has_origin ) {
391 $custom = array_merge( $custom, self::ORIGIN );
392 }
393 if ( ! $has_cache_control ) {
394 $custom = array_merge( $custom, array( 'Cache-Control' => self::CACHE_CONTROL ) );
395 }
396
397 return $custom;
398 }
399
400 /**
401 * Read `Name: value` lines into pairs.
402 *
403 * Blank lines and `#` comments are skipped so someone can annotate their
404 * own box. A line with no colon is dropped rather than guessed at.
405 *
406 * @return array<string,string>
407 */
408 public static function parse_header_lines( string $raw ): array {
409 $pairs = array();
410 foreach ( preg_split( '/\r\n|\r|\n/', $raw ) ?: array() as $line ) {
411 $line = trim( $line );
412 if ( '' === $line || 0 === strpos( $line, '#' ) ) {
413 continue;
414 }
415 $colon = strpos( $line, ':' );
416 if ( false === $colon || 0 === $colon ) {
417 continue;
418 }
419 $name = trim( substr( $line, 0, $colon ) );
420 $value = trim( substr( $line, $colon + 1 ) );
421 if ( '' === $name || '' === $value ) {
422 continue;
423 }
424 $pairs[ $name ] = $value;
425 }
426
427 return $pairs;
428 }
429
430 /**
431 * What the request alone says, ignoring every pin above it.
432 *
433 * Exists so a pinned provider can be checked against reality. A setting
434 * is the one signal that both outranks detection and gets frozen into a
435 * baked artifact, so a site that moves from one CDN to another keeps
436 * asserting the old one until someone notices. Nothing can re-check it
437 * automatically — that would defeat the point of pinning — but Health can
438 * say the two disagree, which is enough for someone to act on.
439 *
440 * @return array{provider:string,confidence:string,source:string}
441 */
442 public static function sniffed(): array {
443 if ( ! isset( self::$memo['__sniff'] ) ) {
444 self::$memo['__sniff'] = self::sniff();
445 }
446
447 return self::$memo['__sniff'];
448 }
449
450 /**
451 * Was this answer a deliberate "send nothing", rather than "we saw
452 * nothing"?
453 *
454 * Both come back as `none`, and the difference decides whether a hold
455 * fires anyway. Someone who switched this off gets silence on every
456 * reason; a site where detection simply found no marker still gets the
457 * holds that protect somebody's data, because an unseen proxy is not an
458 * absent one.
459 *
460 * @param array{provider:string,confidence:string,source:string} $answer From detect().
461 */
462 public static function is_off( array $answer ): bool {
463 return self::NONE === ( $answer['confidence'] ?? '' )
464 && in_array( $answer['source'] ?? '', array( 'setting', 'constant', 'filter' ), true );
465 }
466
467 /** Drop the memo. Tests, and the CLI after a setting write. */
468 public static function forget(): void {
469 self::$memo = array();
470 }
471
472 /**
473 * @return array{provider:string,confidence:string,source:string}
474 */
475 private static function resolve( string $context ): array {
476 /**
477 * Filter: xspeed_edge_provider
478 *
479 * Last word, because it is the most specific override there is: it
480 * runs in PHP, it can read the constant and the setting below it, and
481 * decide. Return a slug to pin one, `'off'` to send nothing, or null
482 * to let detection run.
483 *
484 * @param string|null $provider Provider slug, 'off', or null.
485 * @param string $context `request`, `store` or `bake`.
486 */
487 $filtered = apply_filters( 'xspeed_edge_provider', null, $context );
488 $pinned = self::pin( is_string( $filtered ) ? $filtered : '', 'filter' );
489 if ( null !== $pinned ) {
490 return $pinned;
491 }
492
493 // The only override that reaches the pre-plugin fast path, since
494 // advanced-cache.php runs before any filter exists — and the shape a
495 // managed host needs, one line in a templated wp-config.php rather
496 // than an mu-plugin shipped to every site in the fleet.
497 if ( defined( 'XSPEED_EDGE_PROVIDER' ) ) {
498 $pinned = self::pin( (string) constant( 'XSPEED_EDGE_PROVIDER' ), 'constant' );
499 if ( null !== $pinned ) {
500 return $pinned;
501 }
502 }
503
504 $pinned = self::pin( (string) ( self::setting( 'edge_provider' ) ?? 'auto' ), 'setting' );
505 if ( null !== $pinned ) {
506 return $pinned;
507 }
508
509 // Free's own Cloudflare module, or Cloudflare's official plugin. A
510 // grey-clouded zone makes this a false positive, which costs one
511 // inert header; the constant and the filter exist for the reverse
512 // case, another CDN in front of a connected Cloudflare.
513 //
514 // The Cdn module's hostname is deliberately not a signal: a pull zone
515 // serves assets and never sees the HTML this is about.
516 $cloudflare = get_option( 'xspeed_module_cloudflare', array() );
517 if ( is_array( $cloudflare ) && ! empty( $cloudflare['enabled'] ) ) {
518 return self::answer( 'cloudflare', self::CONFIRMED, 'plugin' );
519 }
520 $active = (array) get_option( 'active_plugins', array() );
521 if ( in_array( 'cloudflare/cloudflare.php', $active, true ) ) {
522 return self::answer( 'cloudflare', self::CONFIRMED, 'plugin' );
523 }
524
525 // Everything below reads the inbound request, so it is skipped
526 // outside `request`. A bake runs once in an admin or CLI request and
527 // answers for every page on the site; a sidecar is written from one
528 // visitor's request and replayed to every later visitor of that page.
529 // Neither may carry a provider that only a forgeable header vouched
530 // for, and a CLI bake has no headers to read in any case.
531 if ( 'request' !== $context ) {
532 return self::answer( '', self::NONE, 'context' );
533 }
534
535 return self::sniff();
536 }
537
538 /**
539 * @return array{provider:string,confidence:string,source:string}
540 */
541 private static function sniff(): array {
542 foreach ( self::SIGNALS as $signal ) {
543 $value = self::server_header( $signal['key'] );
544 if ( '' === $value ) {
545 continue;
546 }
547 if ( isset( $signal['pattern'] ) && ! preg_match( $signal['pattern'], $value ) ) {
548 // Present but the wrong shape. Something forwarded a header
549 // it does not own, which says a proxy is there without
550 // saying which.
551 return self::answer( '', self::PROXY, 'request' );
552 }
553 return self::answer( $signal['provider'], self::CONFIRMED, 'request' );
554 }
555
556 // RFC 8586. The only standards-track signal here, and the only one
557 // whose absence means anything, since a conforming intermediary must
558 // append itself.
559 $loop = strtolower( self::server_header( 'HTTP_CDN_LOOP' ) );
560 if ( '' !== $loop ) {
561 // Vendors only. KNOWN also holds `generic` and `custom`, which
562 // name a policy rather than a company — a request carrying
563 // `CDN-Loop: custom` would otherwise make us read the admin's own
564 // header box and report "custom (request)" back to them.
565 foreach ( self::VENDORS as $provider ) {
566 if ( false !== strpos( $loop, $provider ) ) {
567 return self::answer( $provider, self::CONFIRMED, 'request' );
568 }
569 }
570 return self::answer( '', self::PROXY, 'request' );
571 }
572
573 foreach ( self::PROXY_SIGNALS as $key ) {
574 if ( '' !== self::server_header( $key ) ) {
575 return self::answer( '', self::PROXY, 'request' );
576 }
577 }
578
579 // `Via` names a provider for exactly one of these. Bunny documents
580 // `Via: BunnyCDN` on requests to the origin. CloudFront does NOT:
581 // its own header table says it FORWARDS the viewer's `Via` to the
582 // origin and sets its own only on the response to the viewer, so
583 // matching `cloudfront` here would key on something a visitor can
584 // type. Google Cloud CDN documents no origin-side header at all.
585 $via = strtolower( self::server_header( 'HTTP_VIA' ) );
586 if ( false !== strpos( $via, 'bunnycdn' ) ) {
587 return self::answer( 'bunny', self::CONFIRMED, 'request' );
588 }
589 if ( '' !== $via ) {
590 return self::answer( '', self::PROXY, 'request' );
591 }
592
593 // True-Client-IP is sent by Akamai AND by Cloudflare Enterprise, so
594 // on its own it names nothing. It still proves someone is in front.
595 if ( '' !== self::server_header( 'HTTP_TRUE_CLIENT_IP' ) ) {
596 return self::answer( '', self::PROXY, 'request' );
597 }
598
599 // A host page cache in front of PHP leaves no marker of its own. The
600 // forwarding headers are the only trace, and they are why nginx is
601 // never `confirmed` from a request: X-Forwarded-For says a proxy
602 // exists, not that it caches.
603 if ( Server::is_behind_proxy() ) {
604 return self::answer( '', self::PROXY, 'request' );
605 }
606
607 return self::answer( '', self::NONE, 'request' );
608 }
609
610 /**
611 * Accept a slug a human named, or reject it and let detection continue.
612 *
613 * An unrecognised value is ignored rather than treated as `generic`: a
614 * typo in wp-config.php should not silently become a different policy
615 * from the one that was typed.
616 *
617 * @return array{provider:string,confidence:string,source:string}|null
618 */
619 private static function pin( string $value, string $source ): ?array {
620 $value = strtolower( trim( $value ) );
621 if ( 'off' === $value ) {
622 return self::answer( '', self::NONE, $source );
623 }
624 if ( in_array( $value, self::KNOWN, true ) ) {
625 return self::answer( $value, self::CONFIRMED, $source );
626 }
627
628 return null;
629 }
630
631 /**
632 * @return array{provider:string,confidence:string,source:string}
633 */
634 private static function answer( string $provider, string $confidence, string $source ): array {
635 return array(
636 'provider' => $provider,
637 'confidence' => $confidence,
638 'source' => $source,
639 );
640 }
641
642 /**
643 * One value from the Cache module's stored options.
644 *
645 * Read from the option directly rather than through Settings_Manager:
646 * this runs on the serve path, where the module registry may not have
647 * been built yet.
648 *
649 * @return mixed
650 */
651 private static function setting( string $key ) {
652 $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() );
653
654 return is_array( $stored ) ? ( $stored[ $key ] ?? null ) : null;
655 }
656
657 private static function server_header( string $key ): string {
658 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading transport metadata about who forwarded the request; there is no form here to nonce.
659 return isset( $_SERVER[ $key ] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) ) : '';
660 }
661 }
662