Commit 860f99e83d for wordpress.org

commit 860f99e83d0e0efbb66b8fa9609be7a7d250cc1d
Author: adamsilverstein <adamsilverstein@git.wordpress.org>
Date:   Thu Sep 17 17:30:45 2026 +0000

    Customize: Improve `header_image_data` theme mod sanitization.

    Props jeremyfelt, lancewillett, jonsurrell, westonruter, dmsnell.


    Built from https://develop.svn.wordpress.org/trunk@63659


    git-svn-id: http://core.svn.wordpress.org/trunk@62833 1a063a9b-81f0-0310-95a4-ce76da25c4cd

diff --git a/wp-admin/includes/class-custom-image-header.php b/wp-admin/includes/class-custom-image-header.php
index c1816ffae2..367ba04cdc 100644
--- a/wp-admin/includes/class-custom-image-header.php
+++ b/wp-admin/includes/class-custom-image-header.php
@@ -572,7 +572,7 @@ class Custom_Image_Header {
 					$header_image_style .= 'height:' . $custom_header->height . 'px;';
 				}
 				?>
-	<div id="headimg" style="<?php echo $header_image_style; ?>">
+	<div id="headimg" style="<?php echo esc_attr( $header_image_style ); ?>">
 				<?php
 				if ( display_header_text() ) {
 					$style = ' style="color:#' . get_header_textcolor() . ';"';
@@ -1184,8 +1184,8 @@ endif;
 				'attachment_id' => $choice['attachment_id'],
 				'url'           => $choice['url'],
 				'thumbnail_url' => $choice['url'],
-				'height'        => $choice['height'],
-				'width'         => $choice['width'],
+				'height'        => absint( $choice['height'] ),
+				'width'         => absint( $choice['width'] ),
 			);

 			update_post_meta( $choice['attachment_id'], '_wp_attachment_is_custom_header', get_stylesheet() );
@@ -1216,7 +1216,13 @@ endif;
 			}
 		}

-		set_theme_mod( 'header_image', sanitize_url( $header_image_data['url'] ) );
+		$header_image_data['url'] = sanitize_url( $header_image_data['url'] );
+
+		if ( isset( $header_image_data['thumbnail_url'] ) ) {
+			$header_image_data['thumbnail_url'] = sanitize_url( $header_image_data['thumbnail_url'] );
+		}
+
+		set_theme_mod( 'header_image', $header_image_data['url'] );
 		set_theme_mod( 'header_image_data', $header_image_data );
 	}

@@ -1589,9 +1595,8 @@ endif;
 		$alt_text_key  = '_wp_attachment_image_alt';

 		foreach ( $header_images as &$header_image ) {
-			$header_meta               = get_post_meta( $header_image['attachment_id'] );
-			$header_image['timestamp'] = $header_meta[ $timestamp_key ] ?? '';
-			$header_image['alt_text']  = $header_meta[ $alt_text_key ] ?? '';
+			$header_image['timestamp'] = get_post_meta( $header_image['attachment_id'], $timestamp_key, true );
+			$header_image['alt_text']  = get_post_meta( $header_image['attachment_id'], $alt_text_key, true );
 		}

 		return $header_images;
diff --git a/wp-includes/customize/class-wp-customize-header-image-setting.php b/wp-includes/customize/class-wp-customize-header-image-setting.php
index 009e2e606f..8076e1e65c 100644
--- a/wp-includes/customize/class-wp-customize-header-image-setting.php
+++ b/wp-includes/customize/class-wp-customize-header-image-setting.php
@@ -15,6 +15,17 @@
  * @since 3.4.0
  *
  * @see WP_Customize_Setting
