Automatic WordPress security: local firewall, malware and file-integrity scanning, vulnerability protection, quarantine, scheduled backups, an optional page cache, and automatic global threat intelligence. See README.md for installation, update, and uninstall instructions.
186 lines
6.5 KiB
PHP
186 lines
6.5 KiB
PHP
<?php
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class Argus_Cache_Eligibility {
|
|
|
|
const SAFE_TO_CACHE = 'safe_to_cache';
|
|
const BYPASS = 'bypass';
|
|
const NEEDS_REVIEW = 'needs_review';
|
|
const FORCE_EXCLUDED = 'force_excluded';
|
|
|
|
const IGNORABLE_QUERY_PARAMS = array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'msclkid', 'mc_cid', 'mc_eid', 'ref' );
|
|
|
|
public static function classify_request( array $context ) {
|
|
$method = strtoupper( $context['method'] ?? 'GET' );
|
|
if ( ! in_array( $method, array( 'GET', 'HEAD' ), true ) ) {
|
|
return self::result( self::FORCE_EXCLUDED, 'HTTP method ' . $method . ' is never cached (only GET/HEAD are safe).' );
|
|
}
|
|
|
|
if ( ! empty( $context['has_authorization_header'] ) ) {
|
|
return self::result( self::BYPASS, 'Request carries an Authorization header (API/Basic-auth client).' );
|
|
}
|
|
|
|
if ( ! empty( $context['is_admin'] ) ) {
|
|
return self::result( self::FORCE_EXCLUDED, 'wp-admin is never cached.' );
|
|
}
|
|
|
|
if ( ! empty( $context['is_ajax'] ) || ! empty( $context['is_rest'] ) || ! empty( $context['is_cron'] ) ) {
|
|
return self::result( self::FORCE_EXCLUDED, 'admin-ajax.php / wp-json / wp-cron.php are always excluded from the shared page cache.' );
|
|
}
|
|
|
|
$path = untrailingslashit( (string) ( $context['path'] ?? '' ) );
|
|
foreach ( self::protected_paths() as $protected ) {
|
|
$protected = untrailingslashit( $protected );
|
|
if ( $path === $protected || 0 === strpos( $path, $protected . '/' ) || false !== strpos( $protected, '*' ) && self::wildcard_match( $protected, $path ) ) {
|
|
return self::result( self::FORCE_EXCLUDED, 'Matches protected WordPress endpoint: ' . $protected );
|
|
}
|
|
}
|
|
|
|
if ( ! empty( $context['is_logged_in'] ) ) {
|
|
return self::result( self::BYPASS, 'Authenticated WordPress user.' );
|
|
}
|
|
|
|
foreach ( (array) ( $context['cookies'] ?? array() ) as $cookie_name => $cookie_value ) {
|
|
$hit = self::match_cookie_pattern( $cookie_name );
|
|
if ( $hit ) {
|
|
return self::result( self::BYPASS, 'Session/personalization cookie present: ' . $hit );
|
|
}
|
|
}
|
|
|
|
$query = (array) ( $context['query'] ?? array() );
|
|
$meaningful_query = array_diff_key( $query, array_flip( self::IGNORABLE_QUERY_PARAMS ) );
|
|
if ( ! empty( $meaningful_query ) ) {
|
|
return self::result( self::NEEDS_REVIEW, 'Request has query parameters beyond known tracking params: ' . implode( ', ', array_keys( $meaningful_query ) ) );
|
|
}
|
|
|
|
return self::result( self::SAFE_TO_CACHE, 'GET request, anonymous, no protected path, no session cookies, no meaningful query string.' );
|
|
}
|
|
|
|
public static function classify_response( array $context ) {
|
|
$status = (int) ( $context['status_code'] ?? 0 );
|
|
if ( 200 !== $status ) {
|
|
return self::result( self::FORCE_EXCLUDED, 'Response status ' . $status . ' is not 200 (redirects/errors are never cached).' );
|
|
}
|
|
|
|
$content_type = (string) ( $context['content_type'] ?? '' );
|
|
if ( '' !== $content_type && false === stripos( $content_type, 'text/html' ) ) {
|
|
return self::result( self::BYPASS, 'Response content-type is not text/html: ' . $content_type );
|
|
}
|
|
|
|
$headers = array_change_key_case( (array) ( $context['headers'] ?? array() ), CASE_LOWER );
|
|
|
|
$cache_control = strtolower( (string) ( $headers['cache-control'] ?? '' ) );
|
|
if ( $cache_control ) {
|
|
foreach ( array( 'no-store', 'private', 'no-cache' ) as $directive ) {
|
|
if ( false !== strpos( $cache_control, $directive ) ) {
|
|
return self::result( self::BYPASS, 'Origin response sent Cache-Control: ' . $directive );
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( ! empty( $headers['pragma'] ) && false !== stripos( (string) $headers['pragma'], 'no-cache' ) ) {
|
|
return self::result( self::BYPASS, 'Origin response sent Pragma: no-cache.' );
|
|
}
|
|
|
|
if ( ! empty( $headers['set-cookie'] ) ) {
|
|
return self::result( self::BYPASS, 'Origin response set a new cookie during this request -- likely personalized (session/cart/comment identity).' );
|
|
}
|
|
|
|
foreach ( (array) ( $context['new_cookies_set'] ?? array() ) as $cookie_name ) {
|
|
if ( self::match_cookie_pattern( $cookie_name ) ) {
|
|
return self::result( self::BYPASS, 'Origin response set a session/personalization cookie: ' . $cookie_name );
|
|
}
|
|
}
|
|
|
|
return self::result( self::SAFE_TO_CACHE, 'Response is a plain 200 text/html page with no private/session signal.' );
|
|
}
|
|
|
|
protected static function match_cookie_pattern( $cookie_name ) {
|
|
foreach ( self::cookie_patterns() as $pattern ) {
|
|
if ( self::wildcard_match( $pattern, $cookie_name ) ) {
|
|
return $pattern;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
protected static function wildcard_match( $pattern, $subject ) {
|
|
$regex = '/^' . str_replace( '\*', '.*', preg_quote( $pattern, '/' ) ) . '$/i';
|
|
return (bool) preg_match( $regex, $subject );
|
|
}
|
|
|
|
public static function cookie_patterns() {
|
|
$defaults = array(
|
|
|
|
'wordpress_logged_in_*',
|
|
'wordpress_sec_*',
|
|
'wordpress_*',
|
|
'wp-settings-*',
|
|
'wp-settings-time-*',
|
|
'comment_author_*',
|
|
'comment_author_email_*',
|
|
'comment_author_url_*',
|
|
|
|
'woocommerce_items_in_cart',
|
|
'woocommerce_cart_hash',
|
|
'wp_woocommerce_session_*',
|
|
);
|
|
|
|
return apply_filters( 'argus_wpd_cache_bypass_cookie_patterns', $defaults );
|
|
}
|
|
|
|
public static function protected_paths() {
|
|
$defaults = array(
|
|
'/wp-admin',
|
|
'/wp-login.php',
|
|
'/wp-json',
|
|
'/wp-cron.php',
|
|
'/xmlrpc.php',
|
|
);
|
|
if ( class_exists( 'WooCommerce' ) ) {
|
|
$defaults = array_merge( $defaults, self::woocommerce_paths() );
|
|
}
|
|
return apply_filters( 'argus_wpd_cache_protected_paths', $defaults );
|
|
}
|
|
|
|
protected static function woocommerce_paths() {
|
|
$paths = array();
|
|
$page_options = array(
|
|
'woocommerce_cart_page_id' => '/cart',
|
|
'woocommerce_checkout_page_id' => '/checkout',
|
|
'woocommerce_myaccount_page_id' => '/my-account',
|
|
);
|
|
foreach ( $page_options as $option => $fallback ) {
|
|
$page_id = (int) get_option( $option );
|
|
$path = $page_id ? wp_parse_url( get_permalink( $page_id ), PHP_URL_PATH ) : null;
|
|
$paths[] = $path ? untrailingslashit( $path ) : $fallback;
|
|
}
|
|
return $paths;
|
|
}
|
|
|
|
public static function classify_url( $url ) {
|
|
$parts = wp_parse_url( $url );
|
|
$query = array();
|
|
if ( ! empty( $parts['query'] ) ) {
|
|
parse_str( $parts['query'], $query );
|
|
}
|
|
return self::classify_request(
|
|
array(
|
|
'method' => 'GET',
|
|
'path' => $parts['path'] ?? '/',
|
|
'query' => $query,
|
|
'is_admin' => false,
|
|
'is_logged_in' => false,
|
|
'cookies' => array(),
|
|
)
|
|
);
|
|
}
|
|
|
|
protected static function result( $status, $reason ) {
|
|
return array( 'status' => $status, 'reason' => $reason );
|
|
}
|
|
}
|