*/ public static function allowlist( $context ) { $allowed = array(); if ( 'post' === $context ) { $allowed['_wp_page_template'] = array( 'type' => 'slug', 'protected' => true, ); } if ( 'attachment' === $context ) { $allowed['_wp_attachment_image_alt'] = array( 'type' => 'text', 'protected' => true, ); } return self::drop_forbidden( $allowed ); } /** * Remove anything from an allowlist that must never be written. * * Applied last, so nothing added to the list above can reach the keys * the plugin's own safety depends on. * * @param array $allowed Candidate allowlist. * @return array */ private static function drop_forbidden( $allowed ) { $forbidden = self::never_importable(); $prefixes = self::never_importable_prefixes(); $clean = array(); foreach ( $allowed as $key => $spec ) { $key = (string) $key; if ( '' === $key || in_array( $key, $forbidden, true ) ) { continue; } $blocked = false; foreach ( $prefixes as $prefix ) { // The alt text sits under the _wp_attachment_ prefix and is the // one member of it that describes the image rather than the // file, so it is named back in. if ( 0 === strpos( $key, $prefix ) && '_wp_attachment_image_alt' !== $key ) { $blocked = true; break; } } if ( $blocked ) { continue; } $clean[ $key ] = is_array( $spec ) ? $spec : array( 'type' => 'text' ); } return $clean; } /** * Reduce a received meta array to what may be written, cleaned. * * @param array $meta Meta as the wire delivered it. * @param string $context One of post, attachment, term. * @return array Keys: values, refused. */ public static function filter( $meta, $context ) { $allowed = self::allowlist( $context ); $values = array(); $refused = array(); foreach ( (array) $meta as $key => $value ) { $key = is_string( $key ) ? $key : ''; if ( '' === $key || strlen( $key ) > 255 || ! isset( $allowed[ $key ] ) ) { if ( '' !== $key ) { $refused[] = $key; } continue; } $spec = $allowed[ $key ]; // A key WordPress hides from the editor is only written when the // allowlist says so in as many words. if ( is_protected_meta( $key, 'post' === $context || 'attachment' === $context ? 'post' : 'term' ) && empty( $spec['protected'] ) ) { $refused[] = $key; continue; } $clean = self::clean_value( $value, isset( $spec['type'] ) ? $spec['type'] : 'text' ); if ( null === $clean ) { $refused[] = $key; continue; } $values[ $key ] = $clean; } return array( 'values' => $values, 'refused' => array_values( array_unique( $refused ) ), ); } /** * Write the allowed part of a received meta array onto a post. * * @param int $post_id Local post id. * @param array $meta Meta as the wire delivered it. * @param string $post_type Post type. * @return string[] Keys that were refused. */ public static function apply_to_post( $post_id, $meta, $post_type ) { $context = 'attachment' === $post_type ? 'attachment' : 'post'; $result = self::filter( $meta, $context ); foreach ( $result['values'] as $key => $value ) { update_post_meta( $post_id, $key, $value ); } return $result['refused']; } /** * Write the allowed part of a received meta array onto a term. * * @param int $term_id Local term id. * @param array $meta Meta as the wire delivered it. * @return string[] Keys that were refused. */ public static function apply_to_term( $term_id, $meta ) { $result = self::filter( $meta, 'term' ); foreach ( $result['values'] as $key => $value ) { update_term_meta( $term_id, $key, $value ); } return $result['refused']; } /** * Validate and clean one value against its declared shape. * * @param mixed $value Value from the wire. * @param string $type Declared shape. * @return mixed|null Null when the value cannot be made to fit. */ private static function clean_value( $value, $type ) { if ( is_array( $value ) ) { $clean = self::clean_array( $value, $type, 0 ); return null === $clean ? null : $clean; } if ( is_object( $value ) || is_resource( $value ) ) { return null; } switch ( $type ) { case 'int': return is_numeric( $value ) ? (int) $value : null; case 'float': return is_numeric( $value ) ? (float) $value : null; case 'bool': return (bool) $value; case 'url': $url = Block_Sanitizer::sanitize_url( (string) $value ); return '' === $url ? null : $url; case 'slug': $slug = preg_replace( '#[^A-Za-z0-9_./-]#', '', (string) $value ); $slug = str_replace( array( '..', '//' ), '', (string) $slug ); return '' === $slug ? null : $slug; case 'html': return self::within_length( Block_Sanitizer::sanitize_html( (string) $value ) ); case 'text': default: $text = str_replace( "\0", '', (string) $value ); return self::within_length( sanitize_textarea_field( $text ) ); } } /** * Validate and clean an array value. * * @param array $value Value. * @param string $type Declared shape of the leaves. * @param int $depth Current depth. * @return array|null */ private static function clean_array( $value, $type, $depth ) { if ( $depth >= self::MAX_DEPTH || count( $value ) > self::MAX_ITEMS ) { return null; } $clean = array(); foreach ( $value as $key => $item ) { if ( ! is_string( $key ) && ! is_int( $key ) ) { return null; } if ( is_string( $key ) ) { $key = sanitize_text_field( str_replace( "\0", '', $key ) ); } if ( is_array( $item ) ) { $child = self::clean_array( $item, $type, $depth + 1 ); if ( null === $child ) { return null; } $clean[ $key ] = $child; continue; } $child = self::clean_value( $item, $type ); if ( null === $child ) { continue; } $clean[ $key ] = $child; } $encoded = wp_json_encode( $clean ); return ( false !== $encoded && strlen( $encoded ) <= self::MAX_LENGTH ) ? $clean : null; } /** * Refuse a string that is longer than a meta value has any business being. * * @param string $value Value. * @return string|null */ private static function within_length( $value ) { return strlen( $value ) > self::MAX_LENGTH ? null : $value; } }