PluginProbe
WPGraphQL / trunk
WPGraphQL vtrunk
2.22.3 2.22.2 2.22.1 2.22.0 2.21.1 2.21.0 2.20.0 2.19.0 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.1 2.14.0 2.13.0 2.2.0 2.3.0 2.3.3 2.3.6 2.3.8 2.5.0 2.5.1 2.5.2 2.5.3 All 177 releases
wp-graphql / src / Mutation / MediaItemCreate.php

MediaItemCreate.php in WPGraphQL trunk, at src/Mutation/MediaItemCreate.php

648 lines 22.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPGraphQL\Mutation;
4
5 use GraphQL\Error\UserError;
6 use GraphQL\Type\Definition\ResolveInfo;
7 use WPGraphQL\AppContext;
8 use WPGraphQL\Data\MediaItemMutation;
9 use WPGraphQL\Utils\Utils;
10
11 class MediaItemCreate {
12 /**
13 * Registers the MediaItemCreate mutation.
14 *
15 * @return void
16 * @throws \Exception
17 */
18 public static function register_mutation() {
19 register_graphql_mutation(
20 'createMediaItem',
21 [
22 'inputFields' => self::get_input_fields(),
23 'outputFields' => self::get_output_fields(),
24 'mutateAndGetPayload' => self::mutate_and_get_payload(),
25 ]
26 );
27 }
28
29 /**
30 * Defines the mutation input field configuration.
31 *
32 * @return array<string,array<string,mixed>>
33 */
34 public static function get_input_fields() {
35 return [
36 'altText' => [
37 'type' => 'String',
38 'description' => static function () {
39 return __( 'Alternative text to display when mediaItem is not displayed', 'wp-graphql' );
40 },
41 ],
42 'authorId' => [
43 'type' => 'ID',
44 'description' => static function () {
45 return __( 'The userId to assign as the author of the mediaItem', 'wp-graphql' );
46 },
47 ],
48 'caption' => [
49 'type' => 'String',
50 'description' => static function () {
51 return __( 'The caption for the mediaItem', 'wp-graphql' );
52 },
53 ],
54 'commentStatus' => [
55 'type' => 'String',
56 'description' => static function () {
57 return __( 'The comment status for the mediaItem', 'wp-graphql' );
58 },
59 ],
60 'date' => [
61 'type' => 'String',
62 'description' => static function () {
63 return __( 'The date of the mediaItem', 'wp-graphql' );
64 },
65 ],
66 'dateGmt' => [
67 'type' => 'String',
68 'description' => static function () {
69 return __( 'The date (in GMT zone) of the mediaItem', 'wp-graphql' );
70 },
71 ],
72 'description' => [
73 'type' => 'String',
74 'description' => static function () {
75 return __( 'Description of the mediaItem', 'wp-graphql' );
76 },
77 ],
78 'filePath' => [
79 'type' => 'String',
80 'description' => static function () {
81 return __( 'The file name of the mediaItem', 'wp-graphql' );
82 },
83 ],
84 'fileType' => [
85 'type' => 'MimeTypeEnum',
86 'description' => static function () {
87 return __( 'The file type of the mediaItem', 'wp-graphql' );
88 },
89 ],
90 'slug' => [
91 'type' => 'String',
92 'description' => static function () {
93 return __( 'The slug of the mediaItem', 'wp-graphql' );
94 },
95 ],
96 'status' => [
97 'type' => 'MediaItemStatusEnum',
98 'description' => static function () {
99 return __( 'The status of the mediaItem', 'wp-graphql' );
100 },
101 ],
102 'title' => [
103 'type' => 'String',
104 'description' => static function () {
105 return __( 'The title of the mediaItem', 'wp-graphql' );
106 },
107 ],
108 'pingStatus' => [
109 'type' => 'String',
110 'description' => static function () {
111 return __( 'The ping status for the mediaItem', 'wp-graphql' );
112 },
113 ],
114 'parentId' => [
115 'type' => 'ID',
116 'description' => static function () {
117 return __( 'The ID of the parent object', 'wp-graphql' );
118 },
119 ],
120 ];
121 }
122
123 /**
124 * Defines the mutation output field configuration.
125 *
126 * @return array<string,array<string,mixed>>
127 */
128 public static function get_output_fields() {
129 return [
130 'mediaItem' => [
131 'type' => 'MediaItem',
132 'description' => static function () {
133 return __( 'The MediaItem object mutation type.', 'wp-graphql' );
134 },
135 'resolve' => static function ( $payload, $args, AppContext $context ) {
136 if ( empty( $payload['postObjectId'] ) || ! absint( $payload['postObjectId'] ) ) {
137 return null;
138 }
139
140 return $context->get_loader( 'post' )->load_deferred( $payload['postObjectId'] );
141 },
142 ],
143 ];
144 }
145
146 /**
147 * Defines the mutation data modification closure.
148 *
149 * @return callable(array<string,mixed>$input,\WPGraphQL\AppContext $context,\GraphQL\Type\Definition\ResolveInfo $info):array<string,mixed>
150 */
151 public static function mutate_and_get_payload() {
152 return static function ( $input, AppContext $context, ResolveInfo $info ) {
153 /**
154 * Stop now if a user isn't allowed to upload a mediaItem
155 */
156 if ( ! current_user_can( 'upload_files' ) ) {
157 throw new UserError( esc_html__( 'Sorry, you are not allowed to upload mediaItems', 'wp-graphql' ) );
158 }
159
160 $post_type_object = get_post_type_object( 'attachment' );
161 if ( empty( $post_type_object ) ) {
162 throw new UserError( esc_html__( 'The Media Item could not be created', 'wp-graphql' ) );
163 }
164
165 /**
166 * If the mediaItem being created is being assigned to another user that's not the current user, make sure
167 * the current user has permission to edit others mediaItems
168 */
169 if ( ! empty( $input['authorId'] ) ) {
170 // Ensure authorId is a valid databaseId.
171 $input['authorId'] = Utils::get_database_id_from_id( $input['authorId'] );
172
173 // Bail if can't edit other users' attachments.
174 if ( get_current_user_id() !== $input['authorId'] && ( ! isset( $post_type_object->cap->edit_others_posts ) || ! current_user_can( $post_type_object->cap->edit_others_posts ) ) ) {
175 throw new UserError( esc_html__( 'Sorry, you are not allowed to create mediaItems as this user', 'wp-graphql' ) );
176 }
177 }
178
179 // REST parity: reject `revision` and `attachment` as parent types.
180 // WP_REST_Attachments_Controller::create_item() rejects these at the
181 // top of the handler. Mirroring that here so the failure happens
182 // before any file is downloaded.
183 if ( ! empty( $input['parentId'] ) ) {
184 $parent_database_id = Utils::get_database_id_from_id( $input['parentId'] );
185 if ( $parent_database_id ) {
186 $parent_post_type = get_post_type( (int) $parent_database_id );
187 if ( in_array( $parent_post_type, [ 'revision', 'attachment' ], true ) ) {
188 throw new UserError( esc_html__( 'Invalid parent type.', 'wp-graphql' ) );
189 }
190 }
191 }
192
193 /**
194 * Set the file name, whether it's a local file or from a URL.
195 * Then set the url for the uploaded file
196 */
197 $file_name = basename( $input['filePath'] );
198 $uploaded_file_url = (string) $input['filePath'];
199 $sanitized_file_path = sanitize_file_name( $input['filePath'] );
200
201 // Check that the filetype is allowed
202 $check_file = wp_check_filetype( $sanitized_file_path );
203
204 // wp_http_validate_url() blocks 127/10/0/172.16-31/192.168 by the
205 // resolved IP but not RFC 3927 link-local (169.254/16), which
206 // exposes cloud instance metadata (169.254.169.254). Resolve the
207 // host and reject any request that lands on a non-public address, so
208 // a DNS name (or a decimal/octal/hex encoding of an address) that
209 // maps to an internal host cannot be used to reach it.
210
211 // if the file doesn't pass the check, throw an error
212 if ( ! $check_file['ext'] || ! $check_file['type'] || ! wp_http_validate_url( $uploaded_file_url ) || ! self::is_safe_remote_url( $uploaded_file_url ) ) {
213 // translators: %s is the file path.
214 throw new UserError( esc_html( sprintf( __( 'Invalid filePath "%s"', 'wp-graphql' ), $input['filePath'] ) ) );
215 }
216
217 $protocol = wp_parse_url( $input['filePath'], PHP_URL_SCHEME );
218
219 // prevent the filePath from being submitted with a non-allowed protocols
220 $allowed_protocols = [ 'https', 'http' ];
221
222 /**
223 * Filter the allowed protocols for the mutation
224 *
225 * @param string[] $allowed_protocols The allowed protocols for filePaths to be submitted
226 * @param mixed $protocol The current protocol of the filePath
227 * @param array<string,mixed> $input The input of the current mutation
228 * @param \WPGraphQL\AppContext $context The context of the current request
229 * @param \GraphQL\Type\Definition\ResolveInfo $info The ResolveInfo of the current field
230 *
231 * @hookGroup models
232 * @since 0.0.5
233 */
234 $allowed_protocols = apply_filters( 'graphql_media_item_create_allowed_protocols', $allowed_protocols, $protocol, $input, $context, $info );
235
236 if ( ! in_array( $protocol, $allowed_protocols, true ) ) {
237 throw new UserError(
238 esc_html(
239 sprintf(
240 // translators: %1$s is the protocol, %2$s is the list of allowed protocols.
241 __( 'Invalid protocol. "%1$s". Only "%2$s" allowed.', 'wp-graphql' ),
242 $protocol,
243 implode( '", "', $allowed_protocols )
244 )
245 )
246 );
247 }
248
249 /**
250 * Require the file.php file from wp-admin. This file includes the
251 * download_url and wp_handle_sideload methods.
252 */
253 require_once ABSPATH . 'wp-admin/includes/file.php';
254
255 /**
256 * Ensure we have a valid URL before attempting download
257 */
258 if ( empty( $uploaded_file_url ) ) {
259 throw new UserError( esc_html__( 'Sorry, the file could not be uploaded', 'wp-graphql' ) );
260 }
261
262 /**
263 * URL data for the mediaItem, timeout value is the default, see:
264 * https://developer.wordpress.org/reference/functions/download_url/
265 */
266 $timeout_seconds = 300;
267
268 // download_url() follows redirects. wp_safe_remote_get() re-validates
269 // each hop, but only through wp_http_validate_url(), which does not
270 // cover every range is_safe_remote_url() rejects. Re-validate every
271 // redirect target with the same guard so a public URL cannot be used
272 // to redirect the server onto an internal address.
273 add_action( 'requests-requests.before_redirect', [ self::class, 'reject_unsafe_redirect' ] );
274
275 // finally guarantees the guard is removed on every exit path,
276 // including the WP < 6.2 case where reject_unsafe_redirect() throws a
277 // fatal Error (the Requests\Exception class does not exist) rather
278 // than a WP_Error, which would otherwise leave the guard registered
279 // on the worker for the rest of the process.
280 try {
281 $temp_file = download_url( $uploaded_file_url, $timeout_seconds );
282 } finally {
283 remove_action( 'requests-requests.before_redirect', [ self::class, 'reject_unsafe_redirect' ] );
284 }
285
286 /**
287 * Handle the error from download_url if it occurs
288 */
289 if ( is_wp_error( $temp_file ) ) {
290 throw new UserError( esc_html__( 'Sorry, the URL for this file is invalid, it must be a valid URL', 'wp-graphql' ) );
291 }
292
293 // REST parity: enforce multisite file-size and quota limits.
294 // Mirrors WP_REST_Attachments_Controller::check_upload_size().
295 $size_error = self::check_multisite_upload_size( $temp_file );
296 if ( null !== $size_error ) {
297 wp_delete_file( $temp_file );
298 throw new UserError( esc_html( $size_error ) );
299 }
300
301 /**
302 * Build the file data for side loading
303 */
304 $file_data = [
305 'name' => $file_name,
306 'type' => ! empty( $input['fileType'] ) ? $input['fileType'] : wp_check_filetype( $temp_file ),
307 'tmp_name' => $temp_file,
308 'error' => 0,
309 'size' => (int) filesize( $temp_file ),
310 ];
311
312 /**
313 * Tells WordPress to not look for the POST form fields that would normally be present as
314 * we downloaded the file from a remote server, so there will be no form fields
315 * The default is true
316 */
317 $overrides = [
318 'test_form' => false,
319 ];
320
321 /**
322 * Insert the mediaItem and retrieve it's data
323 */
324 $file = wp_handle_sideload( $file_data, $overrides );
325
326 /**
327 * Handle the error from wp_handle_sideload if it occurs
328 */
329 if ( ! empty( $file['error'] ) || ! isset( $file['file'] ) ) {
330 throw new UserError( esc_html__( 'Sorry, the URL for this file is invalid, it must be a path to the mediaItem file', 'wp-graphql' ) );
331 }
332
333 // REST parity: reject image types the server cannot generate
334 // sub-sizes for. Mirrors the wp_image_editor_supports() check in
335 // WP_REST_Attachments_Controller::create_item_permissions_check()
336 // (added in WP 6.8). The wp_prevent_unsupported_mime_type_uploads
337 // filter mirrors core, so site owners can opt out the same way.
338 $detected_type = $file['type'];
339 if (
340 apply_filters( 'wp_prevent_unsupported_mime_type_uploads', true, $detected_type )
341 && 0 === strpos( $detected_type, 'image/' )
342 && 'image/svg+xml' !== $detected_type
343 && ! wp_image_editor_supports( [ 'mime_type' => $detected_type ] )
344 ) {
345 if ( ! empty( $file['file'] ) ) {
346 wp_delete_file( $file['file'] );
347 }
348 throw new UserError( esc_html__( 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.', 'wp-graphql' ) );
349 }
350
351 /**
352 * Insert the mediaItem object and get the ID
353 */
354 $media_item_args = MediaItemMutation::prepare_media_item( $input, $post_type_object, 'createMediaItem', $file );
355
356 /**
357 * Get the post parent and if it's not set, set it to 0
358 */
359 $attachment_parent_id = ! empty( $media_item_args['post_parent'] ) ? $media_item_args['post_parent'] : 0;
360
361 /**
362 * Stop now if a user isn't allowed to edit the parent post
363 */
364 $parent = get_post( $attachment_parent_id );
365
366 if ( null !== $parent ) {
367 $post_parent_type = get_post_type_object( $parent->post_type );
368
369 if ( empty( $post_parent_type ) ) {
370 throw new UserError( esc_html__( 'The parent of the Media Item is of an invalid type', 'wp-graphql' ) );
371 }
372
373 if ( 'attachment' !== $post_parent_type->name && ( ! isset( $post_parent_type->cap->edit_post ) || ! current_user_can( $post_parent_type->cap->edit_post, $attachment_parent_id ) ) ) {
374 throw new UserError( esc_html__( 'Sorry, you are not allowed to upload mediaItems assigned to this parent node', 'wp-graphql' ) );
375 }
376 }
377
378 /**
379 * Insert the mediaItem
380 *
381 * Required Argument defaults are set in the main MediaItemMutation.php if they aren't set
382 * by the user during input, they are:
383 * post_title (pulled from file if not entered)
384 * post_content (empty string if not entered)
385 * post_status (inherit if not entered)
386 * post_mime_type (pulled from the file if not entered in the mutation)
387 */
388 $attachment_id = wp_insert_attachment( $media_item_args, $file['file'], $attachment_parent_id, true );
389
390 if ( is_wp_error( $attachment_id ) ) {
391 $error_message = $attachment_id->get_error_message();
392 if ( ! empty( $error_message ) ) {
393 throw new UserError( esc_html( $error_message ) );
394 }
395
396 throw new UserError( esc_html__( 'The media item failed to create but no error was provided', 'wp-graphql' ) );
397 }
398
399 /**
400 * Check if the wp_generate_attachment_metadata method exists and include it if not.
401 */
402 require_once ABSPATH . 'wp-admin/includes/image.php';
403
404 /**
405 * Generate and update the mediaItem's metadata.
406 * If we make it this far the file and attachment
407 * have been validated and we will not receive any errors
408 */
409 $attachment_data = wp_generate_attachment_metadata( $attachment_id, $file['file'] );
410 wp_update_attachment_metadata( $attachment_id, $attachment_data );
411
412 /**
413 * Update alt text postmeta for mediaItem
414 */
415 MediaItemMutation::update_additional_media_item_data( $attachment_id, $input, $post_type_object, 'createMediaItem', $context, $info );
416
417 return [
418 'postObjectId' => $attachment_id,
419 ];
420 };
421 }
422
423 /**
424 * Mirrors WP_REST_Attachments_Controller::check_upload_size() for the
425 * multisite quota and per-file size limit checks. Returns null when the
426 * file is within all limits, or a translated error message string when
427 * one of the limits is exceeded.
428 *
429 * No-ops on single-site installs, where these site-option-driven limits
430 * do not apply.
431 *
432 * @param string $file_path Path to the downloaded temp file.
433 * @return string|null Error message if size exceeds limits, null otherwise.
434 */
435 private static function check_multisite_upload_size( $file_path ) {
436 if ( ! is_multisite() || get_site_option( 'upload_space_check_disabled' ) ) {
437 return null;
438 }
439
440 $file_size = (int) filesize( $file_path );
441 $space_left = (int) get_upload_space_available();
442
443 if ( $space_left < $file_size ) {
444 return sprintf(
445 // translators: %s is the required disk space in kilobytes.
446 __( 'Not enough space to upload. %s KB needed.', 'wp-graphql' ),
447 number_format( ( $file_size - $space_left ) / KB_IN_BYTES )
448 );
449 }
450
451 $max_kb = (int) get_site_option( 'fileupload_maxk', 1500 );
452 if ( $file_size > KB_IN_BYTES * $max_kb ) {
453 return sprintf(
454 // translators: %s is the maximum allowed file size in kilobytes.
455 __( 'This file is too big. Files must be less than %s KB in size.', 'wp-graphql' ),
456 $max_kb
457 );
458 }
459
460 require_once ABSPATH . 'wp-admin/includes/ms.php';
461 if ( upload_is_user_over_quota( false ) ) {
462 return __( 'You have used your space quota. Please delete files before uploading.', 'wp-graphql' );
463 }
464
465 return null;
466 }
467
468 /**
469 * Rejects a redirect whose target is not safe for the server to fetch.
470 *
471 * Registered on the Requests before_redirect hook while a media file is
472 * downloaded, so every hop of a redirect chain is validated with the same
473 * host resolution as the initial URL. Throwing aborts the request; WP_Http
474 * converts the exception into a WP_Error, which download_url() returns and
475 * the caller surfaces as an invalid filePath.
476 *
477 * Public because WordPress must be able to invoke it as a hook callback; it
478 * is not part of the extension API.
479 *
480 * @internal
481 *
482 * @param mixed $location The URL the response is redirecting to.
483 *
484 * @return void
485 * @throws \WpOrg\Requests\Exception When the redirect target is not publicly routable.
486 */
487 public static function reject_unsafe_redirect( $location ) {
488 if ( ! is_string( $location ) || self::is_safe_remote_url( $location ) ) {
489 return;
490 }
491
492 // Abort the redirect. On WP 6.2+ WP_Http catches WpOrg\Requests\Exception
493 // and turns it into a WP_Error, so download_url() cleans up and returns
494 // that error. On WP < 6.2 the class is unavailable and this surfaces as a
495 // hard failure, which still fails closed: the upload is aborted before
496 // the server can be redirected onto an internal address.
497 throw new \WpOrg\Requests\Exception(
498 esc_html__( 'A redirect to a non-public address was blocked.', 'wp-graphql' ),
499 'wpgraphql_media_item_unsafe_redirect'
500 );
501 }
502
503 /**
504 * Determines whether a remote URL is safe for the server to fetch.
505 *
506 * Resolves the host and returns false when the host does not resolve or any
507 * resolved address is not publicly routable (loopback, private, link-local,
508 * or otherwise reserved). Because the check runs against the resolved
509 * address rather than the host text, a DNS name, or a decimal/octal/hex
510 * encoding of an address, that maps to an internal host such as the
511 * 169.254.169.254 cloud-metadata endpoint is rejected.
512 *
513 * Both IPv4 (A) and IPv6 (AAAA) records are resolved and every address is
514 * validated. download_url() delegates to curl, which may connect over IPv6
515 * even when a host also advertises a public IPv4 address, so validating the
516 * IPv4 result alone would let a dual-stack host with a public A record and
517 * an internal AAAA record (e.g. an IPv6 cloud-metadata endpoint) through.
518 *
519 * @param string $url The URL whose host should be validated.
520 */
521 private static function is_safe_remote_url( string $url ): bool {
522 $host = wp_parse_url( $url, PHP_URL_HOST );
523
524 if ( ! is_string( $host ) || '' === $host ) {
525 return false;
526 }
527
528 // Unwrap an IPv6 literal, e.g. "[::1]" becomes "::1".
529 $host = trim( $host, '[]' );
530
531 // IP literals are checked directly. Anything else is resolved, which
532 // also normalizes numeric host encodings (e.g. "2852039166") to a
533 // dotted-quad address.
534 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
535 $addresses = [ $host ];
536 } else {
537 $addresses = self::resolve_host_addresses( $host );
538 }
539
540 if ( empty( $addresses ) ) {
541 return false;
542 }
543
544 foreach ( $addresses as $address ) {
545 if ( ! self::is_public_ip( $address ) ) {
546 return false;
547 }
548 }
549
550 return true;
551 }
552
553 /**
554 * Resolves a host name to every IPv4 and IPv6 address it advertises.
555 *
556 * The gethostbynamel() built-in returns A (IPv4) records only. AAAA (IPv6)
557 * records are resolved separately via dns_get_record() so a dual-stack host
558 * cannot hide an internal IPv6 address behind a public IPv4 address. A failed
559 * lookup returns no records and yields no addresses, which is fail-closed:
560 * is_safe_remote_url() rejects a host that resolves to nothing.
561 *
562 * @param string $host The host name to resolve.
563 *
564 * @return string[] The resolved IPv4 and IPv6 addresses, empty if none.
565 */
566 private static function resolve_host_addresses( string $host ): array {
567 $addresses = [];
568
569 $ipv4 = gethostbynamel( $host );
570 if ( is_array( $ipv4 ) ) {
571 $addresses = $ipv4;
572 }
573
574 if ( function_exists( 'dns_get_record' ) ) {
575 $records = dns_get_record( $host, DNS_AAAA );
576 if ( is_array( $records ) ) {
577 foreach ( $records as $record ) {
578 if ( isset( $record['ipv6'] ) && is_string( $record['ipv6'] ) ) {
579 $addresses[] = $record['ipv6'];
580 }
581 }
582 }
583 }
584
585 return $addresses;
586 }
587
588 /**
589 * Determines whether an IP address is publicly routable.
590 *
591 * Rejects private, reserved, loopback, and link-local ranges for both IPv4
592 * and IPv6. IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) are unwrapped so the
593 * embedded IPv4 range is evaluated rather than trusted.
594 *
595 * @param string $ip The IP address to check.
596 */
597 private static function is_public_ip( string $ip ): bool {
598 // Unwrap an IPv4-mapped IPv6 address so ::ffff:169.254.169.254 is judged
599 // as the link-local 169.254/16 range it actually targets.
600 if ( 0 === stripos( $ip, '::ffff:' ) ) {
601 $mapped = substr( $ip, strlen( '::ffff:' ) );
602 if ( filter_var( $mapped, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
603 $ip = $mapped;
604 }
605 }
606
607 if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
608 return false;
609 }
610
611 // FILTER_FLAG_NO_RES_RANGE misses several IPv4 blocks that are not
612 // publicly routable and can front internal services, so reject them
613 // explicitly: 100.64.0.0/10 (carrier-grade NAT, RFC 6598, used for EKS
614 // pod IPs and some metadata proxies), 192.0.0.0/24 (IETF protocol
615 // assignments), and 198.18.0.0/15 (benchmarking, RFC 2544).
616 if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
617 foreach ( [ '100.64.0.0/10', '192.0.0.0/24', '198.18.0.0/15' ] as $cidr ) {
618 if ( self::ipv4_in_cidr( $ip, $cidr ) ) {
619 return false;
620 }
621 }
622 }
623
624 return true;
625 }
626
627 /**
628 * Determines whether an IPv4 address falls within a CIDR block.
629 *
630 * @param string $ip A validated IPv4 address.
631 * @param string $cidr A CIDR block in "network/prefix" form.
632 */
633 private static function ipv4_in_cidr( string $ip, string $cidr ): bool {
634 [ $subnet, $prefix ] = explode( '/', $cidr );
635
636 $ip_long = ip2long( $ip );
637 $subnet_long = ip2long( $subnet );
638
639 if ( false === $ip_long || false === $subnet_long ) {
640 return false;
641 }
642
643 $mask = -1 << ( 32 - (int) $prefix );
644
645 return ( $ip_long & $mask ) === ( $subnet_long & $mask );
646 }
647 }
648