Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ disk, and writes its contents into the page as inline SVG. The file is not
sanitised and is not put through the Icons API allowlist, because keeping
strokes, groups and gradients is the whole point of the upload escape hatch.

The file's contents are held in the object cache, keyed on the attachment ID
and its modified time. Sanitising a file that is already uploaded only reaches
the page once the attachment is updated or the cache is flushed. The mime type
is checked before the cache is read, so a deleted attachment stops rendering
straight away.

Inline SVG is part of the document. A `<script>` or an `onload` inside the file
runs, where the same file referenced through `<img src="...">` would not. So the
plugin makes an unsanitised SVG in the media library more dangerous than it was
Expand Down
65 changes: 49 additions & 16 deletions inc/render.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

use const HM\Button_Icon\Attributes\BLOCK;

const CACHE_GROUP = 'hm-button-icon';

/**
* Set up hooks.
*/
Expand Down Expand Up @@ -257,13 +259,56 @@ function uploaded_icon_markup( $attachment_id, $size ): string {
return '';
}

$tags = new WP_HTML_Tag_Processor( uploaded_icon_source( $attachment_id ) );

if ( ! $tags->next_tag( 'svg' ) ) {
return '';
}

$tags->add_class( 'hm-button-icon' );
$tags->set_attribute( 'width', (string) $size );
$tags->set_attribute( 'height', (string) $size );

// Match what `wp_get_icon()` produces for a decorative icon.
$tags->set_attribute( 'aria-hidden', 'true' );
$tags->set_attribute( 'focusable', 'false' );
$tags->remove_attribute( 'role' );
$tags->remove_attribute( 'aria-label' );

return $tags->get_updated_html();
}

/**
* An uploaded SVG's contents, from its root element onwards.
*
* Cached, because this runs for every button on every uncached page view, and
* where uploads live in remote storage such as S3 each read is a network
* request. Without a persistent object cache it still saves the repeat reads
* when one icon is on several buttons in a page.
*
* The key carries the attachment's modified time rather than an expiry, so
* updating the attachment is what retires an entry. A file swapped in place
* with nothing touching the attachment post keeps serving the old contents
* until the cache is flushed.
*
* @param int $attachment_id Attachment ID, already confirmed to be an SVG.
* @return string SVG markup, or '' when the file is unreadable or has no root.
*/
function uploaded_icon_source( int $attachment_id ): string {
$key = $attachment_id . ':' . get_post_modified_time( 'U', true, $attachment_id );
$svg = wp_cache_get( $key, CACHE_GROUP );

if ( is_string( $svg ) ) {
return $svg;
}

$path = get_attached_file( $attachment_id );

if ( ! $path || ! is_readable( $path ) ) {
return '';
}

// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a local uploaded file, not a remote request.
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading an uploaded file through its stream wrapper, not fetching a URL.
$svg = file_get_contents( $path );

if ( false === $svg ) {
Expand All @@ -277,21 +322,9 @@ function uploaded_icon_markup( $attachment_id, $size ): string {
return '';
}

$tags = new WP_HTML_Tag_Processor( substr( $svg, $start ) );
$svg = substr( $svg, $start );

if ( ! $tags->next_tag( 'svg' ) ) {
return '';
}
wp_cache_set( $key, $svg, CACHE_GROUP );

$tags->add_class( 'hm-button-icon' );
$tags->set_attribute( 'width', (string) $size );
$tags->set_attribute( 'height', (string) $size );

// Match what `wp_get_icon()` produces for a decorative icon.
$tags->set_attribute( 'aria-hidden', 'true' );
$tags->set_attribute( 'focusable', 'false' );
$tags->remove_attribute( 'role' );
$tags->remove_attribute( 'aria-label' );

return $tags->get_updated_html();
return $svg;
}
Loading