Skip to content

Icons Registry: improve SVG sanitizer - #75550

Open
t-hamano wants to merge 55 commits into
trunkfrom
icon-registry-sanitizer
Open

t-hamano wants to merge 55 commits into
trunkfrom
icon-registry-sanitizer

Conversation

@t-hamano

@t-hamano t-hamano commented Feb 15, 2026 •

Copy link
Copy Markdown
Contributor

What?

In the experimental icon registry, any string can be registered as SVG content. Unfortunately, there is still no core function to properly sanitize SVGs, so we've enhanced the private methods for sanitization and defined as many SVG-friendly tags and attributes as possible.

Testing Instructions

Please refer to the unit test to see what the function expects.

@t-hamano t-hamano self-assigned this Feb 15, 2026
@github-project-automation github-project-automation Bot moved this to 🔎 Needs Review in WordPress 7.0 Editor Tasks Feb 15, 2026
@t-hamano t-hamano added [Type] Experimental Experimental feature or API. [Block] Icon Affects the Icon block labels Feb 15, 2026
@t-hamano
t-hamano force-pushed the icon-registry-sanitizer branch from e537f45 to 7bdd4c1 Compare February 15, 2026 08:58
Comment thread lib/experimental/class-wp-icons-registry.php Outdated
Comment on lines +66 to +67
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="icon" aria-hidden="true"><path d="M0 0" fill="currentColor" /></svg>',
'<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" width="24" height="24" class="icon" aria-hidden="true"><path d="M0 0" fill="currentColor" /></svg>',

@t-hamano t-hamano Feb 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I was surprised to see wp_kses() convert camel case attribute names (viewBox) to lower case (viewbox). Lower-case attribute names should be recognized correctly by most browsers, but I'm not sure if this should be allowed.

cc @dmsnell @sirreal

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

As far as I've tested, lowercase viewbox is automatically recognized as viewBox by major browsers, so as long as we're using inline SVG as HTML content, we shouldn't have any problems.

<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100">
  <rect x="0" y="0" width="100%" height="100%" />
  <circle cx="50%" cy="50%" r="4" fill="white" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 100 100" width="100">
  <rect x="0" y="0" width="100%" height="100%" />
  <circle cx="50%" cy="50%" r="4" fill="white" />
</svg>

</body>
</html>
viewbox

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.

yes, when the SVG is embedded within HTML, those attributes are identical across case variations. the HTML parser will convert a subset of attributes to their normalized camelCase form, but when parsing they are identical.

one of the security concerns with SVG revolves around subtleties like this, because embedded and external SVG parse differently, which can be a problem when plugins inline external files.

@t-hamano t-hamano changed the title Icons Regstry: improveme SVG sanitizer Icons Regstry: improve SVG sanitizer Feb 15, 2026
@t-hamano
t-hamano marked this pull request as ready for review February 15, 2026 09:09
@mcsf mcsf changed the title Icons Regstry: improve SVG sanitizer Icons Registry: improve SVG sanitizer Feb 17, 2026
@t-hamano t-hamano mentioned this pull request Feb 19, 2026
8 of 17 tasks
@dmsnell

dmsnell commented Feb 20, 2026

Copy link
Copy Markdown
Member

@t-hamano this seems like a good improvement since it retains the structure of the existing sanitization while adding extra attributes. did you verify that none of the newly-allowed attributes allows for script execution?

one note here is that SVG is complicated to parse, and even the search for <svg\b seems a bit suspicious. the HTML Processor can parse all valid inline SVGs, and one thing that might be useful here is to prepare the input using that and then dump the output into kses after normalization, where things that seem sneaky might be less prone to issues in kses.

$svg = '';
$processor = WP_HTML_Processor::create_fragment( $icon_content );
if ( ! $processor->next_token() || 'SVG' !== $processor->get_tag() ) {
	return '';
}

$svg .= $processor->serialize_token();
$svg_depth = $processor->get_current_depth();
while ( $processor->next_token() && $processor->get_current_depth() > $svg_depth ) {
	$svg .= $processor->serialize_token();
}

// Parsing has stopped even though there might be additional content.
$filtered = wp_kses( ... )

This could also be done after filtering with wp_kses() as it’s likely to corrupt the SVG, but normalizing first should help avoid a lot of problems. For instance, wp_kses() will be unaware of this but a single <p> terminates the SVG element. In fact, many tags that appear within inline SVG will end it and jump back into HTML. If we first extract the SVG in its entirety with the HTML Processor then we wouldn’t have to consider this.

Unfortunately there could be issues with self-closing tags, which exist within the SVG content but otherwise not in the HTML. In HTML, <svg/> is an entire SVG element, even though HTML contains no self-closing tags (the <svg/> tag is actually an SVG tag so the self-closing rule applies).

