| @@ -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. |