Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.88% covered (danger)
36.88%
52 / 141
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Filter_Embedded_HTML_Objects
37.96% covered (danger)
37.96%
52 / 137
0.00% covered (danger)
0.00%
0 / 9
672.21
0.00% covered (danger)
0.00%
0 / 1
 sh_regexp_callback
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 filter
61.76% covered (warning)
61.76%
21 / 34
0.00% covered (danger)
0.00%
0 / 1
20.05
 regexp_entities
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 register
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 unregister
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 dispatch_entities
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 dispatch
21.95% covered (danger)
21.95%
9 / 41
0.00% covered (danger)
0.00%
0 / 1
154.40
 maybe_create_links
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
10.75
 get_attrs
71.43% covered (warning)
71.43%
20 / 28
0.00% covered (danger)
0.00%
0 / 1
12.33
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * The companion file to shortcodes.php
4 *
5 * This file contains the code that converts HTML embeds into shortcodes
6 * for when the user copy/pastes in HTML.
7 *
8 * @package automattic/jetpack
9 */
10
11if ( ! defined( 'ABSPATH' ) ) {
12    exit( 0 );
13}
14
15// Run these everywhere to preserve some content, even in cases of display for improper code saved to the DB.
16add_filter( 'pre_kses', array( 'Filter_Embedded_HTML_Objects', 'filter' ), 11 );
17add_filter( 'pre_kses', array( 'Filter_Embedded_HTML_Objects', 'maybe_create_links' ), 100 ); // See WPCom_Embed_Stats::init().
18
19/**
20 * Helper class for identifying and parsing known HTML embeds (iframe, object, embed, etc. elements), then converting them to shortcodes.
21 * For unknown HTML embeds, the class still tries to convert them to plain links so that at least something is preserved instead of having the entire element stripped by KSES.
22 *
23 * @since 4.5.0
24 */
25class Filter_Embedded_HTML_Objects {
26    /**
27     * Array of patterns to search for via strpos().
28     * Keys are patterns, values are callback functions that implement the HTML -> shortcode replacement.
29     * Patterns are matched against URLs (src or movie HTML attributes).
30     *
31     * @var array
32     */
33    public static $strpos_filters = array();
34    /**
35     * Array of patterns to search for via preg_match().
36     * Keys are patterns, values are callback functions that implement the HTML -> shortcode replacement.
37     * Patterns are matched against URLs (src or movie HTML attributes).
38     *
39     * @var array
40     */
41    public static $regexp_filters = array();
42    /**
43     * HTML element being processed.
44     *
45     * @var string
46     */
47    public static $current_element = false;
48    /**
49     * Array of patterns to search for via strpos().
50     * Keys are patterns, values are callback functions that implement the HTML -> shortcode replacement.
51     * Patterns are matched against full HTML elements.
52     *
53     * @var array
54     */
55    public static $html_strpos_filters = array();
56    /**
57     * Array of patterns to search for via preg_match().
58     * Keys are patterns, values are callback functions that implement the HTML -> shortcode replacement.
59     * Patterns are matched against full HTML elements.
60     *
61     * @var array
62     */
63    public static $html_regexp_filters = array();
64    /**
65     * Failed embeds (stripped)
66     *
67     * @var array
68     */
69    public static $failed_embeds = array();
70
71    /**
72     * Store tokens found in Syntax Highlighter.
73     *
74     * @since 4.5.0
75     *
76     * @var array
77     */
78    private static $sh_unfiltered_content_tokens;
79
80    /**
81     * Capture tokens found in Syntax Highlighter and collect them in self::$sh_unfiltered_content_tokens.
82     *
83     * @since 4.5.0
84     *
85     * @param array $match Array of Syntax Highlighter matches.
86     *
87     * @return string
88     */
89    public static function sh_regexp_callback( $match ) {
90        $token                                        = sprintf(
91            '[prekses-filter-token-%1$d-%2$s-%1$d]',
92            wp_rand(),
93            md5( $match[0] )
94        );
95        self::$sh_unfiltered_content_tokens[ $token ] = $match[0];
96        return $token;
97    }
98
99    /**
100     * Look for HTML elements that match the registered patterns.
101     * Replace them with the HTML generated by the registered replacement callbacks.
102     *
103     * @param string $html Post content.
104     */
105    public static function filter( $html ) {
106        if ( ! $html || ! is_string( $html ) ) {
107            return $html;
108        }
109
110        $regexps = array(
111            'object' => '%<object[^>]*+>(?>[^<]*+(?><(?!/object>)[^<]*+)*)</object>%i',
112            'embed'  => '%<embed[^>]*+>(?:\s*</embed>)?%i',
113            'iframe' => '%<iframe[^>]*+>(?>[^<]*+(?><(?!/iframe>)[^<]*+)*)</iframe>%i',
114            'div'    => '%<div[^>]*+>(?>[^<]*+(?><(?!/div>)[^<]*+)*+)(?:</div>)+%i',
115            'script' => '%<script[^>]*+>(?>[^<]*+(?><(?!/script>)[^<]*+)*)</script>%i',
116        );
117
118        $unfiltered_content_tokens          = array();
119        self::$sh_unfiltered_content_tokens = array();
120
121        // Check here to make sure that SyntaxHighlighter is still used. (Just a little future proofing).
122        if ( class_exists( 'SyntaxHighlighter' ) ) {
123            /*
124             * Replace any "code" shortcode blocks with a token that we'll later replace with its original text.
125             * This will keep the contents of the shortcode from being filtered.
126             */
127            global $SyntaxHighlighter; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
128
129            // Check to see if the $syntax_highlighter object has been created and is ready for use.
130            if ( isset( $SyntaxHighlighter ) && is_array( $SyntaxHighlighter->shortcodes ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
131                $shortcode_regex           = implode( '|', array_map( 'preg_quote', $SyntaxHighlighter->shortcodes ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
132                $html                      = preg_replace_callback(
133                    '/\[(' . $shortcode_regex . ')(\s[^\]]*)?\][\s\S]*?\[\/\1\]/m',
134                    array( __CLASS__, 'sh_regexp_callback' ),
135                    $html
136                );
137                $unfiltered_content_tokens = self::$sh_unfiltered_content_tokens;
138            }
139        }
140
141        foreach ( $regexps as $element => $regexp ) {
142            self::$current_element = $element;
143
144            if ( false !== stripos( $html, "<$element" ) ) {
145                $new_html = preg_replace_callback( $regexp, array( __CLASS__, 'dispatch' ), $html );
146                if ( $new_html ) {
147                    $html = $new_html;
148                }
149            }
150
151            if ( false !== stripos( $html, "&lt;$element" ) ) {
152                $regexp_entities = self::regexp_entities( $regexp );
153                $new_html        = preg_replace_callback( $regexp_entities, array( __CLASS__, 'dispatch_entities' ), $html );
154                if ( $new_html ) {
155                    $html = $new_html;
156                }
157            }
158        }
159
160        if ( $unfiltered_content_tokens !== array() ) {
161            // Replace any tokens generated earlier with their original unfiltered text.
162            $html = str_replace( array_keys( $unfiltered_content_tokens ), $unfiltered_content_tokens, $html );
163        }
164
165        return $html;
166    }
167
168    /**
169     * Replace HTML entities in current HTML element regexp.
170     * This is useful when the content is HTML encoded by TinyMCE.
171     *
172     * @param string $regexp Selected regexp.
173     */
174    public static function regexp_entities( $regexp ) {
175        return preg_replace(
176            '/\[\^&([^\]]+)\]\*\+/',
177            '(?>[^&]*+(?>&(?!\1)[^&])*+)*+',
178            str_replace( '?&gt;', '?' . '>', htmlspecialchars( $regexp, ENT_NOQUOTES ) )
179        );
180    }
181
182    /**
183     * Register a filter to convert a matching HTML element to a shortcode.
184     *
185     * We can match the provided pattern against the source URL of the HTML element
186     * (generally the value of the src attribute of the HTML element), or against the full HTML element.
187     *
188     * The callback is passed an array containing the raw HTML of the element as well as pre-parsed attribute name/values.
189     *
190     * @param string $match          Pattern to search for: either a regular expression to use with preg_match() or a search string to use with strpos().
191     * @param string $callback       Function used to convert embed into shortcode.
192     * @param bool   $is_regexp      Is $match a regular expression? If true, match using preg_match(). If not, match using strpos(). Default false.
193     * @param bool   $is_html_filter Match the pattern against the full HTML (true) or just the source URL (false)? Default false.
194     */
195    public static function register( $match, $callback, $is_regexp = false, $is_html_filter = false ) {
196        if ( $is_html_filter ) {
197            if ( $is_regexp ) {
198                self::$html_regexp_filters[ $match ] = $callback;
199            } else {
200                self::$html_strpos_filters[ $match ] = $callback;
201            }
202        } elseif ( $is_regexp ) {
203            self::$regexp_filters[ $match ] = $callback;
204        } else {
205            self::$strpos_filters[ $match ] = $callback;
206        }
207    }
208
209    /**
210     * Delete an existing registered pattern/replacement filter.
211     *
212     * @param string $match Embed regexp.
213     */
214    public static function unregister( $match ) {
215        // Allow themes/plugins to remove registered embeds.
216        unset( self::$regexp_filters[ $match ] );
217        unset( self::$strpos_filters[ $match ] );
218        unset( self::$html_regexp_filters[ $match ] );
219        unset( self::$html_strpos_filters[ $match ] );
220    }
221
222    /**
223     * Filter and replace HTML element entity.
224     *
225     * @param array $matches Array of matches.
226     */
227    private static function dispatch_entities( $matches ) {
228        $orig_html       = $matches[0];
229        $decoded_matches = array( html_entity_decode( $matches[0], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) );
230
231        return self::dispatch( $decoded_matches, $orig_html );
232    }
233
234    /**
235     * Filter and replace HTML element.
236     *
237     * @param array  $matches Array of matches.
238     * @param string $orig_html Original html. Returned if no results are found via $matches processing.
239     */
240    private static function dispatch( $matches, $orig_html = null ) {
241        if ( null === $orig_html ) {
242            $orig_html = $matches[0];
243        }
244        $html  = preg_replace( '%&#0*58;//%', '://', $matches[0] );
245        $attrs = self::get_attrs( $html );
246        if ( isset( $attrs['src'] ) ) {
247            $src = $attrs['src'];
248        } elseif ( isset( $attrs['movie'] ) ) {
249            $src = $attrs['movie'];
250        } else {
251            // no src found, search html.
252            foreach ( self::$html_strpos_filters as $match => $callback ) {
253                if ( str_contains( $html, $match ) ) {
254                    return call_user_func( $callback, $attrs );
255                }
256            }
257
258            foreach ( self::$html_regexp_filters as $match => $callback ) {
259                if ( preg_match( $match, $html ) ) {
260                    return call_user_func( $callback, $attrs );
261                }
262            }
263
264            return $orig_html;
265        }
266
267        $src = trim( $src );
268
269        // check source filter.
270        foreach ( self::$strpos_filters as $match => $callback ) {
271            if ( str_contains( $src, $match ) ) {
272                return call_user_func( $callback, $attrs );
273            }
274        }
275
276        foreach ( self::$regexp_filters as $match => $callback ) {
277            if ( preg_match( $match, $src ) ) {
278                return call_user_func( $callback, $attrs );
279            }
280        }
281
282        // check html filters.
283        foreach ( self::$html_strpos_filters as $match => $callback ) {
284            if ( str_contains( $html, $match ) ) {
285                return call_user_func( $callback, $attrs );
286            }
287        }
288
289        foreach ( self::$html_regexp_filters as $match => $callback ) {
290            if ( preg_match( $match, $html ) ) {
291                return call_user_func( $callback, $attrs );
292            }
293        }
294
295        // Log the strip.
296        if ( function_exists( 'wp_kses_reject' ) ) {
297            wp_kses_reject(
298                sprintf(
299                    /* translators: placeholder is an HTML tag. */
300                    __( '<code>%s</code> HTML tag removed as it is not allowed', 'jetpack' ),
301                    '&lt;' . self::$current_element . '&gt;'
302                ),
303                array( self::$current_element => $attrs )
304            );
305        }
306
307        // Keep the failed match so we can later replace it with a link,
308        // but return the original content to give others a chance too.
309        self::$failed_embeds[] = array(
310            'match' => $orig_html,
311            'src'   => esc_url( $src ),
312        );
313
314        return $orig_html;
315    }
316
317    /**
318     * Failed embeds are stripped, so let's convert them to links at least.
319     *
320     * @param string $string Failed embed string.
321     *
322     * @return string $string Linkified string.
323     */
324    public static function maybe_create_links( $string ) {
325        if ( empty( self::$failed_embeds ) ) {
326            return $string;
327        }
328
329        foreach ( self::$failed_embeds as $entry ) {
330            $html = sprintf( '<a href="%s">%s</a>', esc_url( $entry['src'] ), esc_url( $entry['src'] ) );
331            // Check if the string doesn't contain iframe, before replace.
332            if ( ! str_contains( $string, '<iframe ' ) ) {
333                $string = str_replace( $entry['match'], $html, $string );
334            }
335        }
336
337        self::$failed_embeds = array();
338
339        return $string;
340    }
341
342    /**
343     * Parse post HTML for HTML tags.
344     *
345     * @param string $html Post HTML.
346     */
347    public static function get_attrs( $html ) {
348        if (
349            ! ( class_exists( 'DOMDocument' ) && function_exists( 'simplexml_load_string' ) ) ) {
350            trigger_error( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
351                esc_html__( 'PHP’s XML extension is not available. Please contact your hosting provider to enable PHP’s XML extension.', 'jetpack' )
352            );
353            return array();
354        }
355        // We have to go through DOM, since it can load non-well-formed XML (i.e. HTML).  SimpleXML cannot.
356        $dom = new DOMDocument();
357        // The @ is not enough to suppress errors when dealing with libxml,
358        // we have to tell it directly how we want to handle errors.
359        libxml_use_internal_errors( true );
360        // Suppress parser warnings.
361        @$dom->loadHTML( $html ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
362        libxml_use_internal_errors( false );
363        $xml = false;
364        // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
365        foreach ( $dom->childNodes as $node ) {
366            // find the root node (html).
367            if ( XML_ELEMENT_NODE === $node->nodeType ) {
368                /*
369                 * Use simplexml_load_string rather than simplexml_import_dom
370                 * as the later doesn't cope well if the XML is malformmed in the DOM
371                 * See #1688-wpcom.
372                 */
373                libxml_use_internal_errors( true );
374                // html->body->object.
375                $xml = simplexml_load_string( $dom->saveXML( $node->firstChild->firstChild ) );
376                libxml_clear_errors();
377                break;
378            }
379        }
380        // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
381
382        if ( ! $xml ) {
383            return array();
384        }
385
386        $attrs              = array();
387        $attrs['_raw_html'] = $html;
388
389        // <param> elements
390        foreach ( $xml->param as $param ) {
391            $attrs[ (string) $param['name'] ] = (string) $param['value'];
392        }
393
394        // <object> attributes
395        foreach ( $xml->attributes() as $name => $attr ) {
396            $attrs[ $name ] = (string) $attr;
397        }
398
399        // <embed> attributes
400        if ( $xml->embed ) {
401            foreach ( $xml->embed->attributes() as $name => $attr ) {
402                $attrs[ $name ] = (string) $attr;
403            }
404        }
405
406        return $attrs;
407    }
408}