PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-sanitize.php

class-sanitize.php in ActivityPub trunk, at includes/class-sanitize.php

713 lines 20.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sanitization file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Collection\Remote_Actors;
11 use Activitypub\Model\Blog;
12
13 /**
14 * Sanitization class.
15 */
16 class Sanitize {
17
18 /**
19 * Elements to strip including their inner content.
20 *
21 * WordPress's wp_kses removes disallowed tags but preserves their inner text.
22 * These elements contain content that is meaningless or harmful
23 * without the surrounding tag (scripts, styles, interactive UI,
24 * embedded objects) or that browsers hide by default (dialogs,
25 * templates), so we remove them entirely before wp_kses runs.
26 *
27 * @var array<string>
28 */
29 const STRIP_ELEMENTS = array(
30 'script',
31 'style',
32 'noscript',
33 'template',
34 'button',
35 'nav',
36 'dialog',
37 'form',
38 'textarea',
39 'select',
40 'option',
41 'optgroup',
42 'datalist',
43 'input',
44 'fieldset',
45 'iframe',
46 'embed',
47 'object',
48 'canvas',
49 'applet',
50 'noembed',
51 'noframes',
52 );
53
54 /**
55 * MathML global attributes allowed per the W3C MathML safe list.
56 *
57 * @see https://w3c.github.io/mathml-docs/mathml-safe-list
58 *
59 * @var array<string, true>
60 */
61 const MATHML_GLOBAL_ATTRS = array(
62 'dir' => true,
63 'displaystyle' => true,
64 'mathbackground' => true,
65 'mathcolor' => true,
66 'mathsize' => true,
67 'scriptlevel' => true,
68 'intent' => true,
69 'arg' => true,
70 );
71
72 /**
73 * Sanitize a list of URLs.
74 *
75 * @param string|array $value The value to sanitize.
76 * @return array The sanitized list of URLs.
77 */
78 public static function url_list( $value ) {
79 if ( ! \is_array( $value ) ) {
80 $value = \explode( PHP_EOL, (string) $value );
81 }
82
83 $value = \array_filter( $value );
84 $value = \array_map( 'trim', $value );
85 $value = \array_map( 'sanitize_url', $value );
86 $value = \array_unique( $value );
87
88 return \array_values( $value );
89 }
90
91 /**
92 * Sanitize and normalize a list of account identifiers to ActivityPub IDs.
93 *
94 * This function processes various identifier formats, such as URLs and
95 * webfinger identifiers, and normalizes them into a consistent format.
96 *
97 * @param string|array $value The value to sanitize.
98 *
99 * @return array The sanitized and normalized list of account identifiers.
100 */
101 public static function identifier_list( $value ) {
102 if ( ! \is_array( $value ) ) {
103 $value = \explode( PHP_EOL, (string) $value );
104 }
105
106 $value = \array_filter( $value );
107 $uris = array();
108
109 foreach ( $value as $uri ) {
110 $uri = \trim( $uri );
111 $uri = \ltrim( $uri, '@' );
112
113 if ( \is_email( $uri ) ) {
114 $_uri = Webfinger::resolve( $uri );
115 if ( \is_wp_error( $_uri ) ) {
116 $uris[] = $uri;
117 continue;
118 }
119
120 $uri = $_uri;
121 }
122
123 $uri = \sanitize_url( $uri );
124 $actor = Remote_Actors::fetch_by_uri( $uri );
125 if ( \is_wp_error( $actor ) ) {
126 $uris[] = $uri;
127 } else {
128 $uris[] = \sanitize_url( $actor->guid );
129 }
130 }
131
132 return \array_values( \array_unique( $uris ) );
133 }
134
135 /**
136 * Sanitize a list of hosts.
137 *
138 * @param string $value The value to sanitize.
139 * @return string The sanitized list of hosts.
140 */
141 public static function host_list( $value ) {
142 $value = \explode( PHP_EOL, (string) $value );
143 $value = \array_map(
144 static function ( $host ) {
145 $host = \trim( $host );
146 $host = \strtolower( $host );
147 $host = \set_url_scheme( $host );
148 $host = \sanitize_url( $host, array( 'http', 'https' ) );
149
150 // Remove protocol.
151 if ( \str_contains( $host, 'http' ) ) {
152 $host = \wp_parse_url( $host, PHP_URL_HOST );
153 }
154
155 return \filter_var( $host, FILTER_VALIDATE_DOMAIN );
156 },
157 $value
158 );
159
160 return \implode( PHP_EOL, \array_filter( $value ) );
161 }
162
163 /**
164 * Sanitize a blog identifier.
165 *
166 * @param string $value The value to sanitize.
167 * @return string The sanitized blog identifier.
168 */
169 public static function blog_identifier( $value ) {
170 // Hack to allow dots in the username.
171 $parts = \explode( '.', (string) $value );
172 $sanitized = \array_map( 'sanitize_title', $parts );
173 $sanitized = \implode( '.', $sanitized );
174
175 if ( empty( $sanitized ) ) {
176 return Blog::get_default_username();
177 }
178
179 // The 'application' identifier is reserved for the Application actor.
180 if ( Application::USERNAME === $sanitized ) {
181 \add_settings_error(
182 'activitypub_blog_identifier',
183 'activitypub_blog_identifier',
184 \esc_html__( 'This name is reserved and cannot be used for the blog profile ID.', 'activitypub' )
185 );
186
187 return Blog::get_default_username();
188 }
189
190 // Check for login or nicename.
191 $user = new \WP_User_Query(
192 array(
193 'search' => $sanitized,
194 'search_columns' => array( 'user_login', 'user_nicename' ),
195 'number' => 1,
196 'hide_empty' => true,
197 'fields' => 'ID',
198 )
199 );
200
201 if ( $user->get_results() ) {
202 \add_settings_error(
203 'activitypub_blog_identifier',
204 'activitypub_blog_identifier',
205 \esc_html__( 'You cannot use an existing author&#8217;s name for the blog profile ID.', 'activitypub' )
206 );
207
208 return Blog::get_default_username();
209 }
210
211 return $sanitized;
212 }
213
214 /**
215 * Get the sanitized value of a constant.
216 *
217 * @param mixed $value The constant value.
218 *
219 * @return string The sanitized value.
220 */
221 public static function constant_value( $value ) {
222 if ( \is_bool( $value ) ) {
223 return $value ? 'true' : 'false';
224 }
225
226 if ( \is_string( $value ) ) {
227 return \esc_attr( $value );
228 }
229
230 if ( \is_array( $value ) ) {
231 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
232 return \print_r( $value, true );
233 }
234
235 return $value;
236 }
237
238 /**
239 * Sanitize a webfinger identifier.
240 *
241 * @param string $value The value to sanitize.
242 *
243 * @return string The sanitized webfinger identifier.
244 */
245 public static function webfinger( $value ) {
246 $value = \str_replace( 'acct:', '', $value );
247 $value = \trim( $value, '@' );
248
249 return $value;
250 }
251
252 /**
253 * Remove elements whose inner text is noise on its own.
254 *
255 * Used by {@see Sanitize::clean_html()}:
256 * kses removes a tag but keeps what is inside it, so a `<script>` body would
257 * otherwise survive as visible text.
258 *
259 * @since 9.3.0
260 *
261 * @param string $content The content to strip.
262 *
263 * @return string The content without those elements.
264 */
265 private static function strip_elements( $content ) {
266 $strip_pattern = \implode( '|', self::STRIP_ELEMENTS );
267 $content = \preg_replace( \sprintf( '@<(%s)(?=[\\s/>])[^>]*?>.*?</\\1>@si', $strip_pattern ), '', $content ) ?? '';
268
269 // <option> and <optgroup> may omit their end tags, so also strip the text that follows an unclosed one.
270 $content = \preg_replace( '@<(option|optgroup)(?=[\\s/>])[^>]*?>[^<]*@si', '', $content ) ?? '';
271
272 // Also catch self-closing variants (e.g. <input />, <embed />).
273 $content = \preg_replace( \sprintf( '@<(%s)(?=[\\s/>])[^>]*?/?>@si', $strip_pattern ), '', $content );
274
275 // preg_replace() returns null if PCRE bails; an empty string is the safe reading.
276 return $content ?? '';
277 }
278
279 /**
280 * Sanitize HTML content that was written by a remote server.
281 *
282 * Normalizes formatting (bare URLs to links, loose lines to paragraphs) and then holds
283 * the content to the same gate we apply to what we send out ({@see Sanitize::clean_html()}):
284 * the FEP-b2b8 allowlist, which carries no `style` attribute and no interactive, scripting
285 * or embed elements. Remote content is held to the FEP its own author federates under.
286 *
287 * HTML comments are stripped first, because this content is stored and later runs
288 * through `do_blocks()`, which would otherwise reconstitute a remote block delimiter.
289 *
290 * @param string $content The content to sanitize.
291 *
292 * @return string The sanitized content.
293 */
294 public static function content( $content ) {
295 if ( ! \is_string( $content ) || '' === $content ) {
296 return '';
297 }
298
299 // Only make URLs clickable if no anchor tags exist, to avoid corrupting existing links.
300 if ( false === \strpos( $content, '<a ' ) ) {
301 $content = \make_clickable( $content );
302 }
303
304 $content = \wpautop( $content );
305 $content = self::clean_html( self::strip_html_comments( $content ) );
306
307 /*
308 * `do_shortcode()` runs on `the_content` right after `do_blocks()`, so a remote
309 * `[gallery]` would reach a local shortcode's render callback the same way a block
310 * delimiter would reach the block parser. Encoding the opening bracket leaves the
311 * text looking identical to a reader and unparseable to the shortcode regex.
312 */
313 return \str_replace( '[', '&#91;', $content );
314 }
315
316 /**
317 * Sanitize comment content that was written by a remote server.
318 *
319 * Comments get a much narrower allowlist than posts, so this is not
320 * the post cleaner {@see Sanitize::content()} with a different name. Core resolves the
321 * `pre_comment_content` context to the comment allowlist, and
322 * {@see Interactions::allowed_comment_html()} adds `p`, `br` and the strict emoji
323 * `img` back on top of it.
324 *
325 * Core only applies that allowlist itself when the request installed the
326 * `pre_comment_content` filter, and `kses_init()` installs nothing for a user with
327 * `unfiltered_html`. Calling this where the content is known to be remote keeps the
328 * guarantee from depending on who is logged in.
329 *
330 * @since 9.3.0
331 *
332 * @param string $content The remote comment content.
333 *
334 * @return string The sanitized content.
335 */
336 public static function comment_content( $content ) {
337 if ( ! \is_string( $content ) || '' === $content ) {
338 return '';
339 }
340
341 return \wp_kses( self::strip_html_comments( $content ), self::get_allowed_comment_html(), \wp_allowed_protocols() );
342 }
343
344 /**
345 * Returns the allowed HTML for remote comment content.
346 *
347 * The `pre_comment_content` allowlist plus `p`, `br` and the strict emoji `img`.
348 * {@see \Activitypub\Collection\Interactions::allowed_comment_html()} applies the
349 * same additions on the `wp_kses_allowed_html` filter and delegates here, so the
350 * filter and the direct call cannot drift.
351 *
352 * @since 9.3.0
353 *
354 * @param array|null $allowed_tags Optional. Allowlist to extend. Default the `pre_comment_content` context.
355 *
356 * @return array The allowed HTML structure for wp_kses.
357 */
358 public static function get_allowed_comment_html( $allowed_tags = null ) {
359 if ( null === $allowed_tags ) {
360 $allowed_tags = \wp_kses_allowed_html( 'pre_comment_content' );
361 }
362
363 // Add `p` and `br` to the list of allowed tags.
364 if ( ! \array_key_exists( 'br', $allowed_tags ) ) {
365 $allowed_tags['br'] = array();
366 }
367
368 if ( ! \array_key_exists( 'p', $allowed_tags ) ) {
369 $allowed_tags['p'] = array();
370 }
371
372 // Add `img` for custom emoji support with strict validation.
373 $emoji_html = Emoji::get_kses_allowed_html();
374 if ( ! \array_key_exists( 'img', $allowed_tags ) ) {
375 $allowed_tags['img'] = $emoji_html['img'];
376 }
377
378 return $allowed_tags;
379 }
380
381 /**
382 * Remove HTML comments from content a remote server wrote.
383 *
384 * Block delimiters are HTML comments, and kses has no opinion about those.
385 * `do_blocks()` then runs over the stored value -- through `the_content` for posts and
386 * {@see \Activitypub\Comment::render_blocks()} for comments -- and core's block
387 * supports rebuild CSS from the delimiter's own JSON. A remote
388 * group delimiter carrying `style.background.backgroundImage.url` comes back out as
389 * `style="background-image:url(...)"`, which is exactly what the FEP-b2b8 allowlist in
390 * {@see Sanitize::clean_html()} drops the `style` attribute to prevent.
391 * Dynamic blocks are the same story with their render callbacks.
392 *
393 * Every comment goes, not just `wp:` ones: the block parser tolerates spacing and
394 * casing a prefix match would have to chase, and a comment carries nothing a reader
395 * would see anyway. The plugin's own emoji and image delimiters are added after this
396 * runs, so they are unaffected.
397 *
398 * @since 9.3.0
399 *
400 * @param string $content The content to strip.
401 *
402 * @return string The content without HTML comments.
403 */
404 private static function strip_html_comments( $content ) {
405 // preg_replace() returns null if PCRE bails; an empty string is the safe reading.
406 return \preg_replace( '/<!--.*?-->/s', '', $content ) ?? '';
407 }
408
409 /**
410 * Strip whitespace between HTML tags.
411 *
412 * Removes newlines, carriage returns, and tabs that appear between HTML tags,
413 * preserving whitespace within text content and preformatted elements.
414 *
415 * @param string $content The content to process.
416 *
417 * @return string The content with whitespace between tags removed.
418 */
419 public static function strip_whitespace( $content ) {
420 return \trim( \preg_replace( '/>[\n\r\t]+</', '><', $content ) );
421 }
422
423 /**
424 * Sanitize a redirect URI, preserving custom protocol schemes.
425 *
426 * WordPress's sanitize_url() and esc_url_raw() strip unknown protocols.
427 * This method extracts the scheme and passes it as allowed so custom
428 * URI schemes for native apps (RFC 8252 Section 7.1) are preserved.
429 *
430 * @since 8.1.0
431 *
432 * @param string $uri The redirect URI to sanitize.
433 * @return string The sanitized URI.
434 */
435 public static function redirect_uri( $uri ) {
436 /*
437 * Extract scheme manually because wp_parse_url() returns false
438 * for URIs like "myapp://" (scheme + empty authority, no path).
439 */
440 if ( ! \preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
441 return '';
442 }
443
444 $scheme = \strtolower( $matches[1] );
445
446 // For standard schemes, use default sanitization.
447 if ( \in_array( $scheme, array( 'http', 'https' ), true ) ) {
448 return \sanitize_url( $uri );
449 }
450
451 // For custom schemes, include the scheme in allowed protocols.
452 return \sanitize_url( $uri, \array_merge( \wp_allowed_protocols(), array( $scheme ) ) );
453 }
454
455 /**
456 * Clean HTML for ActivityPub federation.
457 *
458 * Uses a positive allowlist based on FEP-b2b8 (Long-form Text) for the
459 * `content` property, extended with common WordPress content elements.
460 * Interactive, navigational, and scripting elements are stripped entirely.
461 *
462 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/b2b8/fep-b2b8.md
463 * @see https://github.com/Automattic/wordpress-activitypub/issues/2619
464 *
465 * @param string $content The HTML content to clean.
466 *
467 * @return string The cleaned HTML content.
468 */
469 public static function clean_html( $content ) {
470 if ( empty( $content ) ) {
471 return $content;
472 }
473
474 $content = self::strip_elements( $content );
475
476 /**
477 * Fires the deprecated attribute removal filter.
478 *
479 * @deprecated 8.1.0 Use the {@see 'activitypub_allowed_html'} filter instead.
480 */
481 if ( \has_filter( 'activitypub_remove_html_attributes' ) ) {
482 \_deprecated_hook( 'activitypub_remove_html_attributes', '8.1.0', 'activitypub_allowed_html' );
483 }
484
485 /**
486 * Filters the allowed HTML for ActivityPub content.
487 *
488 * The default allowlist is based on FEP-b2b8 (Long-form Text),
489 * extended with common WordPress content elements like figures,
490 * tables, definition lists, and horizontal rules.
491 *
492 * @param array $allowed_html The allowed HTML structure for wp_kses.
493 */
494 $allowed_html = \apply_filters( 'activitypub_allowed_html', self::get_allowed_html() );
495
496 return \wp_kses( $content, $allowed_html, \wp_allowed_protocols() );
497 }
498
499 /**
500 * Returns the allowed HTML elements and attributes for ActivityPub content.
501 *
502 * Based on the FEP-b2b8 allowlist for the `content` property, extended
503 * with additional WordPress content elements (figures, tables, definition
504 * lists, horizontal rules, etc.).
505 *
506 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/b2b8/fep-b2b8.md
507 *
508 * @return array The allowed HTML structure for wp_kses.
509 */
510 public static function get_allowed_html() {
511 // FEP-b2b8 core allowlist.
512 $allowed_html = array(
513 'p' => array(),
514 'span' => array(
515 'class' => true,
516 ),
517 'br' => array(),
518 'a' => array(
519 'href' => true,
520 'rel' => true,
521 'class' => true,
522 'title' => true,
523 ),
524 'h1' => array(),
525 'h2' => array(),
526 'h3' => array(),
527 'h4' => array(),
528 'h5' => array(),
529 'h6' => array(),
530 'del' => array(),
531 'pre' => array(),
532 'code' => array(),
533 'em' => array(),
534 'strong' => array(),
535 'b' => array(),
536 'i' => array(),
537 'u' => array(),
538 'ul' => array(),
539 'ol' => array(
540 'start' => true,
541 'reversed' => true,
542 ),
543 'li' => array(
544 'value' => true,
545 ),
546 'blockquote' => array(
547 'cite' => true,
548 ),
549 'img' => array(
550 'src' => true,
551 'alt' => true,
552 'title' => true,
553 'width' => true,
554 'height' => true,
555 ),
556 'video' => array(
557 'src' => true,
558 'controls' => true,
559 'loop' => true,
560 'poster' => true,
561 'width' => true,
562 'height' => true,
563 ),
564 'audio' => array(
565 'src' => true,
566 'controls' => true,
567 'loop' => true,
568 ),
569 'source' => array(
570 'src' => true,
571 'type' => true,
572 ),
573 'ruby' => array(),
574 'rt' => array(),
575 'rp' => array(),
576 );
577
578 // WordPress content extensions beyond FEP-b2b8.
579 $allowed_html['figure'] = array();
580 $allowed_html['figcaption'] = array();
581 $allowed_html['hr'] = array();
582 $allowed_html['div'] = array();
583 $allowed_html['table'] = array();
584 $allowed_html['thead'] = array();
585 $allowed_html['tbody'] = array();
586 $allowed_html['tfoot'] = array();
587 $allowed_html['tr'] = array();
588 $allowed_html['th'] = array(
589 'colspan' => true,
590 'rowspan' => true,
591 );
592 $allowed_html['td'] = array(
593 'colspan' => true,
594 'rowspan' => true,
595 );
596 $allowed_html['caption'] = array();
597 $allowed_html['dl'] = array();
598 $allowed_html['dt'] = array();
599 $allowed_html['dd'] = array();
600 $allowed_html['s'] = array();
601 $allowed_html['sub'] = array();
602 $allowed_html['sup'] = array();
603 $allowed_html['abbr'] = array(
604 'title' => true,
605 );
606 $allowed_html['mark'] = array();
607 $allowed_html['ins'] = array();
608 $allowed_html['cite'] = array();
609 $allowed_html['time'] = array(
610 'datetime' => true,
611 );
612 $allowed_html['track'] = array(
613 'src' => true,
614 'kind' => true,
615 'label' => true,
616 'srclang' => true,
617 );
618
619 // MathML safe elements per W3C MathML safe list.
620 $allowed_html['math'] = \array_merge(
621 self::MATHML_GLOBAL_ATTRS,
622 array(
623 'display' => true,
624 )
625 );
626 $allowed_html['merror'] = self::MATHML_GLOBAL_ATTRS;
627 $allowed_html['mfrac'] = \array_merge(
628 self::MATHML_GLOBAL_ATTRS,
629 array(
630 'linethickness' => true,
631 )
632 );
633 $allowed_html['mi'] = self::MATHML_GLOBAL_ATTRS;
634 $allowed_html['mmultiscripts'] = self::MATHML_GLOBAL_ATTRS;
635 $allowed_html['mn'] = self::MATHML_GLOBAL_ATTRS;
636 $allowed_html['mo'] = \array_merge(
637 self::MATHML_GLOBAL_ATTRS,
638 array(
639 'form' => true,
640 'fence' => true,
641 'separator' => true,
642 'lspace' => true,
643 'rspace' => true,
644 'stretchy' => true,
645 'symmetric' => true,
646 'maxsize' => true,
647 'minsize' => true,
648 'largeop' => true,
649 'movablelimits' => true,
650 )
651 );
652 $allowed_html['mover'] = self::MATHML_GLOBAL_ATTRS;
653 $allowed_html['mpadded'] = \array_merge(
654 self::MATHML_GLOBAL_ATTRS,
655 array(
656 'width' => true,
657 'height' => true,
658 'depth' => true,
659 'lspace' => true,
660 'voffset' => true,
661 )
662 );
663 $allowed_html['mprescripts'] = self::MATHML_GLOBAL_ATTRS;
664 $allowed_html['mroot'] = self::MATHML_GLOBAL_ATTRS;
665 $allowed_html['mrow'] = self::MATHML_GLOBAL_ATTRS;
666 $allowed_html['ms'] = self::MATHML_GLOBAL_ATTRS;
667 $allowed_html['mspace'] = \array_merge(
668 self::MATHML_GLOBAL_ATTRS,
669 array(
670 'width' => true,
671 'height' => true,
672 'depth' => true,
673 )
674 );
675 $allowed_html['msqrt'] = self::MATHML_GLOBAL_ATTRS;
676 $allowed_html['mstyle'] = self::MATHML_GLOBAL_ATTRS;
677 $allowed_html['msub'] = self::MATHML_GLOBAL_ATTRS;
678 $allowed_html['msubsup'] = self::MATHML_GLOBAL_ATTRS;
679 $allowed_html['msup'] = self::MATHML_GLOBAL_ATTRS;
680 $allowed_html['mtable'] = self::MATHML_GLOBAL_ATTRS;
681 $allowed_html['mtd'] = \array_merge(
682 self::MATHML_GLOBAL_ATTRS,
683 array(
684 'columnspan' => true,
685 'rowspan' => true,
686 )
687 );
688 $allowed_html['mtext'] = self::MATHML_GLOBAL_ATTRS;
689 $allowed_html['mtr'] = self::MATHML_GLOBAL_ATTRS;
690 $allowed_html['munder'] = self::MATHML_GLOBAL_ATTRS;
691 $allowed_html['munderover'] = \array_merge(
692 self::MATHML_GLOBAL_ATTRS,
693 array(
694 'accent' => true,
695 'accentunder' => true,
696 )
697 );
698 $allowed_html['semantics'] = \array_merge(
699 self::MATHML_GLOBAL_ATTRS,
700 array(
701 'encoding' => true,
702 )
703 );
704 $allowed_html['annotation'] = \array_merge(
705 self::MATHML_GLOBAL_ATTRS,
706 array(
707 'encoding' => true,
708 )
709 );
710 return $allowed_html;
711 }
712 }
713