PluginProbe
ActivityPub / 9.1.0
ActivityPub v9.1.0
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 9.1.0, at includes/class-sanitize.php

572 lines 15.4 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 // The 'application' identifier is reserved for the Application actor.
169 if ( Application::USERNAME === $sanitized ) {
170 \add_settings_error(
171 'activitypub_blog_identifier',
172 'activitypub_blog_identifier',
173 \esc_html__( 'This name is reserved and cannot be used for the blog profile ID.', 'activitypub' )
174 );
175
176 return Blog::get_default_username();
177 }
178
179 // Check for login or nicename.
180 $user = new \WP_User_Query(
181 array(
182 'search' => $sanitized,
183 'search_columns' => array( 'user_login', 'user_nicename' ),
184 'number' => 1,
185 'hide_empty' => true,
186 'fields' => 'ID',
187 )
188 );
189
190 if ( $user->get_results() ) {
191 \add_settings_error(
192 'activitypub_blog_identifier',
193 'activitypub_blog_identifier',
194 \esc_html__( 'You cannot use an existing author&#8217;s name for the blog profile ID.', 'activitypub' )
195 );
196
197 return Blog::get_default_username();
198 }
199
200 return $sanitized;
201 }
202
203 /**
204 * Get the sanitized value of a constant.
205 *
206 * @param mixed $value The constant value.
207 *
208 * @return string The sanitized value.
209 */
210 public static function constant_value( $value ) {
211 if ( \is_bool( $value ) ) {
212 return $value ? 'true' : 'false';
213 }
214
215 if ( \is_string( $value ) ) {
216 return \esc_attr( $value );
217 }
218
219 if ( \is_array( $value ) ) {
220 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
221 return \print_r( $value, true );
222 }
223
224 return $value;
225 }
226
227 /**
228 * Sanitize a webfinger identifier.
229 *
230 * @param string $value The value to sanitize.
231 *
232 * @return string The sanitized webfinger identifier.
233 */
234 public static function webfinger( $value ) {
235 $value = \str_replace( 'acct:', '', $value );
236 $value = \trim( $value, '@' );
237
238 return $value;
239 }
240
241 /**
242 * Sanitize content for ActivityPub.
243 *
244 * @param string $content The content to convert.
245 *
246 * @return string The converted content.
247 */
248 public static function content( $content ) {
249 // Only make URLs clickable if no anchor tags exist, to avoid corrupting existing links.
250 if ( false === \strpos( $content, '<a ' ) ) {
251 $content = \make_clickable( $content );
252 }
253
254 $content = \wpautop( $content );
255 $content = \wp_kses_post( $content );
256
257 return $content;
258 }
259
260 /**
261 * Strip whitespace between HTML tags.
262 *
263 * Removes newlines, carriage returns, and tabs that appear between HTML tags,
264 * preserving whitespace within text content and preformatted elements.
265 *
266 * @param string $content The content to process.
267 *
268 * @return string The content with whitespace between tags removed.
269 */
270 public static function strip_whitespace( $content ) {
271 return \trim( \preg_replace( '/>[\n\r\t]+</', '><', $content ) );
272 }
273
274 /**
275 * Sanitize a redirect URI, preserving custom protocol schemes.
276 *
277 * WordPress's sanitize_url() and esc_url_raw() strip unknown protocols.
278 * This method extracts the scheme and passes it as allowed so custom
279 * URI schemes for native apps (RFC 8252 Section 7.1) are preserved.
280 *
281 * @since 8.1.0
282 *
283 * @param string $uri The redirect URI to sanitize.
284 * @return string The sanitized URI.
285 */
286 public static function redirect_uri( $uri ) {
287 /*
288 * Extract scheme manually because wp_parse_url() returns false
289 * for URIs like "myapp://" (scheme + empty authority, no path).
290 */
291 if ( ! \preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
292 return '';
293 }
294
295 $scheme = \strtolower( $matches[1] );
296
297 // For standard schemes, use default sanitization.
298 if ( \in_array( $scheme, array( 'http', 'https' ), true ) ) {
299 return \sanitize_url( $uri );
300 }
301
302 // For custom schemes, include the scheme in allowed protocols.
303 return \sanitize_url( $uri, \array_merge( \wp_allowed_protocols(), array( $scheme ) ) );
304 }
305
306 /**
307 * Clean HTML for ActivityPub federation.
308 *
309 * Uses a positive allowlist based on FEP-b2b8 (Long-form Text) for the
310 * `content` property, extended with common WordPress content elements.
311 * Interactive, navigational, and scripting elements are stripped entirely.
312 *
313 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/b2b8/fep-b2b8.md
314 * @see https://github.com/Automattic/wordpress-activitypub/issues/2619
315 *
316 * @param string $content The HTML content to clean.
317 *
318 * @return string The cleaned HTML content.
319 */
320 public static function clean_html( $content ) {
321 if ( empty( $content ) ) {
322 return $content;
323 }
324
325 /*
326 * Strip elements whose inner content is noise (scripts, styles, interactive UI, embeds).
327 * This runs before wp_kses because wp_kses strips tags but keeps inner text,
328 * and content inside <script>, <style>, <nav>, etc. is meaningless on its own.
329 */
330 $strip_pattern = \implode( '|', self::STRIP_ELEMENTS );
331 $content = \preg_replace( '@<(' . $strip_pattern . ')[^>]*?>.*?</\\1>@si', '', $content );
332 // Also catch self-closing variants (e.g. <input />, <embed />).
333 $content = \preg_replace( '@<(' . $strip_pattern . ')[^>]*?/?>@si', '', $content );
334
335 /**
336 * Fires the deprecated attribute removal filter.
337 *
338 * @deprecated 8.1.0 Use the {@see 'activitypub_allowed_html'} filter instead.
339 */
340 if ( \has_filter( 'activitypub_remove_html_attributes' ) ) {
341 \_deprecated_hook( 'activitypub_remove_html_attributes', '8.1.0', 'activitypub_allowed_html' );
342 }
343
344 /**
345 * Filters the allowed HTML for ActivityPub content.
346 *
347 * The default allowlist is based on FEP-b2b8 (Long-form Text),
348 * extended with common WordPress content elements like figures,
349 * tables, definition lists, and horizontal rules.
350 *
351 * @param array $allowed_html The allowed HTML structure for wp_kses.
352 */
353 $allowed_html = \apply_filters( 'activitypub_allowed_html', self::get_allowed_html() );
354
355 return \wp_kses( $content, $allowed_html, \wp_allowed_protocols() );
356 }
357
358 /**
359 * Returns the allowed HTML elements and attributes for ActivityPub content.
360 *
361 * Based on the FEP-b2b8 allowlist for the `content` property, extended
362 * with additional WordPress content elements (figures, tables, definition
363 * lists, horizontal rules, etc.).
364 *
365 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/b2b8/fep-b2b8.md
366 *
367 * @return array The allowed HTML structure for wp_kses.
368 */
369 public static function get_allowed_html() {
370 // FEP-b2b8 core allowlist.
371 $allowed_html = array(
372 'p' => array(),
373 'span' => array(
374 'class' => true,
375 ),
376 'br' => array(),
377 'a' => array(
378 'href' => true,
379 'rel' => true,
380 'class' => true,
381 'title' => true,
382 ),
383 'h1' => array(),
384 'h2' => array(),
385 'h3' => array(),
386 'h4' => array(),
387 'h5' => array(),
388 'h6' => array(),
389 'del' => array(),
390 'pre' => array(),
391 'code' => array(),
392 'em' => array(),
393 'strong' => array(),
394 'b' => array(),
395 'i' => array(),
396 'u' => array(),
397 'ul' => array(),
398 'ol' => array(
399 'start' => true,
400 'reversed' => true,
401 ),
402 'li' => array(
403 'value' => true,
404 ),
405 'blockquote' => array(
406 'cite' => true,
407 ),
408 'img' => array(
409 'src' => true,
410 'alt' => true,
411 'title' => true,
412 'width' => true,
413 'height' => true,
414 ),
415 'video' => array(
416 'src' => true,
417 'controls' => true,
418 'loop' => true,
419 'poster' => true,
420 'width' => true,
421 'height' => true,
422 ),
423 'audio' => array(
424 'src' => true,
425 'controls' => true,
426 'loop' => true,
427 ),
428 'source' => array(
429 'src' => true,
430 'type' => true,
431 ),
432 'ruby' => array(),
433 'rt' => array(),
434 'rp' => array(),
435 );
436
437 // WordPress content extensions beyond FEP-b2b8.
438 $allowed_html['figure'] = array();
439 $allowed_html['figcaption'] = array();
440 $allowed_html['hr'] = array();
441 $allowed_html['div'] = array();
442 $allowed_html['table'] = array();
443 $allowed_html['thead'] = array();
444 $allowed_html['tbody'] = array();
445 $allowed_html['tfoot'] = array();
446 $allowed_html['tr'] = array();
447 $allowed_html['th'] = array(
448 'colspan' => true,
449 'rowspan' => true,
450 );
451 $allowed_html['td'] = array(
452 'colspan' => true,
453 'rowspan' => true,
454 );
455 $allowed_html['caption'] = array();
456 $allowed_html['dl'] = array();
457 $allowed_html['dt'] = array();
458 $allowed_html['dd'] = array();
459 $allowed_html['s'] = array();
460 $allowed_html['sub'] = array();
461 $allowed_html['sup'] = array();
462 $allowed_html['abbr'] = array(
463 'title' => true,
464 );
465 $allowed_html['mark'] = array();
466 $allowed_html['ins'] = array();
467 $allowed_html['cite'] = array();
468 $allowed_html['time'] = array(
469 'datetime' => true,
470 );
471 $allowed_html['track'] = array(
472 'src' => true,
473 'kind' => true,
474 'label' => true,
475 'srclang' => true,
476 );
477
478 // MathML safe elements per W3C MathML safe list.
479 $allowed_html['math'] = \array_merge(
480 self::MATHML_GLOBAL_ATTRS,
481 array(
482 'display' => true,
483 )
484 );
485 $allowed_html['merror'] = self::MATHML_GLOBAL_ATTRS;
486 $allowed_html['mfrac'] = \array_merge(
487 self::MATHML_GLOBAL_ATTRS,
488 array(
489 'linethickness' => true,
490 )
491 );
492 $allowed_html['mi'] = self::MATHML_GLOBAL_ATTRS;
493 $allowed_html['mmultiscripts'] = self::MATHML_GLOBAL_ATTRS;
494 $allowed_html['mn'] = self::MATHML_GLOBAL_ATTRS;
495 $allowed_html['mo'] = \array_merge(
496 self::MATHML_GLOBAL_ATTRS,
497 array(
498 'form' => true,
499 'fence' => true,
500 'separator' => true,
501 'lspace' => true,
502 'rspace' => true,
503 'stretchy' => true,
504 'symmetric' => true,
505 'maxsize' => true,
506 'minsize' => true,
507 'largeop' => true,
508 'movablelimits' => true,
509 )
510 );
511 $allowed_html['mover'] = self::MATHML_GLOBAL_ATTRS;
512 $allowed_html['mpadded'] = \array_merge(
513 self::MATHML_GLOBAL_ATTRS,
514 array(
515 'width' => true,
516 'height' => true,
517 'depth' => true,
518 'lspace' => true,
519 'voffset' => true,
520 )
521 );
522 $allowed_html['mprescripts'] = self::MATHML_GLOBAL_ATTRS;
523 $allowed_html['mroot'] = self::MATHML_GLOBAL_ATTRS;
524 $allowed_html['mrow'] = self::MATHML_GLOBAL_ATTRS;
525 $allowed_html['ms'] = self::MATHML_GLOBAL_ATTRS;
526 $allowed_html['mspace'] = \array_merge(
527 self::MATHML_GLOBAL_ATTRS,
528 array(
529 'width' => true,
530 'height' => true,
531 'depth' => true,
532 )
533 );
534 $allowed_html['msqrt'] = self::MATHML_GLOBAL_ATTRS;
535 $allowed_html['mstyle'] = self::MATHML_GLOBAL_ATTRS;
536 $allowed_html['msub'] = self::MATHML_GLOBAL_ATTRS;
537 $allowed_html['msubsup'] = self::MATHML_GLOBAL_ATTRS;
538 $allowed_html['msup'] = self::MATHML_GLOBAL_ATTRS;
539 $allowed_html['mtable'] = self::MATHML_GLOBAL_ATTRS;
540 $allowed_html['mtd'] = \array_merge(
541 self::MATHML_GLOBAL_ATTRS,
542 array(
543 'columnspan' => true,
544 'rowspan' => true,
545 )
546 );
547 $allowed_html['mtext'] = self::MATHML_GLOBAL_ATTRS;
548 $allowed_html['mtr'] = self::MATHML_GLOBAL_ATTRS;
549 $allowed_html['munder'] = self::MATHML_GLOBAL_ATTRS;
550 $allowed_html['munderover'] = \array_merge(
551 self::MATHML_GLOBAL_ATTRS,
552 array(
553 'accent' => true,
554 'accentunder' => true,
555 )
556 );
557 $allowed_html['semantics'] = \array_merge(
558 self::MATHML_GLOBAL_ATTRS,
559 array(
560 'encoding' => true,
561 )
562 );
563 $allowed_html['annotation'] = \array_merge(
564 self::MATHML_GLOBAL_ATTRS,
565 array(
566 'encoding' => true,
567 )
568 );
569 return $allowed_html;
570 }
571 }
572