+ *
+ * @phpstan-type Header_Image_Data array{
+ *     attachment_id?: int,
+ *     url?: string,
+ *     thumbnail_url?: string,
+ *     timestamp?: int,
+ *     width?: int,
+ *     height?: int,
+ *     alt_text?: string,
+ *     attachment_parent?: int,
+ * }
  */
 final class WP_Customize_Header_Image_Setting extends WP_Customize_Setting {

@@ -62,4 +73,163 @@ final class WP_Customize_Header_Image_Setting extends WP_Customize_Setting {
 		}
 		return true;
 	}
+
+	/**
+	 * Sanitizes a header value.
+	 *
+	 * The value is expected to be one of the following:
+	 *
+	 * - An array of header image data, with the keys `attachment_id`, `url`, `thumbnail_url`, `timestamp`, `width`,
+	 *   `height`, `alt_text`, and `attachment_parent`, as supplied by {@see get_uploaded_header_images()}. Any other
+	 *   key is discarded.
+	 * - An array with a `choice` key, being the legacy format in which any of the other accepted values is nested.
+	 * - The string `remove-header`, `random-default-image`, or `random-uploaded-image`.
+	 * - A string corresponding to one of the keys for the array returned by {@see get_uploaded_header_images()}, or
+	 *   one of the keys for the array passed into {@see register_default_headers()}.
+	 *
+	 * @since 7.1.1
+	 *
+	 * @see WP_Customize_Header_Image_Setting::update()
+	 * @see Custom_Image_Header::set_header_image()
+	 *
+	 * @param mixed $value Value to sanitize.
+	 * @return array|string|WP_Error|null Sanitized value, or `null`/`WP_Error` if invalid. The array holds
+	 *                                    the header image data, or that data nested under a `choice` key,
+	 *                                    before the `customize_sanitize_header_image_data` filter, which
+	 *                                    may return anything, is applied to it.
+	 *
+	 * @phpstan-return array<mixed, mixed>|string|WP_Error|null
+	 */
+	public function sanitize( $value ) {
+		/*
+		 * The update() method unwraps the legacy `choice` format before handing the value off to
+		 * Custom_Image_Header::set_header_image(), so the nested value is what must be sanitized.
+		 */
+		if ( is_array( $value ) && isset( $value['choice'] ) ) {
+			$choice = $this->sanitize_choice( $value['choice'] );
+			if ( is_null( $choice ) || is_wp_error( $choice ) ) {
+				return $choice;
+			}
+			$value = array( 'choice' => $choice );
+		} else {
+			$value = $this->sanitize_choice( $value );
+			if ( is_null( $value ) || is_wp_error( $value ) ) {
+				return $value;
+			}
+		}
+
+		return parent::sanitize( $value );
+	}
+
+	/**
+	 * Sanitizes a header image choice.
+	 *
+	 * This is the value which is ultimately passed to {@see Custom_Image_Header::set_header_image()}, whether
+	 * supplied at the top level of the setting value or nested under its legacy `choice` key.
+	 *
+	 * @since 7.1.1
+	 *
+	 * @param mixed $value Value to sanitize.
+	 * @return array|string|WP_Error|null Sanitized value, or `null`/`WP_Error` if invalid.
+	 *
+	 * @phpstan-return Header_Image_Data|string|WP_Error|null
+	 */
+	private function sanitize_choice( $value ) {
+		// Custom_Image_Header::set_header_image() accepts an object in place of an array.
+		if ( is_object( $value ) ) {
+			$value = (array) $value;
+		}
+
+		if ( is_string( $value ) ) {
+			return sanitize_text_field( $value );
+		}
+
+		if ( ! is_array( $value ) ) {
+			return null;
+		}
+
+		/*
+		 * The sanitized value is assembled member by member rather than filtered down from the
+		 * supplied one, so that nothing but the members below can end up in it.
+		 */
+		$sanitized = array();
+
+		if ( isset( $value['attachment_id'] ) ) {
+			if ( ! is_scalar( $value['attachment_id'] ) ) {
+				return null;
+			}
+			$attachment_id = absint( $value['attachment_id'] );
+
+			/*
+			 * A supplied attachment must be an existing image, since its ID is written to postmeta and its
+			 * data displayed. Note that an ID of zero must be skipped rather than looked up, as
+			 * get_post_mime_type() falls back to the global post when passed an empty value.
+			 */
+			if ( $attachment_id > 0 ) {
+				$mime_type = get_post_mime_type( $attachment_id );
+				if ( ! is_string( $mime_type ) || ! str_starts_with( $mime_type, 'image/' ) ) {
+					return null;
+				}
+			}
+
+			$sanitized['attachment_id'] = $attachment_id;
+		}
+
+		if ( isset( $value['url'] ) ) {
+			if ( ! is_string( $value['url'] ) ) {
+				return null;
+			}
+			$sanitized['url'] = sanitize_url( $value['url'] );
+			if ( '' === $sanitized['url'] ) {
+				return new WP_Error( 'invalid_url', __( 'Invalid URL.' ) );
+			}
+		}
+
+		if ( isset( $value['thumbnail_url'] ) ) {
+			if ( ! is_string( $value['thumbnail_url'] ) ) {
+				return null;
+			}
+			$sanitized['thumbnail_url'] = sanitize_url( $value['thumbnail_url'] );
+			if ( '' === $sanitized['thumbnail_url'] ) {
+				return new WP_Error( 'invalid_url', __( 'Invalid URL.' ) );
+			}
+		}
+
+		if ( isset( $value['timestamp'] ) ) {
+			if ( ! is_scalar( $value['timestamp'] ) ) {
+				return null;
+			}
+			$sanitized['timestamp'] = absint( $value['timestamp'] );
+		}
+
+		if ( isset( $value['width'] ) ) {
+			if ( ! is_scalar( $value['width'] ) ) {
+				return null;
+			}
+			$sanitized['width'] = absint( $value['width'] );
+		}
+
+		if ( isset( $value['height'] ) ) {
+			if ( ! is_scalar( $value['height'] ) ) {
+				return null;
+			}
+			$sanitized['height'] = absint( $value['height'] );
+		}
+
+		if ( isset( $value['alt_text'] ) ) {
+			if ( ! is_string( $value['alt_text'] ) ) {
+				return null;
+			}
+			$sanitized['alt_text'] = sanitize_text_field( $value['alt_text'] );
+		}
+
+		if ( isset( $value['attachment_parent'] ) ) {
+			if ( ! is_scalar( $value['attachment_parent'] ) ) {
+				return null;
+			}
+			$sanitized['attachment_parent'] = absint( $value['attachment_parent'] );
+		}
+
+		return $sanitized;
+	}
 }
diff --git a/wp-includes/theme.php b/wp-includes/theme.php
index 9c18c71792..d81616c6c5 100644
--- a/wp-includes/theme.php
+++ b/wp-includes/theme.php
@@ -1551,6 +1551,7 @@ function get_uploaded_header_images() {
  * Gets the header image data.
  *
  * @since 3.4.0
+ * @since 7.1.1 The `width` and `height` are cast to non-negative integers.
  *
  * @global array $_wp_default_headers
  *
@@ -1589,7 +1590,14 @@ function get_custom_header() {
 		'height'        => get_theme_support( 'custom-header', 'height' ),
 		'video'         => get_theme_support( 'custom-header', 'video' ),
 	);
-	return (object) wp_parse_args( $data, $default );
+
+	if ( ! is_array( $data ) && ! is_object( $data ) ) {
+		$data = array();
+	}
+	$header         = (object) wp_parse_args( $data, $default );
+	$header->width  = absint( $header->width );
+	$header->height = absint( $header->height );
+	return $header;
 }

 /**
diff --git a/wp-includes/version.php b/wp-includes/version.php
index 91a2f68d24..f5b3b1998f 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-alpha-63658';
+$wp_version = '7.2-alpha-63659';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.