Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
2bf9709
attempt to bring 29807.8.diff up to date with trunk
adamsilverstein Feb 26, 2024
11336a3
Merge remote-tracking branch 'upstream/trunk' into pr/6184
azaozz Mar 11, 2024
05dcd69
Fix coding standards spaces and methods visibility
azaozz Mar 11, 2024
499dc82
More CS empty space fixes.
azaozz Mar 11, 2024
7a75eba
Merge branch 'trunk' into ticket/29807
adamsilverstein Aug 15, 2025
69312e7
Apply suggestion from @azaozz
adamsilverstein Aug 15, 2025
3c6defd
string in_array test
adamsilverstein Aug 15, 2025
c88673d
expand test slightly
adamsilverstein Aug 15, 2025
022def5
Cleanup; move multi_uri to parameter
adamsilverstein Aug 15, 2025
321e441
phpcbf
adamsilverstein Aug 15, 2025
c7c63dd
add sizes attribute to test
adamsilverstein Aug 15, 2025
e3c1684
enable sizes
adamsilverstein Aug 15, 2025
3862627
Improve doc block
adamsilverstein Aug 15, 2025
4cb049a
test clean up
adamsilverstein Aug 15, 2025
ca94908
Update tests/phpunit/tests/kses.php
adamsilverstein Aug 17, 2025
49f1ba6
Update src/wp-includes/kses.php
adamsilverstein Aug 17, 2025
2b5706c
remove unused $uris
adamsilverstein Aug 17, 2025
d116c8e
Add additional test cases.
adamsilverstein Aug 17, 2025
fbeed82
Merge branch 'trunk' into ticket/29807
adamsilverstein Aug 27, 2025
f9e002c
phpcbf
adamsilverstein Aug 27, 2025
80521e4
Merge remote-tracking branch 'origin/trunk' into ticket/29807
adamsilverstein Mar 13, 2026
0068aac
Add tests exposing srcset and img attribute bugs
adamsilverstein Mar 13, 2026
f11aeb2
Add decoding and fetchpriority to img allowed attrs
adamsilverstein Mar 13, 2026
cdb84fc
Fix srcset URI sanitization for URLs with commas
adamsilverstein Mar 13, 2026
399aa30
Fix array double arrow alignment in kses tests
adamsilverstein Mar 13, 2026
e5488a6
Merge branch 'trunk' into ticket/29807
adamsilverstein Mar 14, 2026
1f02276
Add kses srcset/picture tests, update @since tag
adamsilverstein Mar 14, 2026
9bb1ebf
Fix array double arrow alignment in new tests
adamsilverstein Mar 14, 2026
2bdc1f9
Merge branch 'trunk' into ticket/29807
adamsilverstein Mar 14, 2026
61881bf
Merge branch 'trunk' into ticket/29807
adamsilverstein Apr 23, 2026
99abbda
KSES: Handle decimal pixel density descriptors in srcset sanitization.
adamsilverstein Apr 23, 2026
8f28de9
Tests: Extend srcset and picture KSES coverage.
adamsilverstein Apr 23, 2026
3917202
Merge remote-tracking branch 'origin/trunk' into ticket/29807
adamsilverstein Jul 10, 2026
fc99930
KSES: Apply review feedback to wp_kses_sanitize_uris().
adamsilverstein Jul 10, 2026
5ea4632
KSES: Sanitize descriptor-less srcset entries individually.
adamsilverstein Jul 10, 2026
215b942
HTML API: Preserve srcset values in set_attribute().
adamsilverstein Jul 10, 2026
e979fc1
Merge branch 'trunk' into ticket/29807
adamsilverstein Jul 15, 2026
a86d336
KSES: Add wp_kses_multi_uri_attributes() as the multi-URI source of t…
adamsilverstein Jul 15, 2026
6d3d20a
KSES: Document accepted srcset limitation for invalid-descriptor commas.
adamsilverstein Jul 15, 2026
bdaca80
Merge branch 'trunk' into ticket/29807
adamsilverstein Jul 15, 2026
3018e2e
Merge branch 'trunk' into ticket/29807
adamsilverstein Jul 15, 2026
8581313
Merge branch 'trunk' into ticket/29807
adamsilverstein Aug 5, 2026
826a994
KSES: Parse srcset values with the HTML spec's parsing algorithm.
adamsilverstein Aug 5, 2026
512e959
KSES: Sanitize multi-URI attributes in their own right.
adamsilverstein Aug 5, 2026
9d0efd5
KSES: Keep wp_kses_sanitize_uris() tolerant of loose caller input.
adamsilverstein Aug 5, 2026
e7ed4c2
HTML API: Sanitize multi-URI attribute URLs in set_attribute().
adamsilverstein Aug 5, 2026
7a5cf86
KSES: Pin entity-form scheme handling in srcset candidates.
adamsilverstein Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions src/wp-includes/html-api/class-wp-html-tag-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4638,18 +4638,29 @@ public function set_attribute( $name, $value ): bool {
*
* @see https://html.spec.whatwg.org/#attributes-3
*/
$escaped_new_value = in_array( $comparable_name, wp_kses_uri_attributes(), true )
? esc_url( $value )
: strtr(
$value,
array(
'<' => '&lt;',
'>' => '&gt;',
'&' => '&amp;',
'"' => '&quot;',
"'" => '&apos;',
)
);
$syntax_characters = array(
'<' => '&lt;',
'>' => '&gt;',
'&' => '&amp;',
'"' => '&quot;',
"'" => '&apos;',
);

if ( in_array( $comparable_name, wp_kses_multi_uri_attributes(), true ) ) {
/*
* Multi-URI attributes such as srcset contain a comma-separated list
* of URLs with optional width/density descriptors, not a single URL.
* Passing the whole value through esc_url() would encode the
* descriptor spaces and corrupt the list, so each URL in the list
* is sanitized individually before the value receives the standard
* attribute escaping.
*/
$escaped_new_value = strtr( wp_kses_sanitize_uris( $comparable_name, $value, wp_allowed_protocols() ), $syntax_characters );
} elseif ( in_array( $comparable_name, wp_kses_uri_attributes(), true ) ) {
$escaped_new_value = esc_url( $value );
} else {
$escaped_new_value = strtr( $value, $syntax_characters );
}

// If the escaping functions wiped out the update, reject it and indicate it was rejected.
if ( '' === $escaped_new_value && '' !== $value ) {
Expand Down
228 changes: 209 additions & 19 deletions src/wp-includes/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,17 +213,21 @@
),
'i' => array(),
'img' => array(
'alt' => true,
'align' => true,
'border' => true,
'height' => true,
'hspace' => true,
'loading' => true,
'longdesc' => true,
'vspace' => true,
'src' => true,
'usemap' => true,
'width' => true,
'align' => true,
'alt' => true,
'border' => true,
'decoding' => true,
'fetchpriority' => true,
'height' => true,
'hspace' => true,
'loading' => true,
'longdesc' => true,
'sizes' => true,
'src' => true,
'srcset' => true,
'usemap' => true,
'vspace' => true,
'width' => true,
),
'ins' => array(
'datetime' => true,
Expand Down Expand Up @@ -274,6 +278,7 @@
'p' => array(
'align' => true,
),
'picture' => array(),
'pre' => array(
'width' => true,
),
Expand All @@ -299,6 +304,12 @@
'align' => true,
),
'small' => array(),
'source' => array(
'media' => true,
'sizes' => true,
'srcset' => true,
'type' => true,
),
'strike' => array(),
'strong' => array(),
'sub' => array(),
Expand Down Expand Up @@ -982,7 +993,6 @@ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) {
* @return string Filtered attribute.
*/
function wp_kses_one_attr( $attr, $element ) {
Comment thread
adamsilverstein marked this conversation as resolved.
$uris = wp_kses_uri_attributes();
$allowed_html = wp_kses_allowed_html( 'post' );
$allowed_protocols = wp_allowed_protocols();
$attr = wp_kses_no_null( $attr, array( 'slash_zero' => 'keep' ) );
Expand Down Expand Up @@ -1026,10 +1036,7 @@ function wp_kses_one_attr( $attr, $element ) {
// Sanitize quotes, angle braces, and entities.
$value = esc_attr( $value );

// Sanitize URI values.
if ( in_array( strtolower( $name ), $uris, true ) ) {
$value = wp_kses_bad_protocol( $value, $allowed_protocols );
}
$value = wp_kses_sanitize_uris( $name, $value, $allowed_protocols );

$attr = "$name=$quote$value$quote";
$vless = 'n';
Expand Down Expand Up @@ -1329,6 +1336,7 @@ function wp_kses_uri_attributes() {
'poster',
'profile',
'src',
'srcset',
'usemap',
'xmlns',
);
Expand All @@ -1348,6 +1356,45 @@ function wp_kses_uri_attributes() {
return $uri_attributes;
}

/**
* Returns an array of HTML attribute names whose value contains a list of URLs.
*
* Unlike single-URL attributes such as `href` or `src`, these attributes hold a
* comma-separated list of URLs, each optionally followed by a descriptor, for
* example `srcset="small.jpg 480w, large.jpg 1024w"`. Their values must be split
* into individual candidates so each URL can be sanitized on its own, and must
* not be passed through `esc_url()` as a whole.
*
* An attribute in this list is sanitized as a URI attribute in its own right:
* it does not additionally need to be present in {@see wp_kses_uri_attributes()}.
* Attributes should still be added to both lists so that code consulting only
* {@see wp_kses_uri_attributes()} recognizes them as URI attributes.
*
* @since 7.1.0
*
* @return string[] HTML attribute names whose value contains a list of URLs.
*/
function wp_kses_multi_uri_attributes() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTML only defines a few global attributes, so while functions like esc_url() are stuck in legacy interfaces that prevent determining the type of an HTML attribute, we have the opportunity in new code to do better.

  • the attributes table shows the type of srcset on img and source elements, but on any other element it won’t be interpreted in the same way by the browser.
  • the srcset definition explains how to parse it.
  • the imagesrcset is just like it but exists on the link element.

to this point, I think the naming is a bit misleading. srcset is not a list of URLs or URIs, but a comma-separated list of image candidate strings. some attributes have space-separated lists, in contrast, and as you note, may contain width and pixel-density descriptors. this is not like ping on A or AREA elements, which only accept space-separated non-empty URLs.

I like the direction this is reaching for, and have wanted to improve Core’s handling of URL attributes myself, but I think we should be careful about trapping ourselves in a corner the way we have with esc_url().

@westonruter and I spoke about making semantic functions in the HTML API to handle each attribute byte, but obviously this requires substantial work to handle the varied types of those attributes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the sanitization spec mentions detecting javascript: URIs, which I think is our main concern. it would be interesting to see if we can align with this relatively new part of the HTML spec so that our behaviors mirror the browsers’

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback @dmsnell

$multi_uri_attributes = array(
'srcset',
);

/**
* Filters the list of attributes whose value contains a list of URLs.
*
* Use this filter to add attributes that, like `srcset`, contain multiple
* comma-separated URLs with optional descriptors. Attributes added here
* are sanitized per URL; also add them to the `wp_kses_uri_attributes`
* filter so that code consulting only that list recognizes them as URI
* attributes.
*
* @since 7.1.0
*
* @param string[] $multi_uri_attributes HTML attribute names whose value contains a list of URLs.
*/
return apply_filters( 'wp_kses_multi_uri_attributes', $multi_uri_attributes );
}

/**
* Callback for `wp_kses_split()`.
*
Expand Down Expand Up @@ -1708,7 +1755,6 @@ function wp_kses_attr_check( &$name, &$value, &$whole, $vless, $element, $allowe
*/
function wp_kses_hair( $attr, $allowed_protocols ) {
$attributes = array();
$uris = wp_kses_uri_attributes();

$processor = new WP_HTML_Tag_Processor( "<wp {$attr}>" );
$processor->next_token();
Expand All @@ -1726,11 +1772,15 @@ function wp_kses_hair( $attr, $allowed_protocols ) {
'"' => '&quot;',
);

// Look the attribute lists up once per call rather than once per attribute.
$uri_attrs = wp_kses_uri_attributes();
$multi_uri_attrs = wp_kses_multi_uri_attributes();

foreach ( $attribute_names as $name ) {
$value = $processor->get_attribute( $name );
$is_bool = true === $value;
if ( is_string( $value ) && in_array( $name, $uris, true ) ) {
$value = wp_kses_bad_protocol( $value, $allowed_protocols );
if ( is_string( $value ) ) {
$value = wp_kses_sanitize_uris( $name, $value, $allowed_protocols, $multi_uri_attrs, $uri_attrs );
}

// Reconstruct and normalize the attribute value.
Expand All @@ -1748,6 +1798,146 @@ function wp_kses_hair( $attr, $allowed_protocols ) {
return $attributes;
}

/**
* Sanitizes URI values in HTML attributes.
*
* This function centralizes logic for cleaning attribute values that are expected to contain URLs.
* It checks if the attribute name is one that should contain a URI (e.g., 'href', 'src', 'srcset').
* For attributes that can contain multiple URIs (such as 'srcset'), it splits the value and sanitizes each URI individually.
* All URI values are passed through {@see wp_kses_bad_protocol()} to remove disallowed protocols (e.g., 'javascript:').
*
* @since 7.1.0
*
* @param string $attr_name The attribute name to test.
* @param string $attr_value The attribute value to sanitize.
* @param string[] $allowed_protocols Array of allowed URL protocols.
* @param string[]|null $multi_uri_attrs Optional. Attributes that can contain multiple URIs.
* Default null, meaning the {@see wp_kses_multi_uri_attributes()} list.
* @param string[]|null $uri_attrs Optional. Attributes that contain a single URI.
* Default null, meaning the {@see wp_kses_uri_attributes()} list.
* @return string Sanitized attribute value.
*/
function wp_kses_sanitize_uris( $attr_name, $attr_value, $allowed_protocols, $multi_uri_attrs = null, $uri_attrs = null ) {
$attr_name = strtolower( $attr_name );
$allowed_protocols = (array) $allowed_protocols;

if ( null === $multi_uri_attrs ) {
$multi_uri_attrs = wp_kses_multi_uri_attributes();
}

if ( in_array( $attr_name, $multi_uri_attrs, true ) ) {
/*
* Split the value into image candidates the same way a browser does,
* following the srcset parsing algorithm in the HTML specification:
* a candidate is a run of non-whitespace characters forming a URL,
* optionally followed by whitespace and descriptors. Commas terminate
* a URL only when they end its run of non-whitespace characters; a
* comma elsewhere in the run belongs to the URL (CDN image resizers
* produce such URLs, e.g. cdn-cgi/image/format=auto,quality=80/...).
* Descriptors end at the first comma outside parentheses.
*
* Only the URLs are sanitized. Whitespace, separating commas, and
* descriptors are preserved byte for byte, so a value containing only
* allowed URLs round-trips unchanged.
*
* @see https://html.spec.whatwg.org/multipage/images.html#parsing-a-srcset-attribute
*/
$whitespace = " \t\f\r\n";
$length = strlen( $attr_value );
$at = 0;
$result = '';

while ( $at < $length ) {
// Copy the whitespace and commas separating candidates.
$separator_length = strspn( $attr_value, "{$whitespace},", $at );
$result .= substr( $attr_value, $at, $separator_length );
$at += $separator_length;

if ( $at >= $length ) {
break;
}

// The URL is the next run of non-whitespace characters…
$url_length = strcspn( $attr_value, $whitespace, $at );
$url = substr( $attr_value, $at, $url_length );
$at += $url_length;

// …except that commas ending the run terminate the URL and separate candidates.
$trimmed_url = rtrim( $url, ',' );
$trailing_commas = strlen( $url ) - strlen( $trimmed_url );
$url = $trimmed_url;

/*
* Sanitize the URL's protocol only when the text before its first colon
* could be parsed as a URL scheme: an ASCII letter followed by ASCII
* letters, digits, "+", "-", and "." per RFC 3986. Browsers treat any
* other prefix as part of a schemeless, relative URL, so there is no
* protocol to check, and wp_kses_bad_protocol() would corrupt the URL
* by stripping the text through the colon: it rewrites the relative,
* same-origin URL `a.jpg,https://example.com/b.jpg` into the
* cross-origin URL `//example.com/b.jpg`.
*
* The colon detection and the scheme normalization deliberately mirror
* wp_kses_bad_protocol_once() and wp_kses_bad_protocol_once2() so no
* colon form that wp_kses_bad_protocol() would act on is missed. The
* normalization removes a superset of the characters browsers remove
* from URLs (tab, line feed, and carriage return), so any prefix
* rejected here is also rejected as a scheme by browsers.
*/
$prefix = preg_replace( '/(&#0*58(?![;0-9])|&#x0*3a(?![;a-f0-9]))/i', '$1;', $url );
$prefix = preg_split( '/:|&#0*58;|&#x0*3a;|&colon;/i', $prefix, 2 );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are dangers in writing these decodes ad-hoc because of complicated rules for unexpected formatting. coming from wp_kses_hair(), these should actually be decoded already except for the big five syntax character <&>'"

however, we have WP_HTML_Decoder::decode_attribute() for this, as well as WP_HTML_Decoder::attribute_starts_with()


$scheme = null;
if ( isset( $prefix[1] ) ) {
$scheme = wp_kses_decode_entities( $prefix[0] );
$scheme = preg_replace( '/\s/', '', $scheme );
$scheme = wp_kses_no_null( $scheme );
}

if ( null === $scheme || '' === $scheme || preg_match( '/^[a-z][a-z0-9+.\-]*$/i', $scheme ) ) {
$url = wp_kses_bad_protocol( $url, $allowed_protocols );
}

$result .= $url . str_repeat( ',', $trailing_commas );

// A URL terminated by a comma has no descriptors.
if ( $trailing_commas > 0 ) {
continue;
}

// Copy any descriptors verbatim: everything up to the first comma outside parentheses.
$descriptor_start = $at;
$in_parens = false;
while ( $at < $length ) {
$char = $attr_value[ $at ];
if ( $in_parens ) {
if ( ')' === $char ) {
$in_parens = false;
}
} elseif ( '(' === $char ) {
$in_parens = true;
} elseif ( ',' === $char ) {
break;
}
++$at;
}
$result .= substr( $attr_value, $descriptor_start, $at - $descriptor_start );
}

return $result;
}

if ( null === $uri_attrs ) {
$uri_attrs = wp_kses_uri_attributes();
}

if ( in_array( $attr_name, $uri_attrs, true ) ) {
return wp_kses_bad_protocol( $attr_value, $allowed_protocols );
}

return $attr_value;
}

/**
* Finds all attributes of an HTML element.
*
Expand Down
Loading
Loading