Skip to content

Commit b2413bc

Browse files
committed
Harden emoji KSES to only allow local emoji images
- Add Emoji::get_kses_allowed_html() with strict validation: - Requires class="emoji" attribute - Validates src URL points to local emoji uploads directory - Requires standard emoji dimensions (20x20) - Update Interactions::allowed_comment_html() to use strict KSES - Update Comment::unescape_emoji() to use strict KSES - Only replace emoji shortcodes when local import succeeds - Add activitypub_pre_import_emoji filter for test mocking - Non-emoji img tags are now stripped from incoming content
1 parent 1817ec5 commit b2413bc

File tree

6 files changed

+127
-19
lines changed

6 files changed

+127
-19
lines changed

includes/class-attachments.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,21 @@ public static function import_emoji( $emoji_url, $updated = null ) {
183183
return false;
184184
}
185185

186+
/**
187+
* Filters the result of emoji import before processing.
188+
*
189+
* Allows short-circuiting the emoji import, useful for testing.
190+
*
191+
* @param string|false|null $result The import result. Return a URL string to short-circuit,
192+
* false to indicate failure, or null to proceed normally.
193+
* @param string $emoji_url The remote emoji URL being imported.
194+
* @param string|null $updated The remote emoji's updated timestamp.
195+
*/
196+
$pre_import = \apply_filters( 'activitypub_pre_import_emoji', null, $emoji_url, $updated );
197+
if ( null !== $pre_import ) {
198+
return $pre_import;
199+
}
200+
186201
// Check if already cached.
187202
$cached_url = self::get_emoji_url( $emoji_url );
188203
if ( $cached_url ) {

includes/class-comment.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -881,11 +881,15 @@ public static function render_emoji( $author, $comment_id ) {
881881
* @return string The comment author name with emoji images unescaped.
882882
*/
883883
public static function unescape_emoji( $author ) {
884-
// Only unescape if there are emoji images present.
885-
if ( false !== strpos( $author, 'class="emoji"' ) ) {
886-
$author = \html_entity_decode( $author, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
884+
// Only attempt to unescape if there are emoji images present in the escaped string.
885+
if ( false === \strpos( $author, 'class="emoji"' ) ) {
886+
return $author;
887887
}
888888

889-
return $author;
889+
// Decode entities so we can selectively restore emoji <img> tags.
890+
$decoded = \html_entity_decode( $author, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
891+
892+
// Use strict KSES validation to only allow valid emoji img tags.
893+
return \wp_kses( $decoded, Emoji::get_kses_allowed_html() );
890894
}
891895
}

includes/class-emoji.php

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,61 @@
1212
*/
1313
class Emoji {
1414

15+
/**
16+
* Get the allowed HTML structure for emoji img tags.
17+
*
18+
* Uses WordPress KSES features (WP 5.9+) to strictly validate emoji images:
19+
* - Requires class="emoji"
20+
* - Validates src URL points to local emoji directory
21+
* - Requires standard emoji dimensions
22+
*
23+
* @return array The allowed HTML structure for use with wp_kses.
24+
*/
25+
public static function get_kses_allowed_html() {
26+
return array(
27+
'img' => array(
28+
'class' => array(
29+
'required' => true,
30+
'values' => array( 'emoji' ),
31+
),
32+
'src' => array(
33+
'required' => true,
34+
'value_callback' => array( self::class, 'validate_emoji_src' ),
35+
),
36+
'alt' => array( 'required' => true ),
37+
'title' => array( 'required' => true ),
38+
'height' => array(
39+
'required' => true,
40+
'values' => array( '20' ),
41+
),
42+
'width' => array(
43+
'required' => true,
44+
'values' => array( '20' ),
45+
),
46+
'draggable' => array(
47+
'required' => true,
48+
'values' => array( 'false' ),
49+
),
50+
),
51+
);
52+
}
53+
54+
/**
55+
* Validate emoji src attribute for wp_kses.
56+
*
57+
* Only allows emoji URLs from local uploads directory.
58+
*
59+
* @param string $value The src attribute value.
60+
*
61+
* @return bool True if the src is valid, false otherwise.
62+
*/
63+
public static function validate_emoji_src( $value ) {
64+
$upload_dir = \wp_upload_dir();
65+
$emoji_base = $upload_dir['baseurl'] . Attachments::$emoji_dir;
66+
67+
return \str_starts_with( $value, $emoji_base );
68+
}
69+
1570
/**
1671
* Prepare comment data with emoji handling.
1772
*
@@ -94,8 +149,11 @@ public static function replace_custom_emoji( $text, $activity ) {
94149

95150
foreach ( $emoji_data as $emoji ) {
96151
$local_url = Attachments::import_emoji( $emoji['url'], $emoji['updated'] ?? null );
97-
$emoji_url = $local_url ? $local_url : $emoji['url'];
98-
$text = self::replace_emoji_in_text( $text, $emoji['name'], $emoji_url );
152+
153+
// Only replace if the emoji was successfully uploaded locally.
154+
if ( $local_url ) {
155+
$text = self::replace_emoji_in_text( $text, $emoji['name'], $local_url );
156+
}
99157
}
100158

101159
return $text;

includes/collection/class-interactions.php

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -313,17 +313,10 @@ public static function allowed_comment_html( $allowed_tags, $context = '' ) {
313313
$allowed_tags['p'] = array();
314314
}
315315

316-
// Add `img` for custom emoji support.
316+
// Add `img` for custom emoji support with strict validation.
317+
$emoji_html = Emoji::get_kses_allowed_html();
317318
if ( ! array_key_exists( 'img', $allowed_tags ) ) {
318-
$allowed_tags['img'] = array(
319-
'src' => true,
320-
'alt' => true,
321-
'title' => true,
322-
'class' => true,
323-
'width' => true,
324-
'height' => true,
325-
'draggable' => true,
326-
);
319+
$allowed_tags['img'] = $emoji_html['img'];
327320
}
328321

329322
return $allowed_tags;

tests/phpunit/tests/includes/class-test-emoji.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,44 @@
1616
*/
1717
class Test_Emoji extends \WP_UnitTestCase {
1818

19+
/**
20+
* Set up each test.
21+
*/
22+
public function set_up() {
23+
parent::set_up();
24+
25+
// Mock emoji imports to return a local URL.
26+
\add_filter( 'activitypub_pre_import_emoji', array( $this, 'mock_emoji_import' ), 10, 2 );
27+
}
28+
29+
/**
30+
* Tear down each test.
31+
*/
32+
public function tear_down() {
33+
\remove_filter( 'activitypub_pre_import_emoji', array( $this, 'mock_emoji_import' ), 10 );
34+
35+
parent::tear_down();
36+
}
37+
38+
/**
39+
* Mock emoji import to return a local URL.
40+
*
41+
* @param string|false|null $result The import result.
42+
* @param string $emoji_url The remote emoji URL.
43+
*
44+
* @return string Mocked local URL based on the remote URL.
45+
*/
46+
public function mock_emoji_import( $result, $emoji_url ) {
47+
// Only mock emoji URLs from example.com.
48+
if ( false === \strpos( $emoji_url, 'example.com/emoji/' ) ) {
49+
return $result;
50+
}
51+
52+
// Return a mock local URL that preserves the filename.
53+
$filename = \basename( $emoji_url );
54+
return 'http://example.org/wp-content/uploads/activitypub/emoji/example.com/' . $filename;
55+
}
56+
1957
/**
2058
* Test replacing multiple emoji in a string.
2159
*

tests/phpunit/tests/includes/collection/class-test-interactions.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,14 +236,14 @@ public function test_handle_create_rich() {
236236
$rich_comment_id = Interactions::add_comment( $this->create_test_rich_object() );
237237
$rich_comment = get_comment( $rich_comment_id, ARRAY_A );
238238

239-
// img tags are allowed for emoji support.
240-
$this->assertEquals( 'Hello<br />example<p>example</p><img src="https://example.com/image.jpg" alt="" />', $rich_comment['comment_content'] );
239+
// Non-emoji img tags are stripped. Only local emoji images with class="emoji" are allowed.
240+
$this->assertEquals( 'Hello<br />example<p>example</p>', $rich_comment['comment_content'] );
241241

242242
$rich_comment_array = array(
243243
'comment_post_ID' => self::$post_id,
244244
'comment_author' => 'Example User',
245245
'comment_author_url' => self::$user_url,
246-
'comment_content' => 'Hello<br />example<p>example</p><img src="https://example.com/image.jpg" alt="" />',
246+
'comment_content' => 'Hello<br />example<p>example</p>',
247247
'comment_type' => 'comment',
248248
'comment_author_email' => '',
249249
'comment_parent' => 0,

0 commit comments

Comments
 (0)