normalizes HTML entities. It will convert `AT&T` to the correct
* `AT&T`, `:` to `:`, `YZZY;` to `&#XYZZY;` and so on.
*
* When `$context` is set to 'xml', HTML entities are converted to their code points. For
* example, `AT&T…YZZY;` is converted to `AT&T…&#XYZZY;`.
*
* @since 1.0.0
* @since 5.5.0 Added `$context` parameter.
*
* @param string $content Content to normalize entities.
* @param string $context Context for normalization. Can be either 'html' or 'xml'.
* Default 'html'.
* @return string Content with normalized entities.
*/
function wp_kses_normalize_entities( $content, $context = 'html' ) {
// Disarm all entities by converting & to &
$content = str_replace( '&', '&', $content );
// Change back the allowed entities in our list of allowed entities.
if ( 'xml' === $context ) {
$content = preg_replace_callback( '/&([A-Za-z]{2,8}[0-9]{0,2});/', 'wp_kses_xml_named_entities', $content );
} else {
$content = preg_replace_callback( '/&([A-Za-z]{2,8}[0-9]{0,2});/', 'wp_kses_named_entities', $content );
}
$content = preg_replace_callback( '/&#(0*[0-9]{1,7});/', 'wp_kses_normalize_entities2', $content );
$content = preg_replace_callback( '/&#[Xx](0*[0-9A-Fa-f]{1,6});/', 'wp_kses_normalize_entities3', $content );
return $content;
}
/**
* Callback for `wp_kses_normalize_entities()` regular expression.
*
* This function only accepts valid named entity references, which are finite,
* case-sensitive, and highly scrutinized by HTML and XML validators.
*
* @since 3.0.0
*
* @global array $allowedentitynames
*
* @param array $matches preg_replace_callback() matches array.
* @return string Correctly encoded entity.
*/
function wp_kses_named_entities( $matches ) {
global $allowedentitynames;
if ( empty( $matches[1] ) ) {
return '';
}
$i = $matches[1];
return ( ! in_array( $i, $allowedentitynames, true ) ) ? "&$i;" : "&$i;";
}
/**
* Callback for `wp_kses_normalize_entities()` regular expression.
*
* This function only accepts valid named entity references, which are finite,
* case-sensitive, and highly scrutinized by XML validators. HTML named entity
* references are converted to their code points.
*
* @since 5.5.0
*
* @global array $allowedentitynames
* @global array $allowedxmlentitynames
*
* @param array $matches preg_replace_callback() matches array.
* @return string Correctly encoded entity.
*/
function wp_kses_xml_named_entities( $matches ) {
global $allowedentitynames, $allowedxmlentitynames;
if ( empty( $matches[1] ) ) {
return '';
}
$i = $matches[1];
if ( in_array( $i, $allowedxmlentitynames, true ) ) {
return "&$i;";
} elseif ( in_array( $i, $allowedentitynames, true ) ) {
return html_entity_decode( "&$i;", ENT_HTML5 );
}
return "&$i;";
}
/**
* Callback for `wp_kses_normalize_entities()` regular expression.
*
* This function helps `wp_kses_normalize_entities()` to only accept 16-bit
* values and nothing more for `number;` entities.
*
* @access private
* @ignore
* @since 1.0.0
*
* @param array $matches `preg_replace_callback()` matches array.
* @return string Correctly encoded entity.
*/
function wp_kses_normalize_entities2( $matches ) {
if ( empty( $matches[1] ) ) {
return '';
}
$i = $matches[1];
if ( valid_unicode( $i ) ) {
$i = str_pad( ltrim( $i, '0' ), 3, '0', STR_PAD_LEFT );
$i = "$i;";
} else {
$i = "&#$i;";
}
return $i;
}
/**
* Callback for `wp_kses_normalize_entities()` for regular expression.
*
* This function helps `wp_kses_normalize_entities()` to only accept valid Unicode
* numeric entities in hex form.
*
* @since 2.7.0
* @access private
* @ignore
*
* @param array $matches `preg_replace_callback()` matches array.
* @return string Correctly encoded entity.
*/
function wp_kses_normalize_entities3( $matches ) {
if ( empty( $matches[1] ) ) {
return '';
}
$hexchars = $matches[1];
return ( ! valid_unicode( hexdec( $hexchars ) ) ) ? "&#x$hexchars;" : '' . ltrim( $hexchars, '0' ) . ';';
}
/**
* Determines if a Unicode codepoint is valid.
*
* @since 2.7.0
*
* @param int $i Unicode codepoint.
* @return bool Whether or not the codepoint is a valid Unicode codepoint.
*/
function valid_unicode( $i ) {
$i = (int) $i;
return ( 0x9 === $i || 0xa === $i || 0xd === $i ||
( 0x20 <= $i && $i <= 0xd7ff ) ||
( 0xe000 <= $i && $i <= 0xfffd ) ||
( 0x10000 <= $i && $i <= 0x10ffff )
);
}
/**
* Converts all numeric HTML entities to their named counterparts.
*
* This function decodes numeric HTML entities (`A` and `A`).
* It doesn't do anything with named entities like `ä`, but we don't
* need them in the allowed URL protocols system anyway.
*
* @since 1.0.0
*
* @param string $content Content to change entities.
* @return string Content after decoded entities.
*/
function wp_kses_decode_entities( $content ) {
$content = preg_replace_callback( '/([0-9]+);/', '_wp_kses_decode_entities_chr', $content );
$content = preg_replace_callback( '/[Xx]([0-9A-Fa-f]+);/', '_wp_kses_decode_entities_chr_hexdec', $content );
return $content;
}
/**
* Regex callback for `wp_kses_decode_entities()`.
*
* @since 2.9.0
* @access private
* @ignore
*
* @param array $matches preg match
* @return string
*/
function _wp_kses_decode_entities_chr( $matches ) {
return chr( $matches[1] );
}
/**
* Regex callback for `wp_kses_decode_entities()`.
*
* @since 2.9.0
* @access private
* @ignore
*
* @param array $matches preg match
* @return string
*/
function _wp_kses_decode_entities_chr_hexdec( $matches ) {
return chr( hexdec( $matches[1] ) );
}
/**
* Sanitize content with allowed HTML KSES rules.
*
* This function expects slashed data.
*
* @since 1.0.0
*
* @param string $data Content to filter, expected to be escaped with slashes.
* @return string Filtered content.
*/
function wp_filter_kses( $data ) {
return addslashes( wp_kses( stripslashes( $data ), current_filter() ) );
}
/**
* Sanitize content with allowed HTML KSES rules.
*
* This function expects unslashed data.
*
* @since 2.9.0
*
* @param string $data Content to filter, expected to not be escaped.
* @return string Filtered content.
*/
function wp_kses_data( $data ) {
return wp_kses( $data, current_filter() );
}
/**
* Sanitizes content for allowed HTML tags for post content.
*
* Post content refers to the page contents of the 'post' type and not `$_POST`
* data from forms.
*
* This function expects slashed data.
*
* @since 2.0.0
*
* @param string $data Post content to filter, expected to be escaped with slashes.
* @return string Filtered post content with allowed HTML tags and attributes intact.
*/
function wp_filter_post_kses( $data ) {
return addslashes( wp_kses( stripslashes( $data ), 'post' ) );
}
/**
* Sanitizes global styles user content removing unsafe rules.
*
* @since 5.9.0
*
* @param string $data Post content to filter.
* @return string Filtered post content with unsafe rules removed.
*/
function wp_filter_global_styles_post( $data ) {
$decoded_data = json_decode( wp_unslash( $data ), true );
$json_decoding_error = json_last_error();
if (
JSON_ERROR_NONE === $json_decoding_error &&
is_array( $decoded_data ) &&
isset( $decoded_data['isGlobalStylesUserThemeJSON'] ) &&
$decoded_data['isGlobalStylesUserThemeJSON']
) {
unset( $decoded_data['isGlobalStylesUserThemeJSON'] );
$data_to_encode = WP_Theme_JSON::remove_insecure_properties( $decoded_data );
$data_to_encode['isGlobalStylesUserThemeJSON'] = true;
return wp_slash( wp_json_encode( $data_to_encode ) );
}
return $data;
}
/**
* Sanitizes content for allowed HTML tags for post content.
*
* Post content refers to the page contents of the 'post' type and not `$_POST`
* data from forms.
*
* This function expects unslashed data.
*
* @since 2.9.0
*
* @param string $data Post content to filter.
* @return string Filtered post content with allowed HTML tags and attributes intact.
*/
function wp_kses_post( $data ) {
return wp_kses( $data, 'post' );
}
/**
* Navigates through an array, object, or scalar, and sanitizes content for
* allowed HTML tags for post content.
*
* @since 4.4.2
*
* @see map_deep()
*
* @param mixed $data The array, object, or scalar value to inspect.
* @return mixed The filtered content.
*/
function wp_kses_post_deep( $data ) {
return map_deep( $data, 'wp_kses_post' );
}
/**
* Strips all HTML from a text string.
*
* This function expects slashed data.
*
* @since 2.1.0
*
* @param string $data Content to strip all HTML from.
* @return string Filtered content without any HTML.
*/
function wp_filter_nohtml_kses( $data ) {
return addslashes( wp_kses( stripslashes( $data ), 'strip' ) );
}
/**
* Adds all KSES input form content filters.
*
* All hooks have default priority. The `wp_filter_kses()` function is added to
* the 'pre_comment_content' and 'title_save_pre' hooks.
*
* The `wp_filter_post_kses()` function is added to the 'content_save_pre',
* 'excerpt_save_pre', and 'content_filtered_save_pre' hooks.
*
* @since 2.0.0
*/
function kses_init_filters() {
// Normal filtering.
add_filter( 'title_save_pre', 'wp_filter_kses' );
// Comment filtering.
if ( current_user_can( 'unfiltered_html' ) ) {
add_filter( 'pre_comment_content', 'wp_filter_post_kses' );
} else {
add_filter( 'pre_comment_content', 'wp_filter_kses' );
}
// Global Styles filtering: Global Styles filters should be executed before normal post_kses HTML filters.
add_filter( 'content_save_pre', 'wp_filter_global_styles_post', 9 );
add_filter( 'content_filtered_save_pre', 'wp_filter_global_styles_post', 9 );
// Post filtering.
add_filter( 'content_save_pre', 'wp_filter_post_kses' );
add_filter( 'excerpt_save_pre', 'wp_filter_post_kses' );
add_filter( 'content_filtered_save_pre', 'wp_filter_post_kses' );
}
/**
* Removes all KSES input form content filters.
*
* A quick procedural method to removing all of the filters that KSES uses for
* content in WordPress Loop.
*
* Does not remove the `kses_init()` function from {@see 'init'} hook (priority is
* default). Also does not remove `kses_init()` function from {@see 'set_current_user'}
* hook (priority is also default).
*
* @since 2.0.6
*/
function kses_remove_filters() {
// Normal filtering.
remove_filter( 'title_save_pre', 'wp_filter_kses' );
// Comment filtering.
remove_filter( 'pre_comment_content', 'wp_filter_post_kses' );
remove_filter( 'pre_comment_content', 'wp_filter_kses' );
// Global Styles filtering.
remove_filter( 'content_save_pre', 'wp_filter_global_styles_post', 9 );
remove_filter( 'content_filtered_save_pre', 'wp_filter_global_styles_post', 9 );
// Post filtering.
remove_filter( 'content_save_pre', 'wp_filter_post_kses' );
remove_filter( 'excerpt_save_pre', 'wp_filter_post_kses' );
remove_filter( 'content_filtered_save_pre', 'wp_filter_post_kses' );
}
/**
* Sets up most of the KSES filters for input form content.
*
* First removes all of the KSES filters in case the current user does not need
* to have KSES filter the content. If the user does not have `unfiltered_html`
* capability, then KSES filters are added.
*
* @since 2.0.0
*/
function kses_init() {
kses_remove_filters();
if ( ! current_user_can( 'unfiltered_html' ) ) {
kses_init_filters();
}
}
/**
* Filters an inline style attribute and removes disallowed rules.
*
* @since 2.8.1
* @since 4.4.0 Added support for `min-height`, `max-height`, `min-width`, and `max-width`.
* @since 4.6.0 Added support for `list-style-type`.
* @since 5.0.0 Added support for `background-image`.
* @since 5.1.0 Added support for `text-transform`.
* @since 5.2.0 Added support for `background-position` and `grid-template-columns`.
* @since 5.3.0 Added support for `grid`, `flex` and `column` layout properties.
* Extended `background-*` support for individual properties.
* @since 5.3.1 Added support for gradient backgrounds.
* @since 5.7.1 Added support for `object-position`.
* @since 5.8.0 Added support for `calc()` and `var()` values.
* @since 6.1.0 Added support for `min()`, `max()`, `minmax()`, `clamp()`,
* nested `var()` values, and assigning values to CSS variables.
* Added support for `object-fit`, `gap`, `column-gap`, `row-gap`, and `flex-wrap`.
* Extended `margin-*` and `padding-*` support for logical properties.
* @since 6.2.0 Added support for `aspect-ratio`, `position`, `top`, `right`, `bottom`, `left`,
* and `z-index` CSS properties.
* @since 6.3.0 Extended support for `filter` to accept a URL and added support for repeat().
* Added support for `box-shadow`.
* @since 6.4.0 Added support for `writing-mode`.
*
* @param string $css A string of CSS rules.
* @param string $deprecated Not used.
* @return string Filtered string of CSS rules.
*/
function safecss_filter_attr( $css, $deprecated = '' ) {
if ( ! empty( $deprecated ) ) {
_deprecated_argument( __FUNCTION__, '2.8.1' ); // Never implemented.
}
$css = wp_kses_no_null( $css );
$css = str_replace( array( "\n", "\r", "\t" ), '', $css );
$allowed_protocols = wp_allowed_protocols();
$css_array = explode( ';', trim( $css ) );
/**
* Filters the list of allowed CSS attributes.
*
* @since 2.8.1
*
* @param string[] $attr Array of allowed CSS attributes.
*/
$allowed_attr = apply_filters(
'safe_style_css',
array(
'background',
'background-color',
'background-image',
'background-position',
'background-size',
'background-attachment',
'background-blend-mode',
'border',
'border-radius',
'border-width',
'border-color',
'border-style',
'border-right',
'border-right-color',
'border-right-style',
'border-right-width',
'border-bottom',
'border-bottom-color',
'border-bottom-left-radius',
'border-bottom-right-radius',
'border-bottom-style',
'border-bottom-width',
'border-bottom-right-radius',
'border-bottom-left-radius',
'border-left',
'border-left-color',
'border-left-style',
'border-left-width',
'border-top',
'border-top-color',
'border-top-left-radius',
'border-top-right-radius',
'border-top-style',
'border-top-width',
'border-top-left-radius',
'border-top-right-radius',
'border-spacing',
'border-collapse',
'caption-side',
'columns',
'column-count',
'column-fill',
'column-gap',
'column-rule',
'column-span',
'column-width',
'color',
'filter',
'font',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'letter-spacing',
'line-height',
'text-align',
'text-decoration',
'text-indent',
'text-transform',
'height',
'min-height',
'max-height',
'width',
'min-width',
'max-width',
'margin',
'margin-right',
'margin-bottom',
'margin-left',
'margin-top',
'margin-block-start',
'margin-block-end',
'margin-inline-start',
'margin-inline-end',
'padding',
'padding-right',
'padding-bottom',
'padding-left',
'padding-top',
'padding-block-start',
'padding-block-end',
'padding-inline-start',
'padding-inline-end',
'flex',
'flex-basis',
'flex-direction',
'flex-flow',
'flex-grow',
'flex-shrink',
'flex-wrap',
'gap',
'column-gap',
'row-gap',
'grid-template-columns',
'grid-auto-columns',
'grid-column-start',
'grid-column-end',
'grid-column-gap',
'grid-template-rows',
'grid-auto-rows',
'grid-row-start',
'grid-row-end',
'grid-row-gap',
'grid-gap',
'justify-content',
'justify-items',
'justify-self',
'align-content',
'align-items',
'align-self',
'clear',
'cursor',
'direction',
'float',
'list-style-type',
'object-fit',
'object-position',
'overflow',
'vertical-align',
'writing-mode',
'position',
'top',
'right',
'bottom',
'left',
'z-index',
'box-shadow',
'aspect-ratio',
// Custom CSS properties.
'--*',
)
);
/*
* CSS attributes that accept URL data types.
*
* This is in accordance to the CSS spec and unrelated to
* the sub-set of supported attributes above.
*
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/url
*/
$css_url_data_types = array(
'background',
'background-image',
'cursor',
'filter',
'list-style',
'list-style-image',
);
/*
* CSS attributes that accept gradient data types.
*
*/
$css_gradient_data_types = array(
'background',
'background-image',
);
if ( empty( $allowed_attr ) ) {
return $css;
}
$css = '';
foreach ( $css_array as $css_item ) {
if ( '' === $css_item ) {
continue;
}
$css_item = trim( $css_item );
$css_test_string = $css_item;
$found = false;
$url_attr = false;
$gradient_attr = false;
$is_custom_var = false;
if ( ! str_contains( $css_item, ':' ) ) {
$found = true;
} else {
$parts = explode( ':', $css_item, 2 );
$css_selector = trim( $parts[0] );
// Allow assigning values to CSS variables.
if ( in_array( '--*', $allowed_attr, true ) && preg_match( '/^--[a-zA-Z0-9-_]+$/', $css_selector ) ) {
$allowed_attr[] = $css_selector;
$is_custom_var = true;
}
if ( in_array( $css_selector, $allowed_attr, true ) ) {
$found = true;
$url_attr = in_array( $css_selector, $css_url_data_types, true );
$gradient_attr = in_array( $css_selector, $css_gradient_data_types, true );
}
if ( $is_custom_var ) {
$css_value = trim( $parts[1] );
$url_attr = str_starts_with( $css_value, 'url(' );
$gradient_attr = str_contains( $css_value, '-gradient(' );
}
}
if ( $found && $url_attr ) {
// Simplified: matches the sequence `url(*)`.
preg_match_all( '/url\([^)]+\)/', $parts[1], $url_matches );
foreach ( $url_matches[0] as $url_match ) {
// Clean up the URL from each of the matches above.
preg_match( '/^url\(\s*([\'\"]?)(.*)(\g1)\s*\)$/', $url_match, $url_pieces );
if ( empty( $url_pieces[2] ) ) {
$found = false;
break;
}
$url = trim( $url_pieces[2] );
if ( empty( $url ) || wp_kses_bad_protocol( $url, $allowed_protocols ) !== $url ) {
$found = false;
break;
} else {
// Remove the whole `url(*)` bit that was matched above from the CSS.
$css_test_string = str_replace( $url_match, '', $css_test_string );
}
}
}
if ( $found && $gradient_attr ) {
$css_value = trim( $parts[1] );
if ( preg_match( '/^(repeating-)?(linear|radial|conic)-gradient\(([^()]|rgb[a]?\([^()]*\))*\)$/', $css_value ) ) {
// Remove the whole `gradient` bit that was matched above from the CSS.
$css_test_string = str_replace( $css_value, '', $css_test_string );
}
}
if ( $found ) {
/*
* Allow CSS functions like var(), calc(), etc. by removing them from the test string.
* Nested functions and parentheses are also removed, so long as the parentheses are balanced.
*/
$css_test_string = preg_replace(
'/\b(?:var|calc|min|max|minmax|clamp|repeat)(\((?:[^()]|(?1))*\))/',
'',
$css_test_string
);
/*
* Disallow CSS containing \ ( & } = or comments, except for within url(), var(), calc(), etc.
* which were removed from the test string above.
*/
$allow_css = ! preg_match( '%[\\\(&=}]|/\*%', $css_test_string );
/**
* Filters the check for unsafe CSS in `safecss_filter_attr`.
*
* Enables developers to determine whether a section of CSS should be allowed or discarded.
* By default, the value will be false if the part contains \ ( & } = or comments.
* Return true to allow the CSS part to be included in the output.
*
* @since 5.5.0
*
* @param bool $allow_css Whether the CSS in the test string is considered safe.
* @param string $css_test_string The CSS string to test.
*/
$allow_css = apply_filters( 'safecss_filter_attr_allow_css', $allow_css, $css_test_string );
// Only add the CSS part if it passes the regex check.
if ( $allow_css ) {
if ( '' !== $css ) {
$css .= ';';
}
$css .= $css_item;
}
}
}
return $css;
}
/**
* Helper function to add global attributes to a tag in the allowed HTML list.
*
* @since 3.5.0
* @since 5.0.0 Added support for `data-*` wildcard attributes.
* @since 6.0.0 Added `dir`, `lang`, and `xml:lang` to global attributes.
* @since 6.3.0 Added `aria-controls`, `aria-current`, and `aria-expanded` attributes.
* @since 6.4.0 Added `aria-live` and `hidden` attributes.
*
* @access private
* @ignore
*
* @param array $value An array of attributes.
* @return array The array of attributes with global attributes added.
*/
function _wp_add_global_attributes( $value ) {
$global_attributes = array(
'aria-controls' => true,
'aria-current' => true,
'aria-describedby' => true,
'aria-details' => true,
'aria-expanded' => true,
'aria-hidden' => true,
'aria-label' => true,
'aria-labelledby' => true,
'aria-live' => true,
'class' => true,
'data-*' => true,
'dir' => true,
'hidden' => true,
'id' => true,
'lang' => true,
'style' => true,
'title' => true,
'role' => true,
'xml:lang' => true,
);
if ( true === $value ) {
$value = array();
}
if ( is_array( $value ) ) {
return array_merge( $value, $global_attributes );
}
return $value;
}
/**
* Helper function to check if this is a safe PDF URL.
*
* @since 5.9.0
* @access private
* @ignore
*
* @param string $url The URL to check.
* @return bool True if the URL is safe, false otherwise.
*/
function _wp_kses_allow_pdf_objects( $url ) {
// We're not interested in URLs that contain query strings or fragments.
if ( str_contains( $url, '?' ) || str_contains( $url, '#' ) ) {
return false;
}
// If it doesn't have a PDF extension, it's not safe.
if ( ! str_ends_with( $url, '.pdf' ) ) {
return false;
}
// If the URL host matches the current site's media URL, it's safe.
$upload_info = wp_upload_dir( null, false );
$parsed_url = wp_parse_url( $upload_info['url'] );
$upload_host = isset( $parsed_url['host'] ) ? $parsed_url['host'] : '';
$upload_port = isset( $parsed_url['port'] ) ? ':' . $parsed_url['port'] : '';
if ( str_starts_with( $url, "http://$upload_host$upload_port/" )
|| str_starts_with( $url, "https://$upload_host$upload_port/" )
) {
return true;
}
return false;
}