PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
wdesignkit / includes / admin / class-wdkit-image-guard.php

class-wdkit-image-guard.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/admin/class-wdkit-image-guard.php

414 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Guard against images that cannot be decoded within the available memory.
4 *
5 * Background
6 * ----------
7 * GD holds images UNCOMPRESSED — width x height x 4 bytes — so the size on disk says
8 * nothing about the cost of processing one. A 6336x9504 Pexels photo is 1.7MB as a JPEG
9 * and 229.7MB as a GD bitmap. Decoding it under a 256M memory_limit is an instant fatal
10 * inside imagecreatefromstring(), which WordPress surfaces as "There has been a critical
11 * error on this website" and a HTTP 500 — killing a kit import page with no usable error.
12 *
13 * Imagick is far more frugal and can spill to a disk-backed pixel cache, which is why this
14 * only bites on hosts without it.
15 *
16 * This class is deliberately free of WordPress dependencies: the sizing arithmetic and the
17 * URL checks are pure functions, so they can be reasoned about - and exercised - on their own.
18 *
19 * @package WDesignKit
20 * @since 2.6.2
21 */
22
23 if ( ! defined( 'ABSPATH' ) ) {
24 exit;
25 }
26
27 if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
28
29 /**
30 * Image memory-safety helpers.
31 */
32 class Wdkit_Image_Guard {
33
34 /**
35 * Bytes per pixel for a decoded truecolour bitmap (RGBA).
36 */
37 const BYTES_PER_PIXEL = 4;
38
39 /**
40 * Multiplier applied to the decoded source size to approximate real peak usage.
41 *
42 * A resize needs the decoded source AND a destination bitmap live at the same time,
43 * plus the compressed file contents that were read to produce the source. 1.6x of the
44 * source bitmap tracks observed peaks closely enough to keep us on the safe side
45 * without rejecting images that would actually have succeeded.
46 */
47 const PEAK_OVERHEAD = 1.6;
48
49 /**
50 * Image file extensions we are willing to sideload.
51 */
52 const IMAGE_EXTENSIONS = 'jpg|jpeg|jpe|gif|png|webp|avif|bmp';
53
54 /**
55 * Video file extensions a media control can point at.
56 *
57 * Kept out of IMAGE_EXTENSIONS for the same reason SVG is: that list governs what may be
58 * *decoded*, and none of these ever are. Only the extensions WordPress already allows
59 * with no user (mp4/m4v, webm, mov/qt, ogv), so a background sideload of one succeeds
60 * where an SVG's cannot.
61 */
62 const VIDEO_EXTENSIONS = 'mp4|m4v|webm|mov|qt|ogv';
63
64 /**
65 * Everything a media control can legitimately point at, SVG and video included.
66 *
67 * SVG and video are kept apart from IMAGE_EXTENSIONS because that list governs what may
68 * be *decoded* - neither is ever rasterised, so the memory arithmetic does not apply to
69 * them. This list is the other question: "is this a media file we import at all", which
70 * is what filename_from_url() and is_foreign_media() need. Leaving video out of it meant
71 * filename_from_url() returned '' for every video, and callers read that as "cannot
72 * handle this" - the same way SVGs were being skipped before they were added here.
73 */
74 const MEDIA_EXTENSIONS = self::IMAGE_EXTENSIONS . '|svg|' . self::VIDEO_EXTENSIONS;
75
76 /**
77 * Extensions the deferred-media pass owns, end to end.
78 *
79 * One list, because three separate ones drifted apart and each divergence was a bug:
80 * the collector deferred `avif` but the id-blanking pass did not recognise it, so an AVIF
81 * kept a dangling id and rendered empty for the whole deferred window. Anything added
82 * here must be deferrable, blankable AND remappable - those three passes have to agree or
83 * an id is cleared that nothing will ever restore.
84 *
85 * SVG is deliberately absent: it is imported synchronously (the background pass runs with
86 * no user, where `svg` is not in get_allowed_mime_types()) and already carries a real id.
87 *
88 * @since 2.7.1
89 */
90 const DEFERRABLE_EXTENSIONS = 'png|jpe?g|webp|gif|avif|' . self::VIDEO_EXTENSIONS;
91
92 /**
93 * The same list as a plain array, for in_array() call sites.
94 *
95 * @since 2.7.1
96 *
97 * @return array<string>
98 */
99 public static function deferrable_extensions() {
100 return array( 'png', 'jpg', 'jpeg', 'webp', 'gif', 'avif', 'mp4', 'm4v', 'webm', 'mov', 'qt', 'ogv' );
101 }
102
103 /**
104 * Whether an image of the given dimensions can be decoded in the memory available.
105 *
106 * Pure arithmetic — no WordPress, no filesystem, no globals — so it is directly
107 * unit-testable.
108 *
109 * @param int $width Image width in pixels.
110 * @param int $height Image height in pixels.
111 * @param int $available_bytes Bytes of headroom left. 0 or less means "unlimited".
112 * @param int $bytes_per_pixel Bytes per decoded pixel. Default 4 (RGBA).
113 * @param float $overhead Peak multiplier. Default self::PEAK_OVERHEAD.
114 * @return bool True when it is safe to decode, false when it should be skipped.
115 */
116 public static function decode_fits( $width, $height, $available_bytes, $bytes_per_pixel = self::BYTES_PER_PIXEL, $overhead = self::PEAK_OVERHEAD ) {
117
118 $width = (int) $width;
119 $height = (int) $height;
120 $available_bytes = (int) $available_bytes;
121
122 // Unknown or non-raster dimensions: not ours to judge, let WordPress proceed.
123 if ( $width < 1 || $height < 1 ) {
124 return true;
125 }
126
127 // No ceiling (memory_limit = -1): nothing to protect against.
128 if ( $available_bytes <= 0 ) {
129 return true;
130 }
131
132 $bytes_per_pixel = (int) $bytes_per_pixel > 0 ? (int) $bytes_per_pixel : self::BYTES_PER_PIXEL;
133 $overhead = (float) $overhead > 0 ? (float) $overhead : self::PEAK_OVERHEAD;
134
135 // Multiply in float space: 6336 * 9504 * 4 * 1.6 overflows nothing here, but very
136 // large synthetic dimensions would wrap a 32-bit int.
137 $needed = (float) $width * (float) $height * (float) $bytes_per_pixel * $overhead;
138
139 return $needed <= (float) $available_bytes;
140 }
141
142 /**
143 * Extract a usable filename from an image URL, ignoring any query string.
144 *
145 * Elementor's Import_Images::import() does `basename( $url )` and then bails silently
146 * when wp_check_filetype() finds no extension. A sized CDN URL such as
147 *
148 * https://images.pexels.com/photos/1/pexels-photo-1.jpeg?w=1920&auto=compress
149 *
150 * has the basename "pexels-photo-1.jpeg?w=1920&auto=compress", which ends in
151 * "compress" — so Elementor silently imports nothing, no attachment is created, and
152 * every widget that resolves its image through an attachment ID renders blank.
153 *
154 * WordPress core gets this right in media_sideload_image() by matching `[^\?]+` up to
155 * the query string. This mirrors that so callers can localise such URLs before
156 * Elementor ever sees them.
157 *
158 * @param string $url Image URL.
159 * @return string Bare filename including extension, or '' when the URL is not an image.
160 */
161 public static function filename_from_url( $url ) {
162 if ( ! is_string( $url ) || '' === trim( $url ) ) {
163 return '';
164 }
165
166 // Match the path portion only — [^\?] stops at the query string, exactly as
167 // media_sideload_image() does.
168 //
169 // MEDIA_EXTENSIONS, not IMAGE_EXTENSIONS: an SVG is a file we import like any other,
170 // even though it is never decoded and so plays no part in the memory arithmetic.
171 // Leaving it out here returned an empty filename for every SVG, and callers that
172 // treat that as "cannot handle this" skipped logos and icons entirely.
173 if ( ! preg_match( '/[^\?]+\.(?:' . self::MEDIA_EXTENSIONS . ')\b/i', $url, $matches ) ) {
174 return '';
175 }
176
177 $filename = $matches[0];
178
179 // Take the last path segment without depending on WordPress's wp_basename().
180 $filename = preg_replace( '#^.*/#', '', str_replace( '\\', '/', $filename ) );
181
182 return (string) $filename;
183 }
184
185 /**
186 * Is this media reference still pointing off-site?
187 *
188 * Template content arrives holding whatever URL the media had wherever it came from,
189 * and alongside it that site's attachment ID - which means nothing here. Controls left
190 * in that state render nothing at all whenever the control resolves through the ID
191 * rather than the URL: Elementor inlines an SVG icon by reading the file off the
192 * attachment, and addon widgets fetch their thumbnail by ID, so both come out empty.
193 * Worse, a source ID can collide with a real local post - IDs seen in practice landed
194 * on a revision and on an Elementor template.
195 *
196 * Two things make this narrow enough to act on:
197 *
198 * - The URL must be absolute http(s). Relative and data: URLs are not ours to judge.
199 * - The path must end in a media extension. Media controls share the {url,id} shape
200 * with other controls, and this is what keeps a link from being mistaken for one.
201 *
202 * Compared without the scheme, because one site is routinely reachable over both http
203 * and https and a mismatch there would make every local URL look foreign.
204 *
205 * @param string $url URL from a media control.
206 * @param string $upload_baseurl This site's upload base URL.
207 * @return bool True when the URL is media belonging somewhere else.
208 */
209 public static function is_foreign_media( $url, $upload_baseurl, $require_extension = true ) {
210 if ( ! is_string( $url ) || ! is_string( $upload_baseurl ) || '' === $upload_baseurl ) {
211 return false;
212 }
213
214 if ( ! preg_match( '#^https?://#i', $url ) ) {
215 return false;
216 }
217
218 $strip = static function ( $value ) {
219 return preg_replace( '#^https?://#i', '', $value );
220 };
221
222 // Already ours.
223 if ( 0 === strpos( $strip( $url ), $strip( $upload_baseurl ) ) ) {
224 return false;
225 }
226
227 // A caller that found this URL by scanning text has to prove it is media somehow,
228 // and the extension is the only evidence available. A caller that found it in a
229 // `{ url, id }` media control already knows, and passes false: the template CDN
230 // serves extensionless URLs and resolves the type by Content-Type, so requiring an
231 // extension there rejected real media and left it hotlinked forever.
232 if ( ! $require_extension ) {
233 return true;
234 }
235
236 $path = (string) parse_url( $url, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- no WordPress dependency by design.
237
238 return (bool) preg_match( '/\.(?:' . self::MEDIA_EXTENSIONS . ')$/i', $path );
239 }
240
241 /**
242 * Width requested for a Pexels source image before it is downloaded.
243 *
244 * 1920px is wide enough for any section background a kit page uses; the originals this
245 * caps run up to 6336x9504 (see the class docblock).
246 */
247 const PEXELS_MAX_WIDTH = 1920;
248
249 /**
250 * Cap a Pexels source image at a sane width before it is downloaded.
251 *
252 * Pexels photo URLs resolve to full-resolution originals by default, but accept a `w`
253 * query parameter that returns a server-resized rendition instead. A kit page never
254 * needs more than PEXELS_MAX_WIDTH, and asking for it up front means a smaller network
255 * transfer AND a smaller bitmap for the decode-memory guard above to worry about,
256 * instead of downloading the original just to discard the resolution afterward.
257 *
258 * Pure string handling - no WordPress dependency, unit-testable like the rest of this
259 * class.
260 *
261 * @param string $url Candidate image URL.
262 * @param int $max_width Width to request. Default self::PEXELS_MAX_WIDTH.
263 * @return string The URL unchanged if it is not a Pexels image URL, otherwise capped.
264 */
265 public static function cap_pexels_source( $url, $max_width = self::PEXELS_MAX_WIDTH ) {
266 if ( ! is_string( $url ) || '' === $url ) {
267 return $url;
268 }
269
270 $parts = parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- no WordPress dependency by design.
271
272 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
273 return $url;
274 }
275
276 $host = strtolower( (string) $parts['host'] );
277
278 // Exact host, or a real subdomain of pexels.com. A substring test (the previous
279 // `stripos( $host, 'pexels.com' )`) also accepted `pexels.com.evil.tld` and
280 // `notpexels.com`, which is the wrong shape for a host check even where the
281 // consequence is only a rewritten query string - the next reader will take it for a
282 // host allowlist.
283 if ( 'pexels.com' !== $host && ! self::str_ends_with_ci( $host, '.pexels.com' ) ) {
284 return $url;
285 }
286
287 $max_width = (int) $max_width > 0 ? (int) $max_width : self::PEXELS_MAX_WIDTH;
288
289 $query = isset( $parts['query'] ) ? (string) $parts['query'] : '';
290
291 $params = array();
292 parse_str( $query, $params );
293
294 // parse_str() is lossy: it rewrites any parameter name containing a dot or a space
295 // into an underscore, so `X-Amz-Sig.nature=abc` is re-emitted as
296 // `X-Amz-Sig_nature=abc`. On a signed URL that invalidates the signature, the CDN
297 // answers 403, media_sideload_image() returns WP_Error and the image is dropped with
298 // nothing to retry it. Rather than guess which parameters matter, check whether the
299 // round trip is lossless at all and decline to rewrite when it is not - serving the
300 // original at full width is always the safer failure.
301 if ( '' !== $query && ! self::query_round_trips( $query, $params ) ) {
302 return $url;
303 }
304
305 // Already capped at or below the target width - leave it alone. `is_numeric` first:
306 // `(int) 'abc'` is 0, which compares as "already small enough" and skipped the cap
307 // entirely, so a junk width was a second route to a full-resolution download.
308 if ( isset( $params['w'] ) && is_numeric( $params['w'] ) && (int) $params['w'] > 0
309 && (int) $params['w'] <= $max_width
310 ) {
311 return $url;
312 }
313
314 $params['w'] = $max_width;
315
316 // `auto=compress` is what makes Pexels honour `w` on some endpoints; harmless to set
317 // unconditionally since it only affects encoding, never dimensions.
318 if ( empty( $params['auto'] ) ) {
319 $params['auto'] = 'compress';
320 }
321
322 // Reassembled from components rather than `strtok( $url, '?' )`, which kept any
323 // `#fragment` in the base and so appended the new query *after* it - producing
324 // `…/a.jpg#frag?w=1920`, which every server ignores. That silently defeated the cap
325 // on any fragment-carrying URL.
326 $rebuilt = '';
327
328 if ( ! empty( $parts['scheme'] ) ) {
329 $rebuilt .= $parts['scheme'] . '://';
330 }
331
332 if ( ! empty( $parts['user'] ) ) {
333 $rebuilt .= $parts['user'] . ( empty( $parts['pass'] ) ? '' : ':' . $parts['pass'] ) . '@';
334 }
335
336 $rebuilt .= $parts['host'];
337
338 if ( ! empty( $parts['port'] ) ) {
339 $rebuilt .= ':' . (int) $parts['port'];
340 }
341
342 $rebuilt .= isset( $parts['path'] ) ? $parts['path'] : '';
343 $rebuilt .= '?' . http_build_query( $params );
344
345 // Fragment last, where it belongs.
346 if ( isset( $parts['fragment'] ) && '' !== $parts['fragment'] ) {
347 $rebuilt .= '#' . $parts['fragment'];
348 }
349
350 return $rebuilt;
351 }
352
353 /**
354 * Case-insensitive suffix test.
355 *
356 * str_ends_with() is PHP 8.0+; this plugin still supports 7.4.
357 *
358 * @since 2.7.1
359 *
360 * @param string $haystack Subject.
361 * @param string $needle Suffix to look for.
362 * @return bool
363 */
364 private static function str_ends_with_ci( $haystack, $needle ) {
365 $len = strlen( $needle );
366
367 if ( 0 === $len ) {
368 return true;
369 }
370
371 return strlen( $haystack ) >= $len && 0 === substr_compare( $haystack, $needle, -$len, $len, true );
372 }
373
374 /**
375 * Can this query string survive parse_str() + http_build_query() unchanged?
376 *
377 * Compares the parameter names the parser produced against the names actually present in
378 * the raw query. Any mismatch means rebuilding would emit a different query than it was
379 * given - the dot/space-to-underscore rewrite is the common case - and the caller should
380 * leave the URL alone. Name-based rather than provider-based, so an unfamiliar signing
381 * scheme fails safe without needing to be listed.
382 *
383 * @since 2.7.1
384 *
385 * @param string $query Raw query string.
386 * @param array $params Result of parse_str() on that query.
387 * @return bool True when a rewrite would preserve every parameter name.
388 */
389 private static function query_round_trips( $query, array $params ) {
390
391 foreach ( explode( '&', $query ) as $pair ) {
392 if ( '' === $pair ) {
393 continue;
394 }
395
396 $raw_key = urldecode( strtok( $pair, '=' ) );
397
398 // PHP maps `a[b]=1` to a nested array; the bracket form is expected to differ
399 // from the flat key, and it is not the case being guarded against.
400 if ( false !== strpos( $raw_key, '[' ) ) {
401 continue;
402 }
403
404 if ( ! array_key_exists( $raw_key, $params ) ) {
405 return false;
406 }
407 }
408
409 return true;
410 }
411
412 }
413 }
414