| @@ -17,8 +17,23 @@ | ||
| 17 | 17 | |
| 18 | 18 | /** In-memory cache to avoid repeated file lookups within a request. */ |
| 19 | 19 | private array $cache = []; |
| 20 | 20 | |
| 21 | + /** Transient key prefix for persisted image dimensions. */ | |
| 22 | + private const CACHE_PREFIX = 'ms_dim_'; | |
| 23 | + | |
| 24 | + /** How long resolved dimensions stay cached, in seconds (7 days). */ | |
| 25 | + private const CACHE_TTL = 604800; | |
| 26 | + | |
| 27 | + /** Transient key prefix for failed remote lookups (negative cache). */ | |
| 28 | + private const FAIL_PREFIX = 'ms_dimfail_'; | |
| 29 | + | |
| 30 | + /** How long a failed remote lookup stays cached, in seconds (1 hour). */ | |
| 31 | + private const FAIL_TTL = 3600; | |
| 32 | + | |
| 33 | + /** Cap on bytes fetched from a remote image header sniff. */ | |
| 34 | + private const REMOTE_MAX_BYTES = 65536; | |
| 35 | + | |
| 21 | 36 | public function __construct() { |
| 22 | 37 | add_filter('the_content', [$this, 'inject_dimensions'], 30); |
| 23 | 38 | add_filter('post_thumbnail_html', [$this, 'inject_dimensions'], 30); |
| 24 | 39 | add_filter('widget_text', [$this, 'inject_dimensions'], 30); |
| @@ -27,9 +42,9 @@ | ||
| 27 | 42 | /** |
| 28 | 43 | * Find <img> tags missing width or height and inject dimensions. |
| 29 | 44 | */ |
| 30 | 45 | public function inject_dimensions(string $content): string { |
| 31 | - if (empty($content) || is_admin()) { | |
| 46 | + if (empty($content) || is_admin() || is_feed()) { | |
| 32 | 47 | return $content; |
| 33 | 48 | } |
| 34 | 49 | |
| 35 | 50 | return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) { |
| @@ -67,12 +82,20 @@ | ||
| 67 | 82 | return null; |
| 68 | 83 | } |
| 69 | 84 | $src = $m[1]; |
| 70 | 85 | |
| 86 | + // Request-scoped cache: avoids repeated work within a single render. | |
| 71 | 87 | if (isset($this->cache[$src])) { |
| 72 | 88 | return $this->cache[$src]; |
| 73 | 89 | } |
| 74 | 90 | |
| 91 | + // Persistent cache: skips disk I/O for images resolved on a prior request. | |
| 92 | + $cached = $this->get_cached_dimensions($src); | |
| 93 | + if ($cached !== null) { | |
| 94 | + $this->cache[$src] = $cached; | |
| 95 | + return $cached; | |
| 96 | + } | |
| 97 | + | |
| 75 | 98 | $dims = null; |
| 76 | 99 | |
| 77 | 100 | // Strategy 1: Try to find WordPress attachment by URL |
| 78 | 101 | $dims = $this->get_dims_from_attachment($src); |
| @@ -88,8 +111,9 @@ | ||
| 88 | 111 | } |
| 89 | 112 | |
| 90 | 113 | if ($dims) { |
| 91 | 114 | $this->cache[$src] = $dims; |
| 115 | + $this->set_cached_dimensions($src, $dims); | |
| 92 | 116 | } |
| 93 | 117 | |
| 94 | 118 | return $dims; |
| 95 | 119 | } |
| @@ -94,8 +118,117 @@ | ||
| 94 | 118 | return $dims; |
| 95 | 119 | } |
| 96 | 120 | |
| 97 | 121 | /** |
| 122 | + * Build the transient key for a given image src. | |
| 123 | + */ | |
| 124 | + private static function cache_key(string $src): string { | |
| 125 | + return self::CACHE_PREFIX . md5($src); | |
| 126 | + } | |
| 127 | + | |
| 128 | + /** | |
| 129 | + * Read previously resolved dimensions from the persistent transient cache. | |
| 130 | + * Returns null on a cache miss or a malformed/partial cached value. | |
| 131 | + */ | |
| 132 | + private function get_cached_dimensions(string $src): ?array { | |
| 133 | + $cached = get_transient(self::cache_key($src)); | |
| 134 | + | |
| 135 | + if (is_array($cached) | |
| 136 | + && isset($cached['width'], $cached['height']) | |
| 137 | + && (int) $cached['width'] > 0 | |
| 138 | + && (int) $cached['height'] > 0 | |
| 139 | + ) { | |
| 140 | + return [ | |
| 141 | + 'width' => (int) $cached['width'], | |
| 142 | + 'height' => (int) $cached['height'], | |
| 143 | + ]; | |
| 144 | + } | |
| 145 | + | |
| 146 | + return null; | |
| 147 | + } | |
| 148 | + | |
| 149 | + /** | |
| 150 | + * Persist resolved dimensions so subsequent requests avoid file I/O. | |
| 151 | + */ | |
| 152 | + private function set_cached_dimensions(string $src, array $dims): void { | |
| 153 | + set_transient( | |
| 154 | + self::cache_key($src), | |
| 155 | + [ | |
| 156 | + 'width' => (int) $dims['width'], | |
| 157 | + 'height' => (int) $dims['height'], | |
| 158 | + ], | |
| 159 | + self::CACHE_TTL | |
| 160 | + ); | |
| 161 | + } | |
| 162 | + | |
| 163 | + /** | |
| 164 | + * Purge cached dimensions for every size URL of an attachment. | |
| 165 | + * | |
| 166 | + * Hooked on `delete_attachment` and `wp_update_attachment_metadata` so the | |
| 167 | + * cache can never outlive (or contradict) the image: deleting an image and | |
| 168 | + * re-uploading a different one under the same filename, or regenerating | |
| 169 | + * thumbnails to new dimensions, would otherwise serve stale width/height | |
| 170 | + * for up to CACHE_TTL. Registered unconditionally (even when dimension | |
| 171 | + * injection is disabled) so stale entries are always cleaned up. | |
| 172 | + * | |
| 173 | + * @param int $attachment_id Attachment whose cached dimensions to clear. | |
| 174 | + */ | |
| 175 | + public static function purge_attachment_cache($attachment_id): void { | |
| 176 | + $attachment_id = (int) $attachment_id; | |
| 177 | + if ($attachment_id <= 0) { | |
| 178 | + return; | |
| 179 | + } | |
| 180 | + | |
| 181 | + foreach (self::collect_attachment_urls($attachment_id) as $url) { | |
| 182 | + delete_transient(self::cache_key($url)); | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + /** | |
| 187 | + * `wp_update_attachment_metadata` filter wrapper: purge the cache, then | |
| 188 | + * return the metadata untouched so the filter chain is unaffected. | |
| 189 | + * | |
| 190 | + * @param mixed $data Attachment metadata being saved. | |
| 191 | + * @param int $attachment_id Attachment ID. | |
| 192 | + * @return mixed The unmodified $data. | |
| 193 | + */ | |
| 194 | + public static function purge_on_metadata_update($data, $attachment_id) { | |
| 195 | + self::purge_attachment_cache($attachment_id); | |
| 196 | + return $data; | |
| 197 | + } | |
| 198 | + | |
| 199 | + /** | |
| 200 | + * Collect the full-size URL plus every registered sub-size URL for an | |
| 201 | + * attachment. Sub-size files live in the same directory as the full-size | |
| 202 | + * file, so each is the full URL with its basename swapped for the size | |
| 203 | + * filename. | |
| 204 | + * | |
| 205 | + * @param int $attachment_id Attachment ID. | |
| 206 | + * @return string[] Image URLs (possibly empty). | |
| 207 | + */ | |
| 208 | + private static function collect_attachment_urls(int $attachment_id): array { | |
| 209 | + $full = wp_get_attachment_url($attachment_id); | |
| 210 | + if (!is_string($full) || $full === '') { | |
| 211 | + return []; | |
| 212 | + } | |
| 213 | + | |
| 214 | + $urls = [$full]; | |
| 215 | + | |
| 216 | + $meta = wp_get_attachment_metadata($attachment_id); | |
| 217 | + if (is_array($meta) && !empty($meta['sizes'])) { | |
| 218 | + $slash = strrpos($full, '/'); | |
| 219 | + $base = $slash === false ? '' : substr($full, 0, $slash + 1); | |
| 220 | + foreach ($meta['sizes'] as $size_data) { | |
| 221 | + if (!empty($size_data['file'])) { | |
| 222 | + $urls[] = $base . $size_data['file']; | |
| 223 | + } | |
| 224 | + } | |
| 225 | + } | |
| 226 | + | |
| 227 | + return $urls; | |
| 228 | + } | |
| 229 | + | |
| 230 | + /** | |
| 98 | 231 | * Look up dimensions from WP attachment metadata. |
| 99 | 232 | */ |
| 100 | 233 | private function get_dims_from_attachment(string $url): ?array { |
| 101 | 234 | $attachment_id = attachment_url_to_postid($url); |
| @@ -127,8 +260,17 @@ | ||
| 127 | 260 | } |
| 128 | 261 | } |
| 129 | 262 | } |
| 130 | 263 | |
| 264 | + // A sized variant with no registered sub-size (crop added after | |
| 265 | + // upload, third-party sizes) must not inherit the FULL-size | |
| 266 | + // dimensions — wrong width/height re-introduces the very layout | |
| 267 | + // shift this module exists to prevent. Leaving the tag alone is | |
| 268 | + // safer than injecting wrong values. | |
| 269 | + if (preg_match('/-\d+x\d+(?=\.\w+$)/', wp_basename($url))) { | |
| 270 | + return null; | |
| 271 | + } | |
| 272 | + | |
| 131 | 273 | // Fall back to full size |
| 132 | 274 | if (!empty($meta['width']) && !empty($meta['height'])) { |
| 133 | 275 | return [ |
| 134 | 276 | 'width' => (int) $meta['width'], |
| @@ -166,25 +308,39 @@ | ||
| 166 | 308 | |
| 167 | 309 | /** |
| 168 | 310 | * Fetch dimensions from a remote URL. |
| 169 | 311 | * Downloads just enough bytes to read image headers. |
| 312 | + * | |
| 313 | + * Server-side fetching of arbitrary <img src> values is an SSRF vector: | |
| 314 | + * content authors can plant internal, localhost, or cloud-metadata URLs | |
| 315 | + * that this server would otherwise probe on every pageview. Remote | |
| 316 | + * fetching is therefore opt-in — it only runs for hosts allowlisted via | |
| 317 | + * the 'metasync_dimension_remote_hosts' filter — and failures are | |
| 318 | + * negatively cached so they are not re-fetched on every request. | |
| 170 | 319 | */ |
| 171 | 320 | private function get_dims_from_remote(string $url): ?array { |
| 172 | - if (strpos($url, 'http') !== 0) { | |
| 321 | + if (!$this->is_remote_fetch_allowed($url)) { | |
| 173 | 322 | return null; |
| 174 | 323 | } |
| 175 | 324 | |
| 325 | + $fail_key = self::FAIL_PREFIX . md5($url); | |
| 326 | + if (get_transient($fail_key) !== false) { | |
| 327 | + return null; | |
| 328 | + } | |
| 329 | + | |
| 176 | 330 | $response = wp_remote_get($url, [ |
| 177 | - 'timeout' => 3, | |
| 178 | - 'headers' => ['Range' => 'bytes=0-32767'], | |
| 331 | + 'timeout' => 3, | |
| 332 | + 'headers' => ['Range' => 'bytes=0-' . (self::REMOTE_MAX_BYTES - 1)], | |
| 333 | + 'limit_response_size' => self::REMOTE_MAX_BYTES, | |
| 179 | 334 | ]); |
| 180 | 335 | |
| 181 | - if (is_wp_error($response)) { | |
| 182 | - return null; | |
| 336 | + $body = null; | |
| 337 | + if (!is_wp_error($response)) { | |
| 338 | + $body = wp_remote_retrieve_body($response); | |
| 183 | 339 | } |
| 184 | 340 | |
| 185 | - $body = wp_remote_retrieve_body($response); | |
| 186 | 341 | if (empty($body)) { |
| 342 | + set_transient($fail_key, 1, self::FAIL_TTL); | |
| 187 | 343 | return null; |
| 188 | 344 | } |
| 189 | 345 | |
| 190 | 346 | if (!function_exists('wp_tempnam')) { |
| @@ -193,15 +349,57 @@ | ||
| 193 | 349 | |
| 194 | 350 | $tmp = wp_tempnam($url); |
| 195 | 351 | file_put_contents($tmp, $body); |
| 196 | 352 | $info = @getimagesize($tmp); |
| 197 | - @unlink($tmp); | |
| 353 | + @wp_delete_file($tmp); | |
| 198 | 354 | |
| 199 | 355 | if ($info && $info[0] > 0 && $info[1] > 0) { |
| 200 | 356 | return ['width' => $info[0], 'height' => $info[1]]; |
| 201 | 357 | } |
| 202 | 358 | |
| 359 | + set_transient($fail_key, 1, self::FAIL_TTL); | |
| 203 | 360 | return null; |
| 361 | + } | |
| 362 | + | |
| 363 | + /** | |
| 364 | + * A remote URL may only be fetched when explicitly allowed. | |
| 365 | + * | |
| 366 | + * @param string $url Absolute image URL. | |
| 367 | + * @return bool True when the scheme is http(s), the URL passes core's | |
| 368 | + * internal-address validation, and its host is allowlisted. | |
| 369 | + */ | |
| 370 | + private function is_remote_fetch_allowed(string $url): bool { | |
| 371 | + /** | |
| 372 | + * Hosts whose images may be fetched server-side for dimension | |
| 373 | + * sniffing. Empty by default — remote dimension fetching is opt-in, | |
| 374 | + * which closes the SSRF surface until a site owner explicitly | |
| 375 | + * trusts specific hosts. Checked before any URL parsing so the | |
| 376 | + * default (fetch-disabled) path stays cheap. | |
| 377 | + * | |
| 378 | + * @param string[] $allowed_hosts List of hostnames. | |
| 379 | + */ | |
| 380 | + $allowed_hosts = apply_filters('metasync_dimension_remote_hosts', []); | |
| 381 | + if (empty($allowed_hosts)) { | |
| 382 | + return false; | |
| 383 | + } | |
| 384 | + | |
| 385 | + $scheme = wp_parse_url($url, PHP_URL_SCHEME); | |
| 386 | + if (!in_array($scheme, ['http', 'https'], true)) { | |
| 387 | + return false; | |
| 388 | + } | |
| 389 | + | |
| 390 | + if (!wp_http_validate_url($url)) { | |
| 391 | + return false; | |
| 392 | + } | |
| 393 | + | |
| 394 | + $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST)); | |
| 395 | + foreach ($allowed_hosts as $allowed) { | |
| 396 | + if (strtolower((string) $allowed) === $host) { | |
| 397 | + return true; | |
| 398 | + } | |
| 399 | + } | |
| 400 | + | |
| 401 | + return false; | |
| 204 | 402 | } |
| 205 | 403 | |
| 206 | 404 | /** |
| 207 | 405 | * Insert an attribute into an <img> tag. |