sticking with wp_kses() is appropriate apart from all of this. I have proposed dmsnell/wordpress-develop#20 and several other changes that will feed up into wp_kses() (WordPress/wordpress-develop#7409, WordPress/wordpress-develop#9270, and WordPress/wordpress-develop#9851 for example), so ultimately that should become more reliable.

for gradual improvement I think this is good as long as we have a thorough review of the attributes and tags we’re allowing.

* @return string The sanitized icon SVG content.
*/
private function sanitize_icon_content( $icon_content ) {
// Core attributes applicable to most elements.

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.

This appears to be using the elements and attributes from my earlier comment, can you confirm?

Would be nice to validate those, as I haven't gone in-depth in that, and there are plenty of elements and attributes that SVGs can support. Also we know that any KSES-related changes shouldn't be taken lightly 😉

@t-hamano

Copy link
Copy Markdown
Contributor Author

Sorry for the late reply. I'm working with @mcsf to build the infrastructure to allow the Icon API to be extended on the Gutenberg plugin. If that works out, I'll come back to this PR.

@t-hamano t-hamano added [Type] Enhancement A suggestion for improvement. and removed [Type] Experimental Experimental feature or API. labels Feb 25, 2026
@t-hamano t-hamano added [Feature] Icons Related to Icon registration API and Icon REST API and removed [Block] Icon Affects the Icon block labels Mar 3, 2026
@t-hamano t-hamano moved this from 🔎 Needs Review to 🦵 Punted to 7.1 in WordPress 7.0 Editor Tasks Mar 3, 2026
@t-hamano
t-hamano force-pushed the icon-registry-sanitizer branch from 7bdd4c1 to 26a51db Compare March 12, 2026 08:51
The HTML parser honors XML self-closing syntax in foreign content, so
`<svg />` has no descendants and emits no closing tag. The descendant
scan stopped at a depth lower than the root, which for such a root is
only reached after consuming the following sibling: `<svg /><svg>…</svg>`
was returned as both roots concatenated plus an unbalanced `</svg>`, and
the multiple-root check that follows never saw the sibling because the
cursor had already passed it.

Skip the scan and the closing tag unless the root `expects_closer()`.
Leaving the cursor on the root is what lets the multiple-root check find
the sibling — narrowing the depth comparison instead would stop the loop
on the sibling token, which `next_tag()` would then skip past.

Co-Authored-By: Claude <noreply@anthropic.com>
@t-hamano

t-hamano commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Punting this to 7.2 as the 7.1 RC1 release is approaching.

@t-hamano t-hamano moved this from 🔎 Needs Review to 🦵 Punted to 7.2 in WordPress 7.1 Editor Tasks Aug 3, 2026
@t-hamano

Copy link
Copy Markdown
Contributor Author

Update: Let's consider addressing this in the 7.1 minor release if possible.

@t-hamano t-hamano moved this from 🦵 Punted to 7.2 to 🐛 Punted to 7.1.1 in WordPress 7.1 Editor Tasks Aug 12, 2026
t-hamano and others added 2 commits September 17, 2026 17:35
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

🎉 Props

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: t-hamano <wildworks@git.wordpress.org>
Co-authored-by: tyxla <tyxla@git.wordpress.org>
Co-authored-by: sirreal <jonsurrell@git.wordpress.org>
Co-authored-by: westonruter <westonruter@git.wordpress.org>
Co-authored-by: dmsnell <dmsnell@git.wordpress.org>
Co-authored-by: jasmussen <joen@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Updated as activity occurs, without notifying anyone named here. Add the props-bot label to refresh.

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric 6e01dae trunk % Change
timeToFirstByte 47.6 ms +7.56% -2.73% 48.1 ms +10.19% -3.33% -1.04%
largestContentfulPaint 80 ms +10% -0% 82 ms +7.32% -2.44% -2.44%
lcpMinusTtfb 33.05 ms +10.74% -5.9% 32.35 ms +8.66% -4.95% 2.16%
wpBeforeTemplate 22.55 ms +8.82% -1.73% 22.57 ms +15.64% -1.24% -0.09%
wpTemplate 21.17 ms +2.5% -2.55% 20.88 ms +3.4% -1.72% 1.39%
wpTotal 43.94 ms +7.97% -2.41% 44.49 ms +9.76% -3.48% -1.24%
wpMemoryUsage 7.63 MB +0% -0% 7.59 MB +0% -0% 0.46%
wpDbQueries 17 +0% -0% 17 +0% -0% 0%

front-end-classic-theme

Metric 6e01dae trunk % Change
timeToFirstByte 38.8 ms +3.22% -1.29% 35.5 ms +2.68% -2.96% 9.3%
largestContentfulPaint 88 ms +4.55% -0% 80 ms +0% -0% 10%
lcpMinusTtfb 49.6 ms +1.81% -0.91% 44.3 ms +2.14% -3.5% 11.96%
wpBeforeTemplate 20.33 ms +1.92% -0.79% 20.23 ms +2.87% -0.89% 0.49%
wpTemplate 15.69 ms +2.8% -2.17% 11.95 ms +5.36% -0.42% 31.3%
wpTotal 36.16 ms +3.24% -1.69% 32.54 ms +3.87% -1.84% 11.12%
wpMemoryUsage 6.25 MB +0% -0% 6.07 MB +0% -0% 2.91%
wpDbQueries 14 +0% -0% 10 +0% -0% 40%

media-processing

Metric 6e01dae trunk % Change
mediaProcessingJpeg 408.25 ms +0.12% -0.78% 403.27 ms +1.45% -0.29% 1.23%
mediaProcessingAvif 6236.65 ms +0.15% -0.25% 6234.74 ms +0.12% -0.22% 0.03%
mediaProcessingJpegToAvif 4273.59 ms +0.15% -0.03% 4259.01 ms +0.45% -0.15% 0.34%

media-upload

Metric 6e01dae trunk % Change
jpegUploadProcessing 1420.94 ms +37.06% -1.39% 1416.3 ms +0.58% -1.2% 0.33%
pngUploadProcessing 200.5 ms +3.15% -1.97% 203.05 ms +3.99% -2.04% -1.26%
largeJpegUploadProcessing 1402.47 ms +0.87% -0.47% 1409.37 ms +0.55% -0.48% -0.49%
multipleImageUploadProcessing 1557.41 ms +0.26% -1.36% 1581.49 ms +7.71% -2.04% -1.52%

post-editor

Metric 6e01dae trunk % Change
serverResponse 504.07 ms +4.02% -5.45% 513.58 ms +5.01% -7.51% -1.85%
firstPaint 220.84 ms +12.38% -11.31% 186.11 ms +29.3% -3.86% 18.66%
domContentLoaded 1110.35 ms +2.44% -1.55% 1113.85 ms +2.79% -2.41% -0.31%
loaded 1111.74 ms +2.43% -1.55% 1115.11 ms +2.8% -2.43% -0.3%
firstContentfulPaint 460.17 ms +3.17% -9.06% 454.56 ms +6.47% -2.26% 1.23%
firstBlock 3319.15 ms +1.12% -0.98% 3314.65 ms +1.88% -0.64% 0.14%
type 17.71 ms +8.07% -2.77% 18.63 ms +5.26% -2.2% -4.94%
typeWithoutInspector 17.26 ms +8.52% -4.98% 18.09 ms +13.99% -2.38% -4.59%
typeWithTopToolbar 25.52 ms +7.01% -7.76% 26.12 ms +2.34% -5.63% -2.3%
typeContainer 9.36 ms +19.44% -8.76% 8.21 ms +15.59% -6.7% 14.01%
focus 75.18 ms +11.27% -4.1% 76.24 ms +14.86% -3.16% -1.39%
firstFocus 209.28 ms +0% -0% 201.54 ms +0% -0% 3.84%
selectAll 548.74 ms +2.95% -0.17% 537.99 ms +4.47% -2.02% 2%
listViewOpen 62.91 ms +11.02% -7.25% 66.47 ms +6.94% -1.79% -5.36%
inserterOpen 22.62 ms +15.69% -13% 22.86 ms +14.09% -4.72% -1.05%
inserterHover 2.44 ms +6.56% -18.44% 2.38 ms +5.46% -12.61% 2.52%
inserterSearch 8.04 ms +5.47% -13.68% 8.04 ms +1.99% -3.48% 0%
loadPatterns 641.24 ms +11.07% -2.65% 648.05 ms +4.42% -3.97% -1.05%
wpTotal 494.61 ms +3.87% -5.72% 503.14 ms +5.2% -7.6% -1.7%
wpMemoryUsage 13.18 MB +0% -0% 13.14 MB +0% -0% 0.28%
wpDbQueries 54 +0% -0% 54 +0% -1.85% 0%

site-editor

Metric 6e01dae trunk % Change
serverResponse 297.67 ms +3.47% -4.52% 311.35 ms +2.78% -8.86% -4.39%
firstPaint 186.56 ms +16.49% -11.82% 192.87 ms +7.13% -4.36% -3.27%
domContentLoaded 831.7 ms +1.29% -2.24% 833.28 ms +2.19% -1.58% -0.19%
loaded 832.77 ms +1.3% -2.23% 834.38 ms +2.19% -1.56% -0.19%
firstContentfulPaint 351.57 ms +3.66% -4.1% 354.23 ms +2.73% -3.16% -0.75%
firstBlock 2883.22 ms +1.62% -1.11% 2894.42 ms +2.53% -1.51% -0.39%
type 15.78 ms +1.9% -5.89% 15.36 ms +2.54% -2.67% 2.73%
navigate 100.91 ms +6.58% -19.6% 101.73 ms +10.88% -8.61% -0.81%
loadPatterns 1017.2 ms +3.51% -12.78% 1081.73 ms +6.89% -9.58% -5.97%
loadPages 1031.78 ms +5.03% -2.62% 1074.94 ms +2% -2.91% -4.02%
wpTotal 290 ms +3.55% -4.73% 303.76 ms +2.74% -9.26% -4.53%
wpMemoryUsage 12.14 MB +0% -0% 12.10 MB +0% -0% 0.3%
wpDbQueries 43.5 +1.15% -1.15% 44 +0% -2.27% -1.14%

fdc9db8 Run

Comment on lines +179 to +180
* @param non-empty-string ...$attribute_names Attribute names to allow.
* @return array<non-empty-string, true> Attribute names mapped to true.

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.

Suggested change
* @param non-empty-string ...$attribute_names Attribute names to allow.
* @return array<non-empty-string, true> Attribute names mapped to true.
* @param non-falsy-string ...$attribute_names Attribute names to allow.
* @return array<non-falsy-string, true> Attribute names mapped to true.

@dmsnell dmsnell Sep 24, 2026 •

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.

is this saying that code calling this function should perform this chec Before calling it?

return array_any( $attribute_names, fn ( $n ) => is_string( $n ) && '' !== $n && '0' !== $n )
	? get_allowed_attribute_list( ...$attribute_names )
	: array();

that will be faster generally, but also exports some of the internal behavior from the function onto the callers, which can be valuable, but is often quite messy and error-prone.

maybe I’m misunderstanding the type annotations?

should the function crash if someone sends '' or '0' as any attribute name?

* @param string $html_containing_svg HTML fragment containing the SVG to sanitize.
* @return string The sanitized SVG, or an empty string when no valid SVG is found.
*/
private function sanitize_inline_svg( $html_containing_svg ) {

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.

Suggested change
private function sanitize_inline_svg( $html_containing_svg ) {
private function sanitize_inline_svg( string $html_containing_svg ): string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c4073a2

// An external `.svg` file is XML, but sanitize_inline_svg() expects an
// inline HTML fragment. A dedicated XML sanitizer should handle this
// in the future.
$content = $this->sanitize_inline_svg( file_get_contents( $icon_path ) );

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.

To guard against failures in loading the file:

Suggested change
$content = $this->sanitize_inline_svg( file_get_contents( $icon_path ) );
$content = file_get_contents( $icon_path );
if ( is_string( $content ) ) {
$content = $this->sanitize_inline_svg( $content );
} else {
$content = false;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5c05c02

Comment on lines +465 to +467
* @return array<non-empty-string, array{ input: string, expected: string }>
*/
public function data_sanitize_inline_svg() {

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.

Suggested change
* @return array<non-empty-string, array{ input: string, expected: string }>
*/
public function data_sanitize_inline_svg() {
* @return array<non-falsy-string, array{ input: non-falsy-string, expected: non-falsy-string }>
*/
public function data_sanitize_inline_svg(): array {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in f278e66. I kept string rather than non-falsy-string for input and expected, because some data sets use an empty string: 'returns empty for empty string' has '' as its input, and every rejection case (e.g. 'rejects multiple top-level svg elements') has '' as its expected output.

Comment on lines +454 to +457
* @param string $input The icon content to sanitize.
* @param string $expected The expected sanitized output.
*/
public function test_sanitize_inline_svg( $input, $expected ) {

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.

Suggested change
* @param string $input The icon content to sanitize.
* @param string $expected The expected sanitized output.
*/
public function test_sanitize_inline_svg( $input, $expected ) {
* @param non-falsy-string $input The icon content to sanitize.
* @param non-falsy-string $expected The expected sanitized output.
*/
public function test_sanitize_inline_svg( string $input, string $expected ) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in fdc9db8. For the same reason as in the data provider, I kept string in the @param tags: both $input and $expected can be an empty string.

t-hamano and others added 5 commits October 3, 2026 23:34
# Conflicts:
#	phpunit/class-wp-icons-registry-gutenberg-test.php
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…provider

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Icons Related to Icon registration API and Icon REST API [Type] Bug An existing feature does not function as intended

Projects

Status: 🔎 Needs Review

Development

Successfully merging this pull request may close these issues.

7 participants