| 1 |
<?php |
| 2 |
namespace ABlocks\Classes\PageCache; |
| 3 |
|
| 4 |
if ( ! defined( 'ABSPATH' ) ) { |
| 5 |
exit; |
| 6 |
} |
| 7 |
|
| 8 |
use ABlocks\Classes\FileUpload; |
| 9 |
use ABlocks\Classes\CacheBackend; |
| 10 |
|
| 11 |
/** |
| 12 |
* Page Cache — filesystem layout, reads, writes and purges. |
| 13 |
* |
| 14 |
* Files live at: |
| 15 |
* |
| 16 |
* uploads/ablocks_uploads/page-cache/{host}/{url-path}/index.html |
| 17 |
* index.html.gz |
| 18 |
* |
| 19 |
* The layout mirrors the URL path rather than hashing it. Hashing would be |
| 20 |
* simpler here, but nginx cannot compute a hash in a `try_files` rule, so a hash |
| 21 |
* layout permanently forecloses the server-level tier. Mirroring costs nothing |
| 22 |
* now and is the only layout all three tiers can share. |
| 23 |
* |
| 24 |
* Because nginx matches against its already-decoded `$uri`, the on-disk path |
| 25 |
* must be the *decoded* URL path — including UTF-8 slugs. That rules out |
| 26 |
* sanitising by transliteration or hashing unsafe segments; instead we reject |
| 27 |
* anything that cannot be represented safely. See {@see Store::sanitize_segment()}. |
| 28 |
* |
| 29 |
* Writes are direct filesystem calls rather than WP_Filesystem. This is |
| 30 |
* deliberate: WP_Filesystem cannot do an atomic rename, may fall back to an FTP |
| 31 |
* transport that prompts for credentials, and requires loading wp-admin includes |
| 32 |
* on a frontend request. A page cache needs write-then-rename so a concurrent |
| 33 |
* reader never observes a half-written page. Every path written here is |
| 34 |
* confined to the plugin's own uploads subdirectory and is validated against it |
| 35 |
* before use. |
| 36 |
*/ |
| 37 |
class Store { |
| 38 |
|
| 39 |
const DIR_NAME = 'page-cache'; |
| 40 |
const FILE_NAME = 'index.html'; |
| 41 |
|
| 42 |
/** |
| 43 |
* Resolved base directory, memoised per request. |
| 44 |
* |
| 45 |
* @var string|null |
| 46 |
*/ |
| 47 |
private static $base_dir = null; |
| 48 |
|
| 49 |
/** |
| 50 |
* Absolute path to the page-cache root, without a trailing slash. |
| 51 |
* |
| 52 |
* @return string |
| 53 |
*/ |
| 54 |
public static function base_dir() { |
| 55 |
if ( null === self::$base_dir ) { |
| 56 |
$upload = new FileUpload(); |
| 57 |
self::$base_dir = untrailingslashit( $upload->get_upload_dir() ) . '/' . self::DIR_NAME; |
| 58 |
} |
| 59 |
return self::$base_dir; |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* Absolute path to the cache file for a host + URL path, or null when the |
| 64 |
* request cannot be represented safely on disk. |
| 65 |
* |
| 66 |
* @param string $host Host name. |
| 67 |
* @param string $path URL path (decoded or encoded; decoded here). |
| 68 |
* @param string $variant Optional variant suffix, e.g. 'mobile'. |
| 69 |
* @return string|null |
| 70 |
*/ |
| 71 |
public static function file_path( $host, $path, $variant = '' ) { |
| 72 |
$host = self::sanitize_host( $host ); |
| 73 |
if ( '' === $host ) { |
| 74 |
return null; |
| 75 |
} |
| 76 |
|
| 77 |
$segments = self::sanitize_path( $path ); |
| 78 |
if ( null === $segments ) { |
| 79 |
return null; |
| 80 |
} |
| 81 |
|
| 82 |
$file = self::FILE_NAME; |
| 83 |
if ( '' !== $variant ) { |
| 84 |
$variant = preg_replace( '/[^a-z0-9-]/', '', strtolower( $variant ) ); |
| 85 |
if ( '' !== $variant ) { |
| 86 |
$file = 'index-' . $variant . '.html'; |
| 87 |
} |
| 88 |
} |
| 89 |
|
| 90 |
$dir = self::base_dir() . '/' . $host; |
| 91 |
if ( ! empty( $segments ) ) { |
| 92 |
$dir .= '/' . implode( '/', $segments ); |
| 93 |
} |
| 94 |
|
| 95 |
$full = $dir . '/' . $file; |
| 96 |
|
| 97 |
// Final containment assertion. The segment rules below already make |
| 98 |
// traversal impossible, but this is the check that actually matters, so |
| 99 |
// it is enforced independently of them rather than trusted to them. |
| 100 |
if ( ! self::is_inside_base( $full ) ) { |
| 101 |
return null; |
| 102 |
} |
| 103 |
|
| 104 |
return $full; |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* Cache file path for the current request, or null when not representable. |
| 109 |
* |
| 110 |
* @return string|null |
| 111 |
*/ |
| 112 |
public static function current_file_path() { |
| 113 |
$host = isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : ''; |
| 114 |
return self::file_path( is_string( $host ) ? $host : '', Rules::request_path(), self::current_variant() ); |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* Variant suffix for the current request ('' or 'mobile'). |
| 119 |
* |
| 120 |
* @return string |
| 121 |
*/ |
| 122 |
public static function current_variant() { |
| 123 |
$enabled = (bool) \ABlocks\Helper::get_settings( 'perf_page_cache_mobile_variant', false ); |
| 124 |
if ( ! $enabled ) { |
| 125 |
return ''; |
| 126 |
} |
| 127 |
$agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) : ''; |
| 128 |
if ( ! is_string( $agent ) || '' === $agent ) { |
| 129 |
return ''; |
| 130 |
} |
| 131 |
return preg_match( '/Mobile|Android|iPhone|iPad|iPod|Opera Mini|IEMobile/i', $agent ) ? 'mobile' : ''; |
| 132 |
} |
| 133 |
|
| 134 |
/** |
| 135 |
* Write a page to the cache atomically. |
| 136 |
* |
| 137 |
* @param string $file Absolute target path from {@see Store::file_path()}. |
| 138 |
* @param string $html Rendered HTML. |
| 139 |
* @param bool $gzip Also write a .gz sibling for gzip_static. |
| 140 |
* @return bool |
| 141 |
*/ |
| 142 |
public static function write( $file, $html, $gzip = true ) { |
| 143 |
if ( empty( $file ) || ! self::is_inside_base( $file ) ) { |
| 144 |
return false; |
| 145 |
} |
| 146 |
|
| 147 |
// Mirror into a persistent object cache when one exists. On multi-server |
| 148 |
// setups the filesystem is per-node, so a page written on one server is a |
| 149 |
// miss on every other; an object cache is shared. Files are still written |
| 150 |
// regardless, because the nginx tier can only ever serve from disk. |
| 151 |
self::mirror_to_object_cache( $file, $html ); |
| 152 |
|
| 153 |
$dir = dirname( $file ); |
| 154 |
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { |
| 155 |
return false; |
| 156 |
} |
| 157 |
|
| 158 |
self::protect_base_dir(); |
| 159 |
|
| 160 |
if ( ! self::atomic_put( $file, $html ) ) { |
| 161 |
return false; |
| 162 |
} |
| 163 |
|
| 164 |
$gz_file = $file . '.gz'; |
| 165 |
if ( $gzip && function_exists( 'gzencode' ) ) { |
| 166 |
$encoded = gzencode( $html, 6 ); |
| 167 |
if ( false !== $encoded ) { |
| 168 |
// A failed .gz write is not fatal — the plain file still serves. |
| 169 |
self::atomic_put( $gz_file, $encoded ); |
| 170 |
} |
| 171 |
} elseif ( file_exists( $gz_file ) ) { |
| 172 |
// Setting turned off after entries were written with a sibling: drop |
| 173 |
// the stale .gz, or gzip_static would keep serving old content. |
| 174 |
wp_delete_file( $gz_file ); |
| 175 |
} |
| 176 |
|
| 177 |
return true; |
| 178 |
} |
| 179 |
|
| 180 |
/** |
| 181 |
* Object-cache key for a cache file path. |
| 182 |
* |
| 183 |
* @param string $file Absolute path. |
| 184 |
* @return string |
| 185 |
*/ |
| 186 |
public static function object_key( $file ) { |
| 187 |
return 'page_' . md5( wp_normalize_path( (string) $file ) ); |
| 188 |
} |
| 189 |
|
| 190 |
/** |
| 191 |
* Copy a rendered page into the object cache, when one is persistent. |
| 192 |
* |
| 193 |
* A no-op without Redis/Memcached: storing pages in transients would put |
| 194 |
* whole HTML documents in `wp_options` and read them back with a database |
| 195 |
* query, which is slower than the filesystem it is meant to replace. |
| 196 |
* |
| 197 |
* @param string $file Absolute path the page was written to. |
| 198 |
* @param string $html Rendered HTML. |
| 199 |
* @return bool |
| 200 |
*/ |
| 201 |
private static function mirror_to_object_cache( $file, $html ) { |
| 202 |
if ( ! CacheBackend::is_persistent() ) { |
| 203 |
return false; |
| 204 |
} |
| 205 |
|
| 206 |
$ttl = (int) \ABlocks\Helper::get_settings( 'perf_page_cache_ttl', 0 ); |
| 207 |
$ttl = $ttl > 0 ? $ttl * HOUR_IN_SECONDS : DAY_IN_SECONDS; |
| 208 |
|
| 209 |
return CacheBackend::set( self::object_key( $file ), $html, $ttl, false ); |
| 210 |
} |
| 211 |
|
| 212 |
/** |
| 213 |
* Atomically write a file anywhere inside the plugin's uploads directory. |
| 214 |
* |
| 215 |
* Exposed so sibling features (the style consolidator) share one |
| 216 |
* write-then-rename implementation rather than each rolling their own. The |
| 217 |
* containment check is deliberately against the plugin's uploads root rather |
| 218 |
* than the page-cache subdirectory, since callers legitimately write to |
| 219 |
* neighbouring directories — but nothing outside that root. |
| 220 |
* |
| 221 |
* @param string $file Absolute target path. |
| 222 |
* @param string $contents Payload. |
| 223 |
* @return bool |
| 224 |
*/ |
| 225 |
public static function put_file( $file, $contents ) { |
| 226 |
$upload = new FileUpload(); |
| 227 |
$root = wp_normalize_path( untrailingslashit( $upload->get_upload_dir() ) ); |
| 228 |
$path = wp_normalize_path( (string) $file ); |
| 229 |
|
| 230 |
if ( false !== strpos( $path, '../' ) || 0 !== strpos( $path, $root . '/' ) ) { |
| 231 |
return false; |
| 232 |
} |
| 233 |
|
| 234 |
return self::atomic_put( $file, $contents ); |
| 235 |
} |
| 236 |
|
| 237 |
/** |
| 238 |
* Delete one cache entry and its siblings. |
| 239 |
* |
| 240 |
* @param string $file Absolute path. |
| 241 |
* @return bool |
| 242 |
*/ |
| 243 |
public static function delete_file( $file ) { |
| 244 |
if ( empty( $file ) || ! self::is_inside_base( $file ) ) { |
| 245 |
return false; |
| 246 |
} |
| 247 |
$deleted = false; |
| 248 |
CacheBackend::delete( self::object_key( $file ) ); |
| 249 |
foreach ( [ $file, $file . '.gz' ] as $target ) { |
| 250 |
if ( file_exists( $target ) ) { |
| 251 |
wp_delete_file( $target ); |
| 252 |
$deleted = true; |
| 253 |
} |
| 254 |
} |
| 255 |
return $deleted; |
| 256 |
} |
| 257 |
|
| 258 |
/** |
| 259 |
* Delete every variant cached for a URL. |
| 260 |
* |
| 261 |
* @param string $url Absolute or relative URL. |
| 262 |
* @return bool True when at least one file was removed. |
| 263 |
*/ |
| 264 |
public static function delete_url( $url ) { |
| 265 |
$parts = wp_parse_url( $url ); |
| 266 |
if ( empty( $parts ) ) { |
| 267 |
return false; |
| 268 |
} |
| 269 |
$host = isset( $parts['host'] ) ? $parts['host'] : ( isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : '' ); |
| 270 |
$path = isset( $parts['path'] ) ? $parts['path'] : '/'; |
| 271 |
|
| 272 |
$deleted = false; |
| 273 |
foreach ( [ '', 'mobile' ] as $variant ) { |
| 274 |
$file = self::file_path( is_string( $host ) ? $host : '', $path, $variant ); |
| 275 |
if ( $file && self::delete_file( $file ) ) { |
| 276 |
$deleted = true; |
| 277 |
} |
| 278 |
} |
| 279 |
return $deleted; |
| 280 |
} |
| 281 |
|
| 282 |
/** |
| 283 |
* Remove every cached page. |
| 284 |
* |
| 285 |
* @return int Files removed. |
| 286 |
*/ |
| 287 |
public static function flush() { |
| 288 |
$base = self::base_dir(); |
| 289 |
if ( ! is_dir( $base ) ) { |
| 290 |
return 0; |
| 291 |
} |
| 292 |
|
| 293 |
// Only host directories are removed. Files sitting directly in the base |
| 294 |
// are the guards (index.php, .htaccess) — and on Apache the .htaccess |
| 295 |
// carries the tier-3 serve rules, so wiping it on every purge would |
| 296 |
// quietly demote the site back to PHP-served caching. |
| 297 |
$removed = 0; |
| 298 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value is checked; a directory vanishing mid-purge is normal and must not warn. |
| 299 |
$entries = @scandir( $base ); |
| 300 |
if ( false === $entries ) { |
| 301 |
return 0; |
| 302 |
} |
| 303 |
|
| 304 |
foreach ( $entries as $entry ) { |
| 305 |
if ( '.' === $entry || '..' === $entry ) { |
| 306 |
continue; |
| 307 |
} |
| 308 |
$path = $base . '/' . $entry; |
| 309 |
if ( is_dir( $path ) && ! is_link( $path ) ) { |
| 310 |
$removed += self::delete_tree( $path, true ); |
| 311 |
} |
| 312 |
} |
| 313 |
|
| 314 |
self::protect_base_dir(); |
| 315 |
return $removed; |
| 316 |
} |
| 317 |
|
| 318 |
/** |
| 319 |
* Delete entries older than the configured TTL. |
| 320 |
* |
| 321 |
* @param int $ttl_hours Hours; 0 disables expiry. |
| 322 |
* @return int Files removed. |
| 323 |
*/ |
| 324 |
public static function prune( $ttl_hours = null ) { |
| 325 |
if ( null === $ttl_hours ) { |
| 326 |
$ttl_hours = (int) \ABlocks\Helper::get_settings( 'perf_page_cache_ttl', 0 ); |
| 327 |
} |
| 328 |
$ttl_hours = (int) $ttl_hours; |
| 329 |
if ( $ttl_hours <= 0 ) { |
| 330 |
return 0; |
| 331 |
} |
| 332 |
|
| 333 |
$base = self::base_dir(); |
| 334 |
if ( ! is_dir( $base ) ) { |
| 335 |
return 0; |
| 336 |
} |
| 337 |
|
| 338 |
$cutoff = time() - ( $ttl_hours * HOUR_IN_SECONDS ); |
| 339 |
$removed = 0; |
| 340 |
|
| 341 |
foreach ( self::iterate_files( $base ) as $path ) { |
| 342 |
if ( '.gz' === substr( $path, -3 ) ) { |
| 343 |
continue; // Handled with its parent. |
| 344 |
} |
| 345 |
if ( filemtime( $path ) < $cutoff ) { |
| 346 |
$removed += self::delete_file( $path ) ? 1 : 0; |
| 347 |
} |
| 348 |
} |
| 349 |
|
| 350 |
return $removed; |
| 351 |
} |
| 352 |
|
| 353 |
/** |
| 354 |
* Cached-page count and total size on disk. |
| 355 |
* |
| 356 |
* @return array{pages:int, files:int, bytes:int} |
| 357 |
*/ |
| 358 |
public static function stats() { |
| 359 |
$base = self::base_dir(); |
| 360 |
$stats = [ |
| 361 |
'pages' => 0, |
| 362 |
'files' => 0, |
| 363 |
'bytes' => 0, |
| 364 |
]; |
| 365 |
if ( ! is_dir( $base ) ) { |
| 366 |
return $stats; |
| 367 |
} |
| 368 |
foreach ( self::iterate_files( $base ) as $path ) { |
| 369 |
$stats['files']++; |
| 370 |
$stats['bytes'] += (int) filesize( $path ); |
| 371 |
if ( '.html' === substr( $path, -5 ) ) { |
| 372 |
$stats['pages']++; |
| 373 |
} |
| 374 |
} |
| 375 |
return $stats; |
| 376 |
} |
| 377 |
|
| 378 |
/** |
| 379 |
* Write the directory guards. |
| 380 |
* |
| 381 |
* The .htaccess is defence in depth only — nginx ignores it, and nginx is a |
| 382 |
* first-class target here. The real protection is the rule that we only ever |
| 383 |
* cache the fully-anonymous view of an already-public URL, so a leaked file |
| 384 |
* contains exactly what curl on that URL already returns. See Rules. |
| 385 |
*/ |
| 386 |
public static function protect_base_dir() { |
| 387 |
$base = self::base_dir(); |
| 388 |
if ( ! is_dir( $base ) && ! wp_mkdir_p( $base ) ) { |
| 389 |
return; |
| 390 |
} |
| 391 |
|
| 392 |
$index = $base . '/index.php'; |
| 393 |
if ( ! file_exists( $index ) ) { |
| 394 |
self::atomic_put( $index, "<?php\n// Silence is golden.\n" ); |
| 395 |
} |
| 396 |
|
| 397 |
$htaccess = $base . '/.htaccess'; |
| 398 |
if ( ! file_exists( $htaccess ) ) { |
| 399 |
// Tier 3 on Apache replaces this file with serve rules. |
| 400 |
self::atomic_put( $htaccess, "# aBlocks page cache — served by PHP.\n<Files \"*.html\">\nRequire all denied\n</Files>\n" ); |
| 401 |
} |
| 402 |
} |
| 403 |
|
| 404 |
/** |
| 405 |
* Write a file atomically: temp file in the same directory, then rename. |
| 406 |
* |
| 407 |
* Renaming is atomic within a filesystem, so a concurrent reader sees either |
| 408 |
* the old file or the new one, never a partial write. The temp file is |
| 409 |
* created in the destination directory rather than the system temp dir, |
| 410 |
* because rename() is only atomic when both paths share a filesystem. |
| 411 |
* |
| 412 |
* @param string $file Target path. |
| 413 |
* @param string $contents Payload. |
| 414 |
* @return bool |
| 415 |
*/ |
| 416 |
private static function atomic_put( $file, $contents ) { |
| 417 |
$dir = dirname( $file ); |
| 418 |
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { |
| 419 |
return false; |
| 420 |
} |
| 421 |
|
| 422 |
$tmp = tempnam( $dir, '.ablocks-cache-' ); |
| 423 |
if ( false === $tmp ) { |
| 424 |
return false; |
| 425 |
} |
| 426 |
|
| 427 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents -- WP_Filesystem cannot write-then-rename atomically; see class docblock. |
| 428 |
$written = file_put_contents( $tmp, $contents, LOCK_EX ); |
| 429 |
if ( false === $written ) { |
| 430 |
wp_delete_file( $tmp ); |
| 431 |
return false; |
| 432 |
} |
| 433 |
|
| 434 |
// tempnam() creates 0600; cache files must be readable by the web server |
| 435 |
// user when nginx serves them directly (tier 3). |
| 436 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort: a failed chmod is recoverable, and a warning printed here would land inside the HTML being cached. |
| 437 |
@chmod( $tmp, defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644 ); |
| 438 |
|
| 439 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value is checked; the warning is suppressed because concurrent writers racing on the same page is normal and must stay silent. |
| 440 |
if ( ! @rename( $tmp, $file ) ) { |
| 441 |
wp_delete_file( $tmp ); |
| 442 |
return false; |
| 443 |
} |
| 444 |
|
| 445 |
return true; |
| 446 |
} |
| 447 |
|
| 448 |
/** |
| 449 |
* Recursively delete a directory's contents. |
| 450 |
* |
| 451 |
* @param string $dir Directory, must be inside the cache base. |
| 452 |
* @param bool $remove_self Remove $dir itself as well. |
| 453 |
* @return int Files removed. |
| 454 |
*/ |
| 455 |
private static function delete_tree( $dir, $remove_self = true ) { |
| 456 |
if ( ! is_dir( $dir ) || ! self::is_inside_base( $dir, true ) ) { |
| 457 |
return 0; |
| 458 |
} |
| 459 |
|
| 460 |
$removed = 0; |
| 461 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value is checked; a directory vanishing mid-purge is normal and must not warn. |
| 462 |
$entries = @scandir( $dir ); |
| 463 |
if ( false === $entries ) { |
| 464 |
return 0; |
| 465 |
} |
| 466 |
|
| 467 |
foreach ( $entries as $entry ) { |
| 468 |
if ( '.' === $entry || '..' === $entry ) { |
| 469 |
continue; |
| 470 |
} |
| 471 |
$path = $dir . '/' . $entry; |
| 472 |
if ( is_link( $path ) ) { |
| 473 |
// Never follow a symlink out of the cache tree. |
| 474 |
wp_delete_file( $path ); |
| 475 |
$removed++; |
| 476 |
continue; |
| 477 |
} |
| 478 |
if ( is_dir( $path ) ) { |
| 479 |
$removed += self::delete_tree( $path, true ); |
| 480 |
continue; |
| 481 |
} |
| 482 |
wp_delete_file( $path ); |
| 483 |
$removed++; |
| 484 |
} |
| 485 |
|
| 486 |
if ( $remove_self ) { |
| 487 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best effort: a non-empty or already-removed directory is not an error here. |
| 488 |
@rmdir( $dir ); |
| 489 |
} |
| 490 |
|
| 491 |
return $removed; |
| 492 |
} |
| 493 |
|
| 494 |
/** |
| 495 |
* Yield every file under a directory. |
| 496 |
* |
| 497 |
* @param string $dir Directory. |
| 498 |
* @return \Generator |
| 499 |
*/ |
| 500 |
private static function iterate_files( $dir ) { |
| 501 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value is checked; a directory vanishing mid-purge is normal and must not warn. |
| 502 |
$entries = @scandir( $dir ); |
| 503 |
if ( false === $entries ) { |
| 504 |
return; |
| 505 |
} |
| 506 |
foreach ( $entries as $entry ) { |
| 507 |
if ( '.' === $entry || '..' === $entry ) { |
| 508 |
continue; |
| 509 |
} |
| 510 |
$path = $dir . '/' . $entry; |
| 511 |
if ( is_link( $path ) ) { |
| 512 |
continue; |
| 513 |
} |
| 514 |
if ( is_dir( $path ) ) { |
| 515 |
yield from self::iterate_files( $path ); |
| 516 |
continue; |
| 517 |
} |
| 518 |
if ( '.html' === substr( $path, -5 ) || '.gz' === substr( $path, -3 ) ) { |
| 519 |
yield $path; |
| 520 |
} |
| 521 |
} |
| 522 |
} |
| 523 |
|
| 524 |
/** |
| 525 |
* Normalise a host for use as a directory name. |
| 526 |
* |
| 527 |
* HTTP_HOST is attacker-controlled, so this is strict: lowercase, and only |
| 528 |
* the characters a hostname may legitimately contain. A port is kept (as |
| 529 |
* `_`-joined) so :8080 dev sites do not collide with production. |
| 530 |
* |
| 531 |
* @param string $host Raw host. |
| 532 |
* @return string Sanitised host, or '' when unusable. |
| 533 |
*/ |
| 534 |
private static function sanitize_host( $host ) { |
| 535 |
$host = strtolower( trim( (string) $host ) ); |
| 536 |
$host = str_replace( ':', '_', $host ); |
| 537 |
$host = preg_replace( '/[^a-z0-9._\-]/', '', $host ); |
| 538 |
$host = trim( (string) $host, '.-_' ); |
| 539 |
if ( '' === $host || strlen( $host ) > 253 ) { |
| 540 |
return ''; |
| 541 |
} |
| 542 |
return $host; |
| 543 |
} |
| 544 |
|
| 545 |
/** |
| 546 |
* Split a URL path into validated directory segments. |
| 547 |
* |
| 548 |
* @param string $path URL path. |
| 549 |
* @return string[]|null Segments, or null when the path is not representable. |
| 550 |
*/ |
| 551 |
private static function sanitize_path( $path ) { |
| 552 |
$path = (string) $path; |
| 553 |
$path = (string) strtok( $path, '?' ); |
| 554 |
$path = (string) strtok( $path, '#' ); |
| 555 |
|
| 556 |
$raw = explode( '/', $path ); |
| 557 |
$out = []; |
| 558 |
|
| 559 |
foreach ( $raw as $segment ) { |
| 560 |
if ( '' === $segment ) { |
| 561 |
continue; |
| 562 |
} |
| 563 |
$clean = self::sanitize_segment( $segment ); |
| 564 |
if ( null === $clean ) { |
| 565 |
return null; |
| 566 |
} |
| 567 |
$out[] = $clean; |
| 568 |
} |
| 569 |
|
| 570 |
// Absurd depth is either an attack or a misconfiguration; either way it |
| 571 |
// should not create a thousand-deep directory tree. |
| 572 |
if ( count( $out ) > 20 ) { |
| 573 |
return null; |
| 574 |
} |
| 575 |
|
| 576 |
return $out; |
| 577 |
} |
| 578 |
|
| 579 |
/** |
| 580 |
* Validate one path segment. |
| 581 |
* |
| 582 |
* Percent-decoded first, because nginx matches on its decoded $uri and the |
| 583 |
* on-disk name must agree with it. Anything that could escape the tree or |
| 584 |
* confuse the filesystem is rejected outright rather than rewritten — |
| 585 |
* rewriting would silently map two distinct URLs onto one file. |
| 586 |
* |
| 587 |
* @param string $segment Raw segment. |
| 588 |
* @return string|null Validated segment, or null to reject the whole path. |
| 589 |
*/ |
| 590 |
private static function sanitize_segment( $segment ) { |
| 591 |
$segment = rawurldecode( $segment ); |
| 592 |
|
| 593 |
if ( '' === $segment || '.' === $segment || '..' === $segment ) { |
| 594 |
return null; |
| 595 |
} |
| 596 |
// Null bytes, control characters, separators and Windows-reserved chars. |
| 597 |
if ( preg_match( '#[\x00-\x1F\x7F/\\\\:*?"<>|]#', $segment ) ) { |
| 598 |
return null; |
| 599 |
} |
| 600 |
// A leading dot would create hidden directories and can shadow guards |
| 601 |
// such as .htaccess. |
| 602 |
if ( '.' === $segment[0] ) { |
| 603 |
return null; |
| 604 |
} |
| 605 |
// Individual path components are capped well below the common 255-byte |
| 606 |
// filesystem limit. |
| 607 |
if ( strlen( $segment ) > 200 ) { |
| 608 |
return null; |
| 609 |
} |
| 610 |
|
| 611 |
return $segment; |
| 612 |
} |
| 613 |
|
| 614 |
/** |
| 615 |
* Is a path contained by the cache base directory? |
| 616 |
* |
| 617 |
* Compares lexically on a normalised path so it works for files that do not |
| 618 |
* exist yet (realpath() would return false for those). |
| 619 |
* |
| 620 |
* @param string $path Candidate path. |
| 621 |
* @param bool $allow_base Treat the base directory itself as inside. |
| 622 |
* @return bool |
| 623 |
*/ |
| 624 |
private static function is_inside_base( $path, $allow_base = false ) { |
| 625 |
$base = wp_normalize_path( self::base_dir() ); |
| 626 |
$path = wp_normalize_path( (string) $path ); |
| 627 |
|
| 628 |
if ( false !== strpos( $path, '../' ) ) { |
| 629 |
return false; |
| 630 |
} |
| 631 |
if ( $allow_base && $path === $base ) { |
| 632 |
return true; |
| 633 |
} |
| 634 |
return 0 === strpos( $path, $base . '/' ); |
| 635 |
} |
| 636 |
} |
| 637 |
|