Developer guidesExtend the sanitizer safely

Extend the sanitizer safely

Widen or tighten Easy SVG's allow-lists without opening a hole: how the sanitizer is built, what never to allow, the legacy global and how to test.

Tutorial · Easy SVG

Easy SVG cleans every SVG before WordPress stores it. Most sites never need to touch that. Sometimes a design tool exports an element the cleaner does not know, or a site wants a stricter policy, and then three filters let you change the rules. This tutorial shows how the cleaner is put together, which changes are safe, which ones are not, and how to prove your change does what you think before it reaches production.

How the sanitizer is built

Easy SVG uses the enshrined/svg-sanitize library (version 1.0.0 is bundled). It works with two allow-lists: element names and attribute names. Everything not on a list is removed. On top of that it always removes PHP processing instructions, unsafe href values such as javascript:, and comments, CDATA becomes plain text.

Every time Easy SVG needs to clean something, it calls easy_svg_sanitizer(), which builds a new sanitizer and applies your filters right then:

That has three consequences:

  1. Your filters run on every clean, not once. Register them early (a plugin or must-use plugin file is fine) and they apply to every upload in the same request and every request after.
  2. Every path sees the same rules. Media uploads, sideloads (media_sideload_image(), WP-CLI, importers), the wp_upload_bits() check used by XML-RPC, the check on every new attachment, the icon library and add-ons all call this one function.
  3. Some paths add their own fixed steps. Uploads first refuse files above the size and complexity caps, and remove animations that target href if your list allows animation elements. Icons additionally lose style, script, every animation element, every on* attribute and every href that does not start with #. Your filters cannot switch these off.

When the filters run, relative to the upload:

The upload filters exist only while Allow SVG uploads in the media library is on in Icons, Settings. The new-attachment check is always on, so an SVG that another plugin lets in is cleaned too.

Safe allow-list changes

A safe change adds a name that only describes how something looks, or removes a name you never want. Work from evidence, not guesses.

Find out what is removed

Run the file through the sanitizer and ask what it took out. getXmlIssues() lists every removed element and attribute with its line number.

wp eval '
$s = easy_svg_sanitizer();
$s->sanitize( file_get_contents( "/path/to/export.svg" ) );
foreach ( $s->getXmlIssues() as $i ) { WP_CLI::log( $i["message"] . " (line " . $i["line"] . ")" ); }
'

A typical answer: Suspicious tag 'feDropShadow' or Suspicious attribute 'mask-type'.

Decide whether the name is presentational

Filter primitives (fe*), gradients, masks and presentation attributes (mask-type, transform-origin, focusable) only change pixels. Elements that run code, embed other documents or change other attributes over time do not belong on the list. See the table below.

Add the narrowest change in a must-use plugin

<?php
/**
 * Plugin Name: My SVG allow-list
 */

/**
 * Keep drop shadows from our design tool.
 *
 * @param string[] $tags Allowed element names.
 * @return string[]
 */
function mysite_svg_tags( $tags ) {
	$tags[] = 'feDropShadow';
	return $tags;
}
add_filter( 'esw_svg_allowed_tags', 'mysite_svg_tags' );

/**
 * Keep the mask type our exports rely on.
 *
 * @param string[] $attributes Allowed attribute names.
 * @return string[]
 */
function mysite_svg_attributes( $attributes ) {
	$attributes[] = 'mask-type';
	return $attributes;
}
add_filter( 'esw_svg_allowed_attributes', 'mysite_svg_attributes' );

Save it as wp-content/mu-plugins/mysite-svg-allow-list.php. Must-use plugins load on every request and cannot be deactivated by accident.

Run the checks below

Test that your file now keeps what it needs, and that the known bad cases are still removed.

When you remove names instead of adding them, compare in lower case. The default list mixes spellings (feBlend, lineargradient), and array_diff() is case-sensitive:

/**
 * Never store embedded bitmaps.
 *
 * @param string[] $tags Allowed element names.
 * @return string[]
 */
function mysite_no_bitmaps_in_svg( $tags ) {
	return array_values(
		array_filter(
			$tags,
			static function ( $tag ) {
				return 'image' !== strtolower( $tag );
			}
		)
	);
}
add_filter( 'esw_svg_allowed_tags', 'mysite_no_bitmaps_in_svg' );

Removing an element removes everything inside it. Taking out g or a can empty a whole drawing.

What never to allow, and why

Never addWhy
scriptRuns JavaScript wherever the SVG is opened directly, including from the media library.
on* attributes (onload, onclick, ...)Event handlers are JavaScript too. The attribute list does not check values, so any handler you allow runs as written.
foreignObjectEmbeds HTML inside the drawing. The SVG allow-lists were never meant to judge HTML content.
animate, setThey change other attributes over time. attributeName="href" with values="javascript:..." sets a link the sanitizer would have removed. Uploads remove that one pattern after the fact, easy_svg_sanitizer() alone does not.
iframe, object, embedLoad other documents into the SVG.
New URL attributesOnly attribute names containing href get a value check. A new attribute that takes a URL keeps javascript: as written.

Already on the default list and fine to leave there: style (CSS, with remote references stripped), use, image, a (their href values are checked), animateTransform, animateMotion, animateColor.

Your administrators open uploaded SVGs in the media library. A list that lets script through turns any account that can upload media into a way to run code in an administrator's browser. Nothing in Easy SVG's settings can switch sanitizing off; these filters are the only way, which is why each addition deserves a reason you can write down.

Remote references

esw_svg_remove_remote_references is true by default: every absolute URL (https://..., //host/...) is removed from attributes, and remote url() and every @import are cut out of style elements. Your own domain counts as remote, because the check looks at the URL form, not the host. Keep it on unless you trust everyone who can upload. If you need remote references for one import only, add __return_false before the import and remove it afterwards; the value is read every time a sanitizer is built.

The legacy global $sanitizer

Easy SVG 4.1 cleaned uploads with one sanitizer stored in the global variable $sanitizer, and some sites configured it from a snippet:

global $sanitizer;
$sanitizer->removeXMLTag( true );

Easy SVG still creates that global when the plugin loads, as long as the library is present and nothing else uses the name. It is now a template: easy_svg_sanitizer() clones it, so settings made on it carry over to every copy. What that means in practice:

  • Still works: output settings such as removeXMLTag( true ) and minify( true ). The test suite proves a removeXMLTag() on the global reaches the stored upload.
  • No effect: setAllowedTags(), setAllowedAttrs() and removeRemoteReferences(). The copy gets the filtered lists and the filtered remote setting after cloning, so they always win. Move those settings to the three filters.
  • Never reconfigured by use: Easy SVG never changes the global itself, and it never hands the global out.
  • Not called when foreign: if another plugin put a different object into $sanitizer, Easy SVG ignores it and builds a fresh sanitizer.

New code should not touch the global at all. Use the filters for the rules and easy_svg_sanitizer() for a sanitizer of your own.

Test your changes

Run known good and known bad files through the sanitizer

Save this as svg-sanitizer-check.php and run wp eval-file svg-sanitizer-check.php on a staging copy of the site. It uses the site's real filters, because it asks easy_svg_sanitizer().

<?php
/**
 * Feed known good and known bad SVGs through this site's sanitizer.
 *
 * Usage: wp eval-file svg-sanitizer-check.php
 */

if ( ! function_exists( 'easy_svg_sanitizer' ) || null === easy_svg_sanitizer() ) {
	WP_CLI::error( 'Easy SVG is not active or its sanitizer library is missing.' );
}

$ns = 'xmlns="http://www.w3.org/2000/svg"';

// Label => array( input, strings that must be gone, strings that must stay ).
$cases = array(
	'script element'    => array( "<svg $ns><script>alert(1)</script><rect width="9" height="9"/></svg>", array( '<script', 'alert' ), array( '<rect' ) ),
	'event handler'     => array( "<svg $ns><rect onload="alert(1)" width="9" height="9"/></svg>", array( 'onload' ), array( '<rect' ) ),
	'javascript link'   => array( "<svg $ns><a href="javascript:alert(1)"><rect width="9" height="9"/></a></svg>", array( 'javascript:' ), array( '<rect' ) ),
	'href animation'    => array( "<svg $ns><a href="#x"><rect width="9" height="9"/></a><animate attributeName="href" values="javascript:alert(1)"/></svg>", array( 'javascript:' ), array( '<rect' ) ),
	'foreign object'    => array( "<svg $ns><foreignObject><div xmlns="http://www.w3.org/1999/xhtml">x</div></foreignObject><rect width="9" height="9"/></svg>", array( 'foreignObject', '<div' ), array( '<rect' ) ),
	'remote image'      => array( "<svg $ns><image href="https://tracker.example/p.png" width="1" height="1"/><rect width="9" height="9"/></svg>", array( 'tracker.example' ), array( '<rect' ) ),
	'benign gradient'   => array( "<svg $ns viewBox="0 0 24 24"><defs><linearGradient id="g"><stop offset="0" stop-color="#f60"/></linearGradient></defs><path d="M0 0h24v24H0z" fill="url(#g)"/></svg>", array(), array( '<linearGradient', 'url(#g)', 'viewBox' ) ),
	'benign accessible' => array( "<svg $ns role="img" aria-labelledby="t"><title id="t">Logo</title><circle cx="5" cy="5" r="4"/></svg>", array(), array( 'aria-labelledby', '<title', '<circle' ) ),
);

$failed = 0;

foreach ( $cases as $label => $case ) {
	list( $input, $must_go, $must_stay ) = $case;

	try {
		$output = easy_svg_sanitizer()->sanitize( $input );
	} catch ( \Throwable $e ) {
		$output = false;
	}

	$problems = array();
	if ( ! is_string( $output ) || '' === trim( $output ) ) {
		$problems[] = 'refused';
	} else {
		foreach ( $must_go as $needle ) {
			if ( false !== stripos( $output, $needle ) ) {
				$problems[] = 'kept ' . $needle;
			}
		}
		foreach ( $must_stay as $needle ) {
			if ( false === strpos( $output, $needle ) ) {
				$problems[] = 'lost ' . $needle;
			}
		}
	}

	if ( array() === $problems ) {
		WP_CLI::log( 'PASS  ' . $label );
	} else {
		++$failed;
		WP_CLI::warning( 'FAIL  ' . $label . ': ' . implode( ', ', $problems ) );
	}
}

// Broken markup must be refused, never returned as it came.
try {
	$broken = easy_svg_sanitizer()->sanitize( '<svg><rect></svg' );
} catch ( \Throwable $e ) {
	$broken = false;
}
if ( false === $broken ) {
	WP_CLI::log( 'PASS  broken markup is refused' );
} else {
	++$failed;
	WP_CLI::warning( 'FAIL  broken markup came back' );
}

if ( $failed > 0 ) {
	WP_CLI::error( $failed . ' check(s) failed.' );
}
WP_CLI::success( 'The sanitizer behaves as expected.' );

Add a case for every name you allowed (it must stay) and keep the bad cases unchanged. If the site has added animate to its list, the href animation case fails here: that pattern is only removed on the upload path, which is the next step.

Prove the test can fail

Temporarily add the forbidden change, run the script again, and watch it fail. For example:

wp eval-file svg-sanitizer-check.php --require=<(echo '<?php add_filter( "esw_svg_allowed_attributes", function ( $a ) { $a[] = "onload"; return $a; } );')

The event handler case must now report FAIL ... kept onload. A check that cannot fail proves nothing. If your shell does not support <( ), put the line in a small file and pass its path to --require.

Check the real upload path

With Allow SVG uploads in the media library switched on, import a bad file and a broken file through WP-CLI, which uses the same sideload path importers use:

printf '%s' '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect width="9" height="9"/></svg>' > /tmp/evil.svg
printf '%s' '<svg><rect></svg' > /tmp/broken.svg

ID=$(wp media import /tmp/evil.svg --porcelain)
grep -c '<script' "$(wp eval "echo get_attached_file( $ID );")"

wp media import /tmp/broken.svg

The first import succeeds and grep prints 0: the stored file is the cleaned one. The second import is refused with the reason "Sorry, please check your SVG file." Delete the test attachment afterwards with wp post delete $ID --force.

Check the editor and the library by eye

Upload one of your real exports in Media, Add New Media File, open it in the media library and place it in a post. If something looks different from the export, go back to step one of the previous section and look at getXmlIssues().

Checklist

  • Every name you added only changes how the drawing looks.
  • You compare in lower case when removing names, and your callbacks always return an array.
  • The check script passes, and fails when you add onload on purpose.
  • wp media import stores a cleaned file and refuses a broken one.
  • Files already in the media library were cleaned with the old rules. Re-upload the ones that matter if your change affects them.