PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | media-optimization/class-dimension-injector.php +82 -8 2.6.19 → 2.7.2 View file →
@@ -23,8 +23,17 @@
23 23
24 24 /** How long resolved dimensions stay cached, in seconds (7 days). */
25 25 private const CACHE_TTL = 604800;
26 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 +
27 36 public function __construct() {
28 37 add_filter('the_content', [$this, 'inject_dimensions'], 30);
29 38 add_filter('post_thumbnail_html', [$this, 'inject_dimensions'], 30);
30 39 add_filter('widget_text', [$this, 'inject_dimensions'], 30);
@@ -33,9 +42,9 @@
33 42 /**
34 43 * Find <img> tags missing width or height and inject dimensions.
35 44 */
36 45 public function inject_dimensions(string $content): string {
37 - if (empty($content) || is_admin()) {
46 + if (empty($content) || is_admin() || is_feed()) {
38 47 return $content;
39 48 }
40 49
41 50 return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
@@ -251,8 +260,17 @@
251 260 }
252 261 }
253 262 }
254 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 +
255 273 // Fall back to full size
256 274 if (!empty($meta['width']) && !empty($meta['height'])) {
257 275 return [
258 276 'width' => (int) $meta['width'],
@@ -290,25 +308,39 @@
290 308
291 309 /**
292 310 * Fetch dimensions from a remote URL.
293 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.
294 319 */
295 320 private function get_dims_from_remote(string $url): ?array {
296 - if (strpos($url, 'http') !== 0) {
321 + if (!$this->is_remote_fetch_allowed($url)) {
297 322 return null;
298 323 }
299 324
325 + $fail_key = self::FAIL_PREFIX . md5($url);
326 + if (get_transient($fail_key) !== false) {
327 + return null;
328 + }
329 +
300 330 $response = wp_remote_get($url, [
301 - 'timeout' => 3,
302 - '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,
303 334 ]);
304 335
305 - if (is_wp_error($response)) {
306 - return null;
336 + $body = null;
337 + if (!is_wp_error($response)) {
338 + $body = wp_remote_retrieve_body($response);
307 339 }
308 340
309 - $body = wp_remote_retrieve_body($response);
310 341 if (empty($body)) {
342 + set_transient($fail_key, 1, self::FAIL_TTL);
311 343 return null;
312 344 }
313 345
314 346 if (!function_exists('wp_tempnam')) {
@@ -317,15 +349,57 @@
317 349
318 350 $tmp = wp_tempnam($url);
319 351 file_put_contents($tmp, $body);
320 352 $info = @getimagesize($tmp);
321 - @unlink($tmp);
353 + @wp_delete_file($tmp);
322 354
323 355 if ($info && $info[0] > 0 && $info[1] > 0) {
324 356 return ['width' => $info[0], 'height' => $info[1]];
325 357 }
326 358
359 + set_transient($fail_key, 1, self::FAIL_TTL);
327 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;
328 402 }
329 403
330 404 /**
331 405 * Insert an attribute into an <img> tag.