PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.1
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.1
2.7.1 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 All 130 releases
← All changes | includes/admin/class-wdkit-image-guard.php +226 -5 2.6.6 → 2.7.1 View file →
@@ -51,16 +51,57 @@
51 51 */
52 52 const IMAGE_EXTENSIONS = 'jpg|jpeg|jpe|gif|png|webp|avif|bmp';
53 53
54 54 /**
55 - * Everything a media control can legitimately point at, SVG included.
55 + * Video file extensions a media control can point at.
56 56 *
57 - * SVG is kept apart from IMAGE_EXTENSIONS because that list governs what may be
58 - * *decoded* - and an SVG is never rasterised, so the memory arithmetic does not apply.
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.
59 61 */
60 - const MEDIA_EXTENSIONS = self::IMAGE_EXTENSIONS . '|svg';
62 + const VIDEO_EXTENSIONS = 'mp4|m4v|webm|mov|qt|ogv';
61 63
62 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 + /**
63 104 * Whether an image of the given dimensions can be decoded in the memory available.
64 105 *
65 106 * Pure arithmetic — no WordPress, no filesystem, no globals — so it is directly
66 107 * unit-testable.
@@ -164,9 +205,9 @@
164 205 * @param string $url URL from a media control.
165 206 * @param string $upload_baseurl This site's upload base URL.
166 207 * @return bool True when the URL is media belonging somewhere else.
167 208 */
168 - public static function is_foreign_media( $url, $upload_baseurl ) {
209 + public static function is_foreign_media( $url, $upload_baseurl, $require_extension = true ) {
169 210 if ( ! is_string( $url ) || ! is_string( $upload_baseurl ) || '' === $upload_baseurl ) {
170 211 return false;
171 212 }
172 213
@@ -182,11 +223,191 @@
182 223 if ( 0 === strpos( $strip( $url ), $strip( $upload_baseurl ) ) ) {
183 224 return false;
184 225 }
185 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 +
186 236 $path = (string) parse_url( $url, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- no WordPress dependency by design.
187 237
188 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;
189 410 }
190 411
191 412 }
192 413 }