PluginProbe
ActivityPub / 8.2.1
ActivityPub v8.2.1
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 8.2.1, at includes/class-sanitize.php

561 lines 15.0 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), so we remove them entirely before wp_kses runs.
25 *
26 * @var array<string>
27 */
28 const STRIP_ELEMENTS = array(
29 'script',
30 'style',
31 'button',
32 'nav',
33 'form',
34 'textarea',
35 'select',
36 'input',
37 'fieldset',
38 'iframe',
39 'embed',
40 'object',
41 );
42
43 /**
44 * MathML global attributes allowed per the W3C MathML safe list.
45 *
46 * @see https://w3c.github.io/mathml-docs/mathml-safe-list
47 *
48 * @var array<string, true>
49 */
50 const MATHML_GLOBAL_ATTRS = array(
51 'dir' => true,
52 'displaystyle' => true,
53 'mathbackground' => true,
54 'mathcolor' => true,
55 'mathsize' => true,
56 'scriptlevel' => true,
57 'intent' => true,
58 'arg' => true,
59 );
60
61 /**
62 * Sanitize a list of URLs.
63 *
64 * @param string|array $value The value to sanitize.
65 * @return array The sanitized list of URLs.
66 */
67 public static function url_list( $value ) {
68 if ( ! \is_array( $value ) ) {
69 $value = \explode( PHP_EOL, (string) $value );
70 }
71
72 $value = \array_filter( $value );
73 $value = \array_map( 'trim', $value );
74 $value = \array_map( 'sanitize_url', $value );
75 $value = \array_unique( $value );
76
77 return \array_values( $value );
78 }
79
80 /**
81 * Sanitize and normalize a list of account identifiers to ActivityPub IDs.
82 *
83 * This function processes various identifier formats, such as URLs and
84 * webfinger identifiers, and normalizes them into a consistent format.
85 *
86 * @param string|array $value The value to sanitize.
87 *
88 * @return array The sanitized and normalized list of account identifiers.
89 */
90 public static function identifier_list( $value ) {
91 if ( ! \is_array( $value ) ) {
92 $value = \explode( PHP_EOL, (string) $value );
93 }
94
95 $value = \array_filter( $value );
96 $uris = array();
97
98 foreach ( $value as $uri ) {
99 $uri = \trim( $uri );
100 $uri = \ltrim( $uri, '@' );
101
102 if ( \is_email( $uri ) ) {
103 $_uri = Webfinger::resolve( $uri );
104 if ( \is_wp_error( $_uri ) ) {
105 $uris[] = $uri;
106 continue;
107 }
108
109 $uri = $_uri;
110 }
111
112 $uri = \sanitize_url( $uri );
113 $actor = Remote_Actors::fetch_by_uri( $uri );
114 if ( \is_wp_error( $actor ) ) {
115 $uris[] = $uri;
116 } else {
117 $uris[] = \sanitize_url( $actor->guid );
118 }
119 }
120
121 return \array_values( \array_unique( $uris ) );
122 }
123
124 /**
125 * Sanitize a list of hosts.
126 *
127 * @param string $value The value to sanitize.
128 * @return string The sanitized list of hosts.
129 */
130 public static function host_list( $value ) {
131 $value = \explode( PHP_EOL, (string) $value );
132 $value = \array_map(
133 static function ( $host ) {
134 $host = \trim( $host );
135 $host = \strtolower( $host );
136 $host = \set_url_scheme( $host );
137 $host = \sanitize_url( $host, array( 'http', 'https' ) );
138
139 // Remove protocol.
140 if ( \str_contains( $host, 'http' ) ) {
141 $host = \wp_parse_url( $host, PHP_URL_HOST );
142 }
143
144 return \filter_var( $host, FILTER_VALIDATE_DOMAIN );
145 },
146 $value
147 );
148
149 return \implode( PHP_EOL, \array_filter( $value ) );
150 }
151
152 /**
153 * Sanitize a blog identifier.
154 *
155 * @param string $value The value to sanitize.
156 * @return string The sanitized blog identifier.
157 */
158 public static function blog_identifier( $value ) {
159 // Hack to allow dots in the username.
160 $parts = \explode( '.', (string) $value );
161 $sanitized = \array_map( 'sanitize_title', $parts );
162 $sanitized = \implode( '.', $sanitized );
163
164 if ( empty( $sanitized ) ) {
165 return Blog::get_default_username();
166 }
167
168 // Check for login or nicename.
169 $user = new \WP_User_Query(
170 array(
171 'search' => $sanitized,
172 'search_columns' => array( 'user_login', 'user_nicename' ),
173 'number' => 1,
174 'hide_empty' => true,
175 'fields' => 'ID',
176 )
177 );
178
179 if ( $user->get_results() ) {
180 \add_settings_error(
181 'activitypub_blog_identifier',
182 'activitypub_blog_identifier',
183 \esc_html__( 'You cannot use an existing author&#8217;s name for the blog profile ID.', 'activitypub' )
184 );
185
186 return Blog::get_default_username();
187 }
188
189 return $sanitized;
190 }
191
192 /**
193 * Get the sanitized value of a constant.
194 *
195 * @param mixed $value The constant value.
196 *
197 * @return string The sanitized value.
198 */
199 public static function constant_value( $value ) {
200 if ( is_bool( $value ) ) {
201 return $value ? 'true' : 'false';
202 }
203
204 if ( is_string( $value ) ) {
205 return esc_attr( $value );
206 }
207
208 if ( is_array( $value ) ) {
209 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
210 return print_r( $value, true );
211 }
212
213 return $value;
214 }
215
216 /**
217 * Sanitize a webfinger identifier.
218 *
219 * @param string $value The value to sanitize.
220 *
221 * @return string The sanitized webfinger identifier.
222 */
223 public static function webfinger( $value ) {
224 $value = \str_replace( 'acct:', '', $value );
225 $value = \trim( $value, '@' );
226
227 return $value;
228 }
229
230 /**
231 * Sanitize content for ActivityPub.
232 *
233 * @param string $content The content to convert.
234 *
235 * @return string The converted content.
236 */
237 public static function content( $content ) {
238 // Only make URLs clickable if no anchor tags exist, to avoid corrupting existing links.
239 if ( false === \strpos( $content, '<a ' ) ) {
240 $content = \make_clickable( $content );
241 }
242
243 $content = \wpautop( $content );
244 $content = \wp_kses_post( $content );
245
246 return $content;
247 }
248
249 /**
250 * Strip whitespace between HTML tags.
251 *
252 * Removes newlines, carriage returns, and tabs that appear between HTML tags,
253 * preserving whitespace within text content and preformatted elements.
254 *
255 * @param string $content The content to process.
256 *
257 * @return string The content with whitespace between tags removed.
258 */
259 public static function strip_whitespace( $content ) {
260 return \trim( \preg_replace( '/>[\n\r\t]+</', '><', $content ) );
261 }
262
263 /**
264 * Sanitize a redirect URI, preserving custom protocol schemes.
265 *
266 * WordPress's sanitize_url() and esc_url_raw() strip unknown protocols.
267 * This method extracts the scheme and passes it as allowed so custom
268 * URI schemes for native apps (RFC 8252 Section 7.1) are preserved.
269 *
270 * @since 8.1.0
271 *
272 * @param string $uri The redirect URI to sanitize.
273 * @return string The sanitized URI.
274 */
275 public static function redirect_uri( $uri ) {
276 /*
277 * Extract scheme manually because wp_parse_url() returns false
278 * for URIs like "myapp://" (scheme + empty authority, no path).
279 */
280 if ( ! preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
281 return '';
282 }
283
284 $scheme = \strtolower( $matches[1] );
285
286 // For standard schemes, use default sanitization.
287 if ( in_array( $scheme, array( 'http', 'https' ), true ) ) {
288 return \sanitize_url( $uri );
289 }
290
291 // For custom schemes, include the scheme in allowed protocols.
292 return \sanitize_url( $uri, array_merge( \wp_allowed_protocols(), array( $scheme ) ) );
293 }
294
295 /**
296 * Clean HTML for ActivityPub federation.
297 *
298 * Uses a positive allowlist based on FEP-b2b8 (Long-form Text) for the
299 * `content` property, extended with common WordPress content elements.
300 * Interactive, navigational, and scripting elements are stripped entirely.
301 *
302 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/b2b8/fep-b2b8.md
303 * @see https://github.com/Automattic/wordpress-activitypub/issues/2619
304 *
305 * @param string $content The HTML content to clean.
306 *
307 * @return string The cleaned HTML content.
308 */
309 public static function clean_html( $content ) {
310 if ( empty( $content ) ) {
311 return $content;
312 }
313
314 /*
315 * Strip elements whose inner content is noise (scripts, styles, interactive UI, embeds).
316 * This runs before wp_kses because wp_kses strips tags but keeps inner text,
317 * and content inside <script>, <style>, <nav>, etc. is meaningless on its own.
318 */
319 $strip_pattern = \implode( '|', self::STRIP_ELEMENTS );
320 $content = \preg_replace( '@<(' . $strip_pattern . ')[^>]*?>.*?</\\1>@si', '', $content );
321 // Also catch self-closing variants (e.g. <input />, <embed />).
322 $content = \preg_replace( '@<(' . $strip_pattern . ')[^>]*?/?>@si', '', $content );
323
324 /**
325 * Fires the deprecated attribute removal filter.
326 *
327 * @deprecated 8.1.0 Use the {@see 'activitypub_allowed_html'} filter instead.
328 */
329 if ( \has_filter( 'activitypub_remove_html_attributes' ) ) {
330 \_deprecated_hook( 'activitypub_remove_html_attributes', '8.1.0', 'activitypub_allowed_html' );
331 }
332
333 /**
334 * Filters the allowed HTML for ActivityPub content.
335 *
336 * The default allowlist is based on FEP-b2b8 (Long-form Text),
337 * extended with common WordPress content elements like figures,
338 * tables, definition lists, and horizontal rules.
339 *
340 * @param array $allowed_html The allowed HTML structure for wp_kses.
341 */
342 $allowed_html = \apply_filters( 'activitypub_allowed_html', self::get_allowed_html() );
343
344 return \wp_kses( $content, $allowed_html, \wp_allowed_protocols() );
345 }
346
347 /**
348 * Returns the allowed HTML elements and attributes for ActivityPub content.
349 *
350 * Based on the FEP-b2b8 allowlist for the `content` property, extended
351 * with additional WordPress content elements (figures, tables, definition
352 * lists, horizontal rules, etc.).
353 *
354 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/b2b8/fep-b2b8.md
355 *
356 * @return array The allowed HTML structure for wp_kses.
357 */
358 public static function get_allowed_html() {
359 // FEP-b2b8 core allowlist.
360 $allowed_html = array(
361 'p' => array(),
362 'span' => array(
363 'class' => true,
364 ),
365 'br' => array(),
366 'a' => array(
367 'href' => true,
368 'rel' => true,
369 'class' => true,
370 'title' => true,
371 ),
372 'h1' => array(),
373 'h2' => array(),
374 'h3' => array(),
375 'h4' => array(),
376 'h5' => array(),
377 'h6' => array(),
378 'del' => array(),
379 'pre' => array(),
380 'code' => array(),
381 'em' => array(),
382 'strong' => array(),
383 'b' => array(),
384 'i' => array(),
385 'u' => array(),
386 'ul' => array(),
387 'ol' => array(
388 'start' => true,
389 'reversed' => true,
390 ),
391 'li' => array(
392 'value' => true,
393 ),
394 'blockquote' => array(
395 'cite' => true,
396 ),
397 'img' => array(
398 'src' => true,
399 'alt' => true,
400 'title' => true,
401 'width' => true,
402 'height' => true,
403 ),
404 'video' => array(
405 'src' => true,
406 'controls' => true,
407 'loop' => true,
408 'poster' => true,
409 'width' => true,
410 'height' => true,
411 ),
412 'audio' => array(
413 'src' => true,
414 'controls' => true,
415 'loop' => true,
416 ),
417 'source' => array(
418 'src' => true,
419 'type' => true,
420 ),
421 'ruby' => array(),
422 'rt' => array(),
423 'rp' => array(),
424 );
425
426 // WordPress content extensions beyond FEP-b2b8.
427 $allowed_html['figure'] = array();
428 $allowed_html['figcaption'] = array();
429 $allowed_html['hr'] = array();
430 $allowed_html['div'] = array();
431 $allowed_html['table'] = array();
432 $allowed_html['thead'] = array();
433 $allowed_html['tbody'] = array();
434 $allowed_html['tfoot'] = array();
435 $allowed_html['tr'] = array();
436 $allowed_html['th'] = array(
437 'colspan' => true,
438 'rowspan' => true,
439 );
440 $allowed_html['td'] = array(
441 'colspan' => true,
442 'rowspan' => true,
443 );
444 $allowed_html['caption'] = array();
445 $allowed_html['dl'] = array();
446 $allowed_html['dt'] = array();
447 $allowed_html['dd'] = array();
448 $allowed_html['s'] = array();
449 $allowed_html['sub'] = array();
450 $allowed_html['sup'] = array();
451 $allowed_html['abbr'] = array(
452 'title' => true,
453 );
454 $allowed_html['mark'] = array();
455 $allowed_html['ins'] = array();
456 $allowed_html['cite'] = array();
457 $allowed_html['time'] = array(
458 'datetime' => true,
459 );
460 $allowed_html['track'] = array(
461 'src' => true,
462 'kind' => true,
463 'label' => true,
464 'srclang' => true,
465 );
466
467 // MathML safe elements per W3C MathML safe list.
468 $allowed_html['math'] = \array_merge(
469 self::MATHML_GLOBAL_ATTRS,
470 array(
471 'display' => true,
472 )
473 );
474 $allowed_html['merror'] = self::MATHML_GLOBAL_ATTRS;
475 $allowed_html['mfrac'] = \array_merge(
476 self::MATHML_GLOBAL_ATTRS,
477 array(
478 'linethickness' => true,
479 )
480 );
481 $allowed_html['mi'] = self::MATHML_GLOBAL_ATTRS;
482 $allowed_html['mmultiscripts'] = self::MATHML_GLOBAL_ATTRS;
483 $allowed_html['mn'] = self::MATHML_GLOBAL_ATTRS;
484 $allowed_html['mo'] = \array_merge(
485 self::MATHML_GLOBAL_ATTRS,
486 array(
487 'form' => true,
488 'fence' => true,
489 'separator' => true,
490 'lspace' => true,
491 'rspace' => true,
492 'stretchy' => true,
493 'symmetric' => true,
494 'maxsize' => true,
495 'minsize' => true,
496 'largeop' => true,
497 'movablelimits' => true,
498 )
499 );
500 $allowed_html['mover'] = self::MATHML_GLOBAL_ATTRS;
501 $allowed_html['mpadded'] = \array_merge(
502 self::MATHML_GLOBAL_ATTRS,
503 array(
504 'width' => true,
505 'height' => true,
506 'depth' => true,
507 'lspace' => true,
508 'voffset' => true,
509 )
510 );
511 $allowed_html['mprescripts'] = self::MATHML_GLOBAL_ATTRS;
512 $allowed_html['mroot'] = self::MATHML_GLOBAL_ATTRS;
513 $allowed_html['mrow'] = self::MATHML_GLOBAL_ATTRS;
514 $allowed_html['ms'] = self::MATHML_GLOBAL_ATTRS;
515 $allowed_html['mspace'] = \array_merge(
516 self::MATHML_GLOBAL_ATTRS,
517 array(
518 'width' => true,
519 'height' => true,
520 'depth' => true,
521 )
522 );
523 $allowed_html['msqrt'] = self::MATHML_GLOBAL_ATTRS;
524 $allowed_html['mstyle'] = self::MATHML_GLOBAL_ATTRS;
525 $allowed_html['msub'] = self::MATHML_GLOBAL_ATTRS;
526 $allowed_html['msubsup'] = self::MATHML_GLOBAL_ATTRS;
527 $allowed_html['msup'] = self::MATHML_GLOBAL_ATTRS;
528 $allowed_html['mtable'] = self::MATHML_GLOBAL_ATTRS;
529 $allowed_html['mtd'] = \array_merge(
530 self::MATHML_GLOBAL_ATTRS,
531 array(
532 'columnspan' => true,
533 'rowspan' => true,
534 )
535 );
536 $allowed_html['mtext'] = self::MATHML_GLOBAL_ATTRS;
537 $allowed_html['mtr'] = self::MATHML_GLOBAL_ATTRS;
538 $allowed_html['munder'] = self::MATHML_GLOBAL_ATTRS;
539 $allowed_html['munderover'] = \array_merge(
540 self::MATHML_GLOBAL_ATTRS,
541 array(
542 'accent' => true,
543 'accentunder' => true,
544 )
545 );
546 $allowed_html['semantics'] = \array_merge(
547 self::MATHML_GLOBAL_ATTRS,
548 array(
549 'encoding' => true,
550 )
551 );
552 $allowed_html['annotation'] = \array_merge(
553 self::MATHML_GLOBAL_ATTRS,
554 array(
555 'encoding' => true,
556 )
557 );
558 return $allowed_html;
559 }
560 }
561