Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/shortpixel-adaptive-images/includes/actions/lqip.actions.class.php
+++ b/shortpixel-adaptive-images/includes/actions/lqip.actions.class.php
@@ -4,6 +4,7 @@
use ShortPixelAI;
use ShortPixelAILQIP;
+ use ShortPixelAIPage;
use ShortPixelUrlTools;
use ShortPixelAIConverter;
@@ -11,11 +12,15 @@
/**
* Method handles the pages's actions
* Works via AJAX
+ *
+ * INSTANT LQIP only: requires spainonce
*/
public static function handle() {
if ( ShortPixelAI::isAjax() ) {
- $data = isset( $_POST[ 'data' ] ) ? $_POST[ 'data' ] : null;
- $action = isset( $data[ 'action' ] ) ? $data[ 'action' ] : null;
+ Page::checkSpaiNonce();
+
+ $data = isset( $_POST[ 'data' ] ) ? wp_unslash( $_POST[ 'data' ] ) : null;
+ $action = is_array( $data ) && isset( $data[ 'action' ] ) ? $data[ 'action' ] : null;
$response = [ 'success' => false ];
@@ -24,7 +29,7 @@
unset( $data[ 'action' ] );
- $response = call_user_func( [ 'self', 'handle' . $action ], isset( $data ) ? $data : null );
+ $response = call_user_func( [ 'self', 'handle' . $action ], is_array( $data ) ? $data : null );
}
wp_send_json( $response, 200 );
@@ -36,21 +41,98 @@
/**
* Handles collect action
*
+ * Sanitizes client-provided image URLs before they reach LQIP::process():
+ * - caps batch size to BUNDLE_CAPACITY
+ * - derives referer server-side (POST referer is ignored)
+ * - keeps only same-site, non-excluded, processable image URLs
+ *
* @param $data
*
* @return array
*/
private static function handleCollect( $data ) {
$collection = isset( $data[ 'collection' ] ) ? $data[ 'collection' ] : null;
- $referer = $data['referer'];
- $collection = array_map(function($item) use ($referer) {$item['referer'] = $referer; return $item;}, $collection);
- $data = LQIP::_()->process( $collection );
- $processed = $data['processed'];
+
+ if ( !is_array( $collection ) || empty( $collection ) ) {
+ return [
+ 'success' => false,
+ 'message' => __( 'Empty collection.', 'shortpixel-adaptive-images' ),
+ ];
+ }
+
+ // Limit DoS via oversized wp_options writes
+ if ( count( $collection ) > LQIP::BUNDLE_CAPACITY ) {
+ return [
+ 'success' => false,
+ 'message' => __( 'Collection is too large.', 'shortpixel-adaptive-images' ),
+ ];
+ }
+
+ // Referer is used later for cache invalidation; never trust data[referer] from the client
+ $site_host = ShortPixelDomainTools::get_site_domain();
+ $referer = wp_get_referer();
+
+ if ( !$referer && isset( $_SERVER[ 'HTTP_REFERER' ] ) ) {
+ $referer = esc_url_raw( wp_unslash( $_SERVER[ 'HTTP_REFERER' ] ) );
+ }
+
+ if ( $referer ) {
+ $referer_host = wp_parse_url( $referer, PHP_URL_HOST );
+
+ if ( !$site_host || !$referer_host || strcasecmp( $referer_host, $site_host ) !== 0 ) {
+ $referer = false;
+ }
+ }
+
+ // Drop invalid, external, or excluded URLs instead of passing attacker-controlled data to LQIP
+ $sanitized = [];
+
+ foreach ( $collection as $item ) {
+ if ( !is_array( $item ) ) {
+ continue;
+ }
+
+ $url = isset( $item[ 'url' ] ) ? trim( (string) $item[ 'url' ] ) : '';
+ $source = isset( $item[ 'source' ] ) ? trim( (string) $item[ 'source' ] ) : '';
+
+ if ( $url === '' || $source === '' || !ShortPixelUrlTools::isValid( $url ) || !ShortPixelUrlTools::isValid( $source ) ) {
+ continue;
+ }
+
+ $url = ShortPixelUrlTools::absoluteUrl( $url );
+ $source = ShortPixelUrlTools::absoluteUrl( $source );
+
+ if ( ShortPixelAI::_()->urlIsExcluded( $url ) || ShortPixelAI::_()->urlIsExcluded( $source ) ) {
+ continue;
+ }
+
+ $url_host = wp_parse_url( $url, PHP_URL_HOST );
+
+ if ( !$site_host || !$url_host || strcasecmp( $url_host, $site_host ) !== 0 ) {
+ continue;
+ }
+
+ $sanitized[] = [
+ 'url' => $url,
+ 'source' => $source,
+ 'referer' => $referer,
+ ];
+ }
+
+ if ( empty( $sanitized ) ) {
+ return [
+ 'success' => false,
+ 'message' => __( 'No valid items in collection.', 'shortpixel-adaptive-images' ),
+ ];
+ }
+
+ $data = LQIP::_()->process( $sanitized );
+ $processed = $data[ 'processed' ];
return [
'success' => true,
- 'message' => $processed ? __( 'Collection has been updated', 'shortpixel-adaptive-images' ) : __( 'Collection has not been updated', 'shortpixel-adaptive-images' ) . ' (' . $data['message'] . ')',
+ 'message' => $processed ? __( 'Collection has been updated', 'shortpixel-adaptive-images' ) : __( 'Collection has not been updated', 'shortpixel-adaptive-images' ) . ' (' . $data[ 'message' ] . ')',
'collection' => $processed,
];
}
- }
No newline at end of file
+ }
--- a/shortpixel-adaptive-images/includes/controllers/lqip.class.php
+++ b/shortpixel-adaptive-images/includes/controllers/lqip.class.php
@@ -1013,8 +1013,11 @@
// LQ placeholder generating handler which ran by cron job
add_action( self::SCHEDULE[ 'name' ], [ $this, 'eventHandler' ] );
- add_action( 'wp_ajax_shortpixel_ai_handle_lqip_action', [ 'ShortPixelAILQIPActions', 'handle' ] );
- add_action( 'wp_ajax_nopriv_shortpixel_ai_handle_lqip_action', [ 'ShortPixelAILQIPActions', 'handle' ] );
+ // Front-end AJAX collect is only used in INSTANT mode (cron uses server-side processing)
+ if ( $this->process_way === self::USE_INSTANT ) {
+ add_action( 'wp_ajax_shortpixel_ai_handle_lqip_action', [ 'ShortPixelAILQIPActions', 'handle' ] );
+ add_action( 'wp_ajax_nopriv_shortpixel_ai_handle_lqip_action', [ 'ShortPixelAILQIPActions', 'handle' ] );
+ }
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueScripts' ] );
}
@@ -1039,6 +1042,8 @@
'ajax_url' => admin_url( 'admin-ajax.php' ),
'processWay' => $this->process_way,
'localStorage' => $this->ctrl->options->settings_behaviour_localStorage,
+ // needed when lqip.js loads without jQuery bundle
+ 'ajax_nonce' => wp_create_nonce( 'shortpixel-ai-settings' ),
] );
ShortPixelUrlTools::applyNonce('spai-lqip');
--- a/shortpixel-adaptive-images/includes/helpers/cache-cleaner.class.php
+++ b/shortpixel-adaptive-images/includes/helpers/cache-cleaner.class.php
@@ -162,6 +162,7 @@
//WP Fastest Cache
//Generic cache, search for cache folders in wp-content/cache - only if we have a list of URLs otherwise we could delete too many things...
+ // URLs and paths are validated to block traversal and off-site referer abuse
if(!$cache_cleared && $urls) {
$cache_parent = WP_CONTENT_DIR . '/cache/';
$caches = @scandir($cache_parent);
@@ -170,11 +171,20 @@
if ($cache == '.' || $cache == '..') continue;
if(is_dir($cache_parent . $cache)) {
foreach($urls as $url) {
- $parsed = parse_url($url);
- $domain = isset($parsed['domain']) ? $parsed['domain'] : '';
- $path = isset($parsed['path']) ? $parsed['path'] : '';
- if(!($cache_cleared = $this->deleteHtmlFiles($cache_parent . $cache . DIRECTORY_SEPARATOR . $domain . $path))) {
- $cache_cleared = $this->deleteHtmlFiles($cache_parent . $cache . DIRECTORY_SEPARATOR . $path);
+ if(!$this->isAllowedCacheClearUrl($url)) {
+ $LOGGER_ON && $this->logger->log('Skipping disallowed cache URL: ' . $url);
+ continue;
+ }
+
+ $path = wp_parse_url($url, PHP_URL_PATH);
+ if(!is_string($path) || $path === '' || strpos($path, '..') !== false) {
+ continue;
+ }
+
+ $relative_path = ltrim(wp_normalize_path($path), '/');
+ $cache_dir = $cache_parent . $cache . DIRECTORY_SEPARATOR . $relative_path;
+ if(($cache_cleared = $this->deleteHtmlFiles($cache_dir))) {
+ break 2;
}
}
}
@@ -190,19 +200,90 @@
return $result;
}
+ /**
+ * Delete .html/.htm files from a cache directory after path validation
+ *
+ * @param string $cache_path Candidate directory under wp-content/cache
+ * @return int Number of deleted files
+ */
protected function deleteHtmlFiles($cache_path) {
+ // Never unlink before resolveCacheDirectory confirms the target stays inside wp-content/cache
+ $safe_path = $this->resolveCacheDirectory($cache_path);
+ if($safe_path === false) {
+ $this->LOGGER_ON && $this->logger->log('Rejected unsafe cache path: ' . $cache_path);
+ return 0;
+ }
+
$counter = 0;
- $cached_pages = @scandir($cache_path);
- $this->LOGGER_ON && $this->logger->log('PATH to clear cache: ' . $cache_path . ' contains: ' . json_encode($cached_pages));
+ $cached_pages = @scandir($safe_path);
+ $this->LOGGER_ON && $this->logger->log('PATH to clear cache: ' . $safe_path . ' contains: ' . json_encode($cached_pages));
if($cached_pages) foreach ($cached_pages as $cp) {
if ($cp == '.' || $cp == '..') continue;
if(preg_match('/.html?$/', $cp)) {
- $counter += @unlink(trailingslashit($cache_path) . $cp );
+ $counter += @unlink(trailingslashit($safe_path) . $cp );
}
}
return $counter;
}
+ /**
+ * Resolve and validate a cache directory path before any filesystem delete
+ *
+ * Rejects traversal sequences, paths outside wp-content/cache, and non-directories
+ *
+ * @param string $cache_path Raw path built from a page URL
+ * @return string|false Real path when safe, false otherwise
+ */
+ protected function resolveCacheDirectory($cache_path) {
+ if(!is_string($cache_path) || $cache_path === '' || strpos($cache_path, "