PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.13.1
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.13.1
2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 All 80 releases
ablocks / includes / classes / page-cache / rules.php

rules.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.13.1, at includes/classes/page-cache/rules.php

513 lines 16.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Classes\PageCache;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 use ABlocks\Helper;
9
10 /**
11 * Page Cache — the single source of truth for "may this request be cached?".
12 *
13 * Split into two layers on purpose:
14 *
15 * - {@see Rules::request_bypass_reason()} uses **superglobals only**. All three
16 * serve tiers (plugins_loaded, the advanced-cache.php drop-in, and the nginx
17 * rule) run before pluggable functions exist, so this layer must never call
18 * is_user_logged_in() or any template conditional. Writing it once against
19 * $_SERVER/$_COOKIE keeps every tier making the identical decision — if they
20 * ever disagree, a logged-in visitor gets served an anonymous page.
21 *
22 * - {@see Rules::response_bypass_reason()} is WordPress-aware and runs only at
23 * write time, once the query and the response are known.
24 *
25 * Both return a short machine-readable reason string (or null when cacheable)
26 * rather than a bool, so `wp ablocks cache status` and the debug header can say
27 * *why* a page is not being cached. That is the difference between a five-minute
28 * and a two-hour support thread.
29 *
30 * Security note: these rules are not merely a correctness feature. Cached files
31 * live under uploads/ and are web-reachable, so the invariant "only ever cache
32 * the fully-anonymous view of an already-public URL" is what makes that safe.
33 * Weakening any check below is a security change. See docs/PAGE-CACHE-PLAN.md §3.2.
34 */
35 class Rules {
36
37 /**
38 * Cookie name prefixes that mean "this response is personalised".
39 *
40 * Matched as prefixes because WordPress suffixes most of these with a hash
41 * of the site URL (wordpress_logged_in_a1b2c3...).
42 */
43 const BYPASS_COOKIE_PREFIXES = [
44 // WordPress core: authenticated session, post password, comment author.
45 'wordpress_logged_in_',
46 'wordpressuser_',
47 'wordpresspass_',
48 'wp-postpass_',
49 'comment_author_',
50 'comment_author_email_',
51 // WordPress core: "your comment is awaiting moderation" needs a fresh render.
52 'wp-resetpass-',
53 // StoreEngine / WooCommerce: a cart cookie means the header cart count,
54 // and usually the page itself, differs per visitor.
55 'storeengine_cart',
56 'storeengine_session',
57 'woocommerce_items_in_cart',
58 'woocommerce_cart_hash',
59 'wp_woocommerce_session_',
60 ];
61
62 /**
63 * Reasons that came from the last evaluation, for debugging output.
64 *
65 * @var string|null
66 */
67 private static $last_reason = null;
68
69 /**
70 * May this *request* be served from, or written to, the cache?
71 *
72 * Superglobals only — see the class docblock.
73 *
74 * @return string|null Bypass reason, or null when the request is cacheable.
75 */
76 public static function request_bypass_reason() {
77 if ( ! self::is_enabled() ) {
78 return self::remember( 'disabled' );
79 }
80
81 // Only GET. HEAD is deliberately excluded: serving a body for HEAD is
82 // wrong, and the win is nil.
83 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
84 if ( 'GET' !== $method ) {
85 return self::remember( 'method:' . strtolower( $method ) );
86 }
87
88 // A POST body on a GET is malformed; treat it as uncacheable rather than
89 // reasoning about it. Nothing here reads a value — only whether the
90 // superglobal is populated at all — so there is no input to verify or
91 // sanitize, and a nonce check would be meaningless on an anonymous
92 // cacheable request.
93 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Presence check only; no form data is read or acted on.
94 if ( ! empty( $_POST ) ) {
95 return self::remember( 'post-data' );
96 }
97
98 $cookie_reason = self::bypass_cookie_reason();
99 if ( null !== $cookie_reason ) {
100 return self::remember( $cookie_reason );
101 }
102
103 $query_reason = self::query_bypass_reason();
104 if ( null !== $query_reason ) {
105 return self::remember( $query_reason );
106 }
107
108 if ( self::is_excluded_url() ) {
109 return self::remember( 'excluded-url' );
110 }
111
112 return self::remember( null );
113 }
114
115 /**
116 * Convenience boolean wrapper around {@see Rules::request_bypass_reason()}.
117 */
118 public static function should_bypass_request() {
119 return null !== self::request_bypass_reason();
120 }
121
122 /**
123 * May this *response* be written to the cache?
124 *
125 * WordPress-aware; safe to call only at write time (shutdown), when the main
126 * query has run and the status code is final.
127 *
128 * @return string|null Bypass reason, or null when the response is cacheable.
129 */
130 public static function response_bypass_reason() {
131 // Everything the request-level layer rejects, the response layer does too.
132 $request_reason = self::request_bypass_reason();
133 if ( null !== $request_reason ) {
134 return $request_reason;
135 }
136
137 // Contexts that never produce a cacheable public page.
138 if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
139 return self::remember( 'admin-context' );
140 }
141 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
142 return self::remember( 'rest-request' );
143 }
144 if ( defined( 'WP_CLI' ) && \WP_CLI ) {
145 return self::remember( 'wp-cli' );
146 }
147 if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
148 return self::remember( 'xmlrpc' );
149 }
150
151 // The de-facto standard opt-out other plugins set when they know a
152 // response is personalised. Respecting it is what makes us a good citizen.
153 if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
154 return self::remember( 'donotcachepage' );
155 }
156
157 // Belt and braces: the cookie check above catches logged-in visitors
158 // without loading pluggable functions, but by write time we can ask
159 // properly, and a session could have been established mid-request.
160 if ( is_user_logged_in() ) {
161 return self::remember( 'logged-in' );
162 }
163
164 // Never freeze a non-200 into a file that a later request would serve as 200.
165 $status = self::response_status();
166 if ( 200 !== $status ) {
167 return self::remember( 'status:' . $status );
168 }
169
170 // Query types that are either personalised, unbounded, or not worth caching.
171 if ( is_preview() ) {
172 return self::remember( 'preview' );
173 }
174 if ( is_404() ) {
175 return self::remember( '404' );
176 }
177 if ( is_search() ) {
178 return self::remember( 'search' );
179 }
180 if ( is_feed() || is_robots() || is_trackback() ) {
181 return self::remember( 'non-html' );
182 }
183 if ( function_exists( 'is_embed' ) && is_embed() ) {
184 return self::remember( 'embed' );
185 }
186 if ( is_customize_preview() ) {
187 return self::remember( 'customizer' );
188 }
189
190 // Password-protected content: the unlocked view must never reach disk.
191 if ( is_singular() ) {
192 $post = get_post();
193 if ( $post instanceof \WP_Post ) {
194 if ( 'publish' !== $post->post_status ) {
195 return self::remember( 'status:' . $post->post_status );
196 }
197 if ( ! empty( $post->post_password ) || post_password_required( $post ) ) {
198 return self::remember( 'password-protected' );
199 }
200 }
201 }
202
203 // A response that sets a cookie is establishing per-visitor state, so the
204 // body almost certainly depends on it. headers_list() catches raw header()
205 // calls that never touch $_COOKIE.
206 if ( self::response_sets_cookie() ) {
207 return self::remember( 'sets-cookie' );
208 }
209
210 $scope_reason = self::scope_bypass_reason();
211 if ( null !== $scope_reason ) {
212 return self::remember( $scope_reason );
213 }
214
215 $host_reason = self::host_bypass_reason();
216 if ( null !== $host_reason ) {
217 return self::remember( $host_reason );
218 }
219
220 // Logged-in editors previewing the frontend are excluded from every other
221 // Performance Suite feature (see DelayJs); stay consistent. This is
222 // unreachable while the is_user_logged_in() check above stands, but both
223 // are cheap and the intent should survive future edits to either.
224 if ( is_user_logged_in() && current_user_can( 'edit_posts' )
225 && (bool) apply_filters( 'ablocks/perf/bypass_optimizations_for_editors', true ) ) {
226 return self::remember( 'editor' );
227 }
228
229 return self::remember( null );
230 }
231
232 /**
233 * Is the response body itself cacheable?
234 *
235 * Guards against freezing a truncated page — a fatal error, an uncaught
236 * exception or a bare exit() mid-render produces output that looks fine to
237 * every check above but is missing its closing tags. Caching that would serve
238 * a broken page to everyone until the next purge, which is the single worst
239 * failure mode this feature has.
240 *
241 * @param string $html Buffered output.
242 * @return string|null Bypass reason, or null when the body is cacheable.
243 */
244 public static function body_bypass_reason( $html ) {
245 if ( strlen( $html ) < 255 ) {
246 return self::remember( 'body-too-short' );
247 }
248 if ( false === stripos( $html, '</html>' ) ) {
249 return self::remember( 'body-truncated' );
250 }
251 return self::remember( null );
252 }
253
254 /**
255 * The last bypass reason recorded, for debug headers and CLI output.
256 *
257 * @return string|null
258 */
259 public static function last_reason() {
260 return self::$last_reason;
261 }
262
263 /**
264 * Is the page cache switched on?
265 */
266 public static function is_enabled() {
267 $enabled = (bool) Helper::get_settings( 'perf_page_cache', false );
268 return (bool) apply_filters( 'ablocks/perf/perf_page_cache', $enabled );
269 }
270
271 /**
272 * Cookie prefixes that force a bypass. Filterable so a site can add its own
273 * personalisation cookie (membership plugins, geo redirectors, A/B tools).
274 *
275 * @return string[]
276 */
277 public static function bypass_cookie_prefixes() {
278 return (array) apply_filters( 'ablocks/perf/page_cache/bypass_cookies', self::BYPASS_COOKIE_PREFIXES );
279 }
280
281 /**
282 * Query args that may appear without disabling the cache.
283 *
284 * Deliberately empty by default: any unrecognised query string bypasses. The
285 * alternative — ignoring unknown args — lets ?utm_source=x overwrite the
286 * canonical entry for a URL, which is cache poisoning by typo.
287 *
288 * @return string[]
289 */
290 public static function allowed_query_args() {
291 $allowed = (array) Helper::get_settings( 'perf_page_cache_query_args', [] );
292 return array_filter( array_map( 'strval', (array) apply_filters( 'ablocks/perf/page_cache/allowed_query_args', $allowed ) ) );
293 }
294
295 /**
296 * Is the request's Host header one this site actually answers to?
297 *
298 * HTTP_HOST is attacker-controlled. Store::sanitize_host() already guarantees
299 * containment — a spoofed `Host: ../../evil` cannot escape the cache
300 * directory — but containment alone still lets an attacker create an
301 * unbounded number of junk directories by varying the header, which is a
302 * slow disk-fill. It also has no legitimate use: a request for a host we do
303 * not serve should not populate the cache.
304 *
305 * Checked at write time only. Writing is the sole operation that creates
306 * directories, so gating it here closes the vector without adding a database
307 * read to the serve path (which must stay callable before WordPress loads).
308 * A serve-time request for an unknown host simply finds no file.
309 *
310 * @return string|null
311 */
312 private static function host_bypass_reason() {
313 $request_host = isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : '';
314 $request_host = self::normalize_host( is_string( $request_host ) ? $request_host : '' );
315
316 if ( '' === $request_host ) {
317 return 'host-missing';
318 }
319
320 $allowed = [];
321 foreach ( [ home_url(), site_url() ] as $known ) {
322 $parts = wp_parse_url( $known );
323 if ( ! empty( $parts['host'] ) ) {
324 $allowed[] = self::normalize_host( $parts['host'] );
325 }
326 }
327
328 // Sites behind a proxy, CDN or domain alias legitimately serve more than
329 // one host; they add theirs here rather than losing the cache entirely.
330 $allowed = array_filter( array_map( [ __CLASS__, 'normalize_host' ], (array) apply_filters( 'ablocks/perf/page_cache/allowed_hosts', $allowed ) ) );
331
332 if ( ! in_array( $request_host, $allowed, true ) ) {
333 return 'host-mismatch';
334 }
335
336 return null;
337 }
338
339 /**
340 * Lowercase a host and drop any port, for comparison purposes.
341 *
342 * The port is deliberately kept in the *directory* name by Store, so that a
343 * :8080 dev site cannot collide with production; it is only stripped here,
344 * where the question is which site the request is for.
345 *
346 * @param string $host Raw host, possibly with a port.
347 * @return string
348 */
349 public static function normalize_host( $host ) {
350 $host = strtolower( trim( (string) $host ) );
351 $host = preg_replace( '/:\d+$/', '', $host );
352 return (string) $host;
353 }
354
355 /**
356 * Does the configured coverage scope exclude this page?
357 *
358 * Default scope is `all`, which is what anyone flipping a switch labelled
359 * "Page Cache" expects. `ablocks_only` is the conservative mode: cache just
360 * the pages this plugin actually renders, so the blast radius is limited to
361 * content aBlocks is responsible for.
362 *
363 * Only singular content can be judged this way — archives and the front page
364 * assemble many posts plus template parts, so a content scan there would be
365 * both expensive and wrong. Those are cached under either scope.
366 *
367 * @return string|null
368 */
369 private static function scope_bypass_reason() {
370 $scope = (string) Helper::get_settings( 'perf_page_cache_scope', 'all' );
371 $scope = (string) apply_filters( 'ablocks/perf/page_cache/scope', $scope );
372
373 if ( 'ablocks_only' !== $scope || ! is_singular() ) {
374 return null;
375 }
376
377 $post = get_post();
378 if ( ! $post instanceof \WP_Post ) {
379 return null;
380 }
381
382 // `wp:block` counts: a reusable block may wrap aBlocks blocks, and the
383 // same allowance is made in Blocks::prewarm_page_assets().
384 $content = (string) $post->post_content;
385 if ( false === strpos( $content, 'wp:ablocks' ) && false === strpos( $content, 'wp:block' ) ) {
386 return 'scope:no-ablocks-blocks';
387 }
388
389 return null;
390 }
391
392 /**
393 * Does a bypass cookie exist on this request?
394 *
395 * @return string|null
396 */
397 private static function bypass_cookie_reason() {
398 if ( empty( $_COOKIE ) || ! is_array( $_COOKIE ) ) {
399 return null;
400 }
401 $prefixes = self::bypass_cookie_prefixes();
402 foreach ( array_keys( $_COOKIE ) as $name ) {
403 $name = (string) $name;
404 foreach ( $prefixes as $prefix ) {
405 if ( 0 === strpos( $name, $prefix ) ) {
406 // The reason names the prefix, never the cookie value.
407 return 'cookie:' . $prefix;
408 }
409 }
410 }
411 return null;
412 }
413
414 /**
415 * Does the query string disqualify this request?
416 *
417 * @return string|null
418 */
419 private static function query_bypass_reason() {
420 // Only argument *names* are inspected, never values, and nothing is acted
421 // on beyond declining to cache — so there is no input to sanitize and a
422 // nonce would be meaningless on an anonymous cacheable request.
423 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Reads query-arg names only, to decide cacheability.
424 if ( empty( $_GET ) || ! is_array( $_GET ) ) {
425 return null;
426 }
427 $allowed = self::allowed_query_args();
428 foreach ( array_keys( $_GET ) as $arg ) {
429 if ( ! in_array( (string) $arg, $allowed, true ) ) {
430 return 'query-arg';
431 }
432 }
433 // phpcs:enable WordPress.Security.NonceVerification.Recommended
434 return null;
435 }
436
437 /**
438 * Does the request path match a user-configured exclusion pattern?
439 *
440 * Patterns are simple wildcards (`/shop/*`), not regular expressions — a
441 * malformed regex in a settings field would otherwise take the site down.
442 */
443 private static function is_excluded_url() {
444 $patterns = (array) Helper::get_settings( 'perf_page_cache_exclusions', [] );
445 $patterns = (array) apply_filters( 'ablocks/perf/page_cache/exclusions', $patterns );
446 if ( empty( $patterns ) ) {
447 return false;
448 }
449 $path = self::request_path();
450 foreach ( $patterns as $pattern ) {
451 $pattern = trim( (string) $pattern );
452 if ( '' === $pattern ) {
453 continue;
454 }
455 if ( fnmatch( $pattern, $path ) ) {
456 return true;
457 }
458 }
459 return false;
460 }
461
462 /**
463 * The current request path, without query string, always leading-slashed.
464 *
465 * @return string
466 */
467 public static function request_path() {
468 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/';
469 $uri = is_string( $uri ) ? $uri : '/';
470 $path = (string) strtok( $uri, '?' );
471 if ( '' === $path || '/' !== $path[0] ) {
472 $path = '/' . $path;
473 }
474 return $path;
475 }
476
477 /**
478 * Final HTTP status for this response.
479 *
480 * @return int
481 */
482 private static function response_status() {
483 $status = function_exists( 'http_response_code' ) ? http_response_code() : 200;
484 return is_int( $status ) ? $status : 200;
485 }
486
487 /**
488 * Has anything queued a Set-Cookie header on this response?
489 */
490 private static function response_sets_cookie() {
491 if ( ! function_exists( 'headers_list' ) ) {
492 return false;
493 }
494 foreach ( headers_list() as $header ) {
495 if ( 0 === stripos( $header, 'set-cookie:' ) ) {
496 return true;
497 }
498 }
499 return false;
500 }
501
502 /**
503 * Record and return a reason, so callers can chain `return self::remember(...)`.
504 *
505 * @param string|null $reason Bypass reason or null.
506 * @return string|null
507 */
508 private static function remember( $reason ) {
509 self::$last_reason = $reason;
510 return $reason;
511 }
512 }
513