<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[51963] trunk: KSES: Add options for restricting tags based upon their attributes.</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { white-space: pre-line; overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="https://core.trac.wordpress.org/changeset/51963">51963</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"https://core.trac.wordpress.org/changeset/51963","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>pento</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2021-11-01 02:12:09 +0000 (Mon, 01 Nov 2021)</dd>
</dl>

<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>KSES: Add options for restricting tags based upon their attributes.

This change adds two now attribute-related config options to KSES:
- An array of allowed values can be defined for attributes. If the attribute value doesn't fall into the list, the attribute will be removed from the tag.
- Attributes can be marked as required. If a required attribute is not present, KSES will remove all attributes from the tag. As KSES doesn't match opening and closing tags, it's not possible to safely remove the tag itself, the safest fallback is to strip all attributes from the tag, instead.

Included with this change is an implementation of these options, allowing the `<object>` tag to be stored in posts, but only when it has a `type` attribute set to `application/pdf`.

Props pento, swissspidy, peterwilsoncc, dd32, jorbin.
Fixes <a href="https://core.trac.wordpress.org/ticket/54261">#54261</a>.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunksrcwpincludesksesphp">trunk/src/wp-includes/kses.php</a></li>
<li><a href="#trunktestsphpunittestsksesphp">trunk/tests/phpunit/tests/kses.php</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunksrcwpincludesksesphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-includes/kses.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/kses.php    2021-10-31 23:15:10 UTC (rev 51962)
+++ trunk/src/wp-includes/kses.php      2021-11-01 02:12:09 UTC (rev 51963)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -271,6 +271,13 @@
</span><span class="cx" style="display: block; padding: 0 10px">                        'lang'     => true,
</span><span class="cx" style="display: block; padding: 0 10px">                        'xml:lang' => true,
</span><span class="cx" style="display: block; padding: 0 10px">                ),
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                'object'     => array(
+                       'data' => true,
+                       'type' => array(
+                               'required' => true,
+                               'values'   => array( 'application/pdf' ),
+                       ),
+               ),
</ins><span class="cx" style="display: block; padding: 0 10px">                 'p'          => array(
</span><span class="cx" style="display: block; padding: 0 10px">                        'align'    => true,
</span><span class="cx" style="display: block; padding: 0 10px">                        'dir'      => true,
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -1165,15 +1172,47 @@
</span><span class="cx" style="display: block; padding: 0 10px">        // Split it.
</span><span class="cx" style="display: block; padding: 0 10px">        $attrarr = wp_kses_hair( $attr, $allowed_protocols );
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+        // Check if there are attributes that are required.
+       $required_attrs = array_filter(
+               $allowed_html[ $element_low ],
+               function( $required_attr_limits ) {
+                       return isset( $required_attr_limits['required'] ) && true === $required_attr_limits['required'];
+               }
+       );
+
+       // If a required attribute check fails, we can return nothing for a self-closing tag,
+       // but for a non-self-closing tag the best option is to return the element with attributes,
+       // as KSES doesn't handle matching the relevant closing tag.
+       $stripped_tag = '';
+       if ( empty( $xhtml_slash ) ) {
+               $stripped_tag = "<$element>";
+       }
+
</ins><span class="cx" style="display: block; padding: 0 10px">         // Go through $attrarr, and save the allowed attributes for this element
</span><span class="cx" style="display: block; padding: 0 10px">        // in $attr2.
</span><span class="cx" style="display: block; padding: 0 10px">        $attr2 = '';
</span><span class="cx" style="display: block; padding: 0 10px">        foreach ( $attrarr as $arreach ) {
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                // Check if this attribute is required.
+               $required = isset( $required_attrs[ strtolower( $arreach['name'] ) ] );
+
</ins><span class="cx" style="display: block; padding: 0 10px">                 if ( wp_kses_attr_check( $arreach['name'], $arreach['value'], $arreach['whole'], $arreach['vless'], $element, $allowed_html ) ) {
</span><span class="cx" style="display: block; padding: 0 10px">                        $attr2 .= ' ' . $arreach['whole'];
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+
+                       // If this was a required attribute, we can mark it as found.
+                       if ( $required ) {
+                               unset( $required_attrs[ strtolower( $arreach['name'] ) ] );
+                       }
+               } elseif ( $required ) {
+                       // This attribute was required, but didn't pass the check. The entire tag is not allowed.
+                       return $stripped_tag;
</ins><span class="cx" style="display: block; padding: 0 10px">                 }
</span><span class="cx" style="display: block; padding: 0 10px">        }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+        // If some required attributes weren't set, the entire tag is not allowed.
+       if ( ! empty( $required_attrs ) ) {
+               return $stripped_tag;
+       }
+
</ins><span class="cx" style="display: block; padding: 0 10px">         // Remove any "<" or ">" characters.
</span><span class="cx" style="display: block; padding: 0 10px">        $attr2 = preg_replace( '/[<>]/', '', $attr2 );
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -1600,6 +1639,17 @@
</span><span class="cx" style="display: block; padding: 0 10px">                                $ok = false;
</span><span class="cx" style="display: block; padding: 0 10px">                        }
</span><span class="cx" style="display: block; padding: 0 10px">                        break;
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+
+               case 'values':
+                       /*
+                        * The values check is used when you want to make sure that the attribute
+                        * has one of the given values.
+                        */
+
+                       if ( false === array_search( strtolower( $value ), $checkvalue, true ) ) {
+                               $ok = false;
+                       }
+                       break;
</ins><span class="cx" style="display: block; padding: 0 10px">         } // End switch.
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">        return $ok;
</span></span></pre></div>
<a id="trunktestsphpunittestsksesphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/tests/phpunit/tests/kses.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/kses.php        2021-10-31 23:15:10 UTC (rev 51962)
+++ trunk/tests/phpunit/tests/kses.php  2021-11-01 02:12:09 UTC (rev 51963)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -1496,4 +1496,298 @@
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                $this->assertSame( $html, wp_kses_post( $html ) );
</span><span class="cx" style="display: block; padding: 0 10px">        }
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+
+       /**
+        * Test that object tags are allowed under limited circumstances.
+        *
+        * @ticket 54261
+        *
+        * @dataProvider data_wp_kses_object_tag_allowed
+        *
+        * @param string $html     A string of HTML to test.
+        * @param string $expected The expected result from KSES.
+        */
+       function test_wp_kses_object_tag_allowed( $html, $expected ) {
+               $this->assertSame( $expected, wp_kses_post( $html ) );
+       }
+
+       /**
+        * Data provider for test_wp_kses_object_tag_allowed().
+        */
+       function data_wp_kses_object_tag_allowed() {
+               return array(
+                       'valid value for type'                    => array(
+                               '<object type="application/pdf" data="https://wordpress.org/foo.pdf" />',
+                               '<object type="application/pdf" data="https://wordpress.org/foo.pdf" />',
+                       ),
+                       'invalid value for type'                  => array(
+                               '<object type="application/exe" data="https://wordpress.org/foo.exe" />',
+                               '',
+                       ),
+                       'multiple type attributes, last invalid'  => array(
+                               '<object type="application/pdf" type="application/exe" data="https://wordpress.org/foo.pdf" />',
+                               '<object type="application/pdf" data="https://wordpress.org/foo.pdf" />',
+                       ),
+                       'multiple type attributes, first uppercase, last invalid' => array(
+                               '<object TYPE="application/pdf" type="application/exe" data="https://wordpress.org/foo.pdf" />',
+                               '<object TYPE="application/pdf" data="https://wordpress.org/foo.pdf" />',
+                       ),
+                       'multiple type attributes, last upper case and invalid' => array(
+                               '<object type="application/pdf" TYPE="application/exe" data="https://wordpress.org/foo.pdf" />',
+                               '<object type="application/pdf" data="https://wordpress.org/foo.pdf" />',
+                       ),
+                       'multiple type attributes, first invalid' => array(
+                               '<object type="application/exe" type="application/pdf" data="https://wordpress.org/foo.pdf" />',
+                               '',
+                       ),
+                       'multiple type attributes, first upper case and invalid' => array(
+                               '<object TYPE="application/exe" type="application/pdf" data="https://wordpress.org/foo.pdf" />',
+                               '',
+                       ),
+                       'multiple type attributes, first invalid, last uppercase' => array(
+                               '<object type="application/exe" TYPE="application/pdf" data="https://wordpress.org/foo.pdf" />',
+                               '',
+                       ),
+                       'multiple object tags, last invalid'      => array(
+                               '<object type="application/pdf" data="https://wordpress.org/foo.pdf" /><object type="application/exe" data="https://wordpress.org/foo.exe" />',
+                               '<object type="application/pdf" data="https://wordpress.org/foo.pdf" />',
+                       ),
+                       'multiple object tags, first invalid'     => array(
+                               '<object type="application/exe" data="https://wordpress.org/foo.exe" /><object type="application/pdf" data="https://wordpress.org/foo.pdf" />',
+                               '<object type="application/pdf" data="https://wordpress.org/foo.pdf" />',
+                       ),
+                       'type attribute with partially incorrect value' => array(
+                               '<object type="application/pdfa" data="https://wordpress.org/foo.pdf" />',
+                               '',
+                       ),
+                       'type attribute with empty value'         => array(
+                               '<object type="" data="https://wordpress.org/foo.pdf" />',
+                               '',
+                       ),
+                       'type attribute with no value'            => array(
+                               '<object type data="https://wordpress.org/foo.pdf" />',
+                               '',
+                       ),
+                       'no type attribute'                       => array(
+                               '<object data="https://wordpress.org/foo.pdf" />',
+                               '',
+                       ),
+               );
+       }
+
+       /**
+        * Test that object tags will continue to function if they've been added using the
+        * 'wp_kses_allowed_html' filter.
+        *
+        * @ticket 54261
+        */
+       function test_wp_kses_object_added_in_html_filter() {
+               $html = <<<HTML
+<object type="application/pdf" data="https://wordpress.org/foo.pdf" />
+<object type="application/x-shockwave-flash" data="https://wordpress.org/foo.swf">
+       <param name="foo" value="bar" />
+</object>
+HTML;
+
+               add_filter( 'wp_kses_allowed_html', array( $this, 'filter_wp_kses_object_added_in_html_filter' ), 10, 2 );
+
+               $filtered_html = wp_kses_post( $html );
+
+               remove_filter( 'wp_kses_allowed_html', array( $this, 'filter_wp_kses_object_added_in_html_filter' ) );
+
+               $this->assertSame( $html, $filtered_html );
+       }
+
+       function filter_wp_kses_object_added_in_html_filter( $tags, $context ) {
+               if ( 'post' === $context ) {
+                       $tags['object'] = array(
+                               'type' => true,
+                               'data' => true,
+                       );
+
+                       $tags['param'] = array(
+                               'name'  => true,
+                               'value' => true,
+                       );
+               }
+
+               return $tags;
+       }
+
+       /**
+        * Test that attributes with a list of allowed values are filtered correctly.
+        *
+        * @ticket 54261
+        *
+        * @dataProvider data_wp_kses_allowed_values_list
+        *
+        * @param string $html         A string of HTML to test.
+        * @param string $expected     The expected result from KSES.
+        * @param array  $allowed_html The allowed HTML to pass to KSES.
+        */
+       function test_wp_kses_allowed_values_list( $html, $expected, $allowed_html ) {
+               $this->assertSame( $expected, wp_kses( $html, $allowed_html ) );
+       }
+
+       /**
+        * Data provider for test_wp_kses_allowed_values_list().
+        */
+       function data_wp_kses_allowed_values_list() {
+               $data = array(
+                       'valid dir attribute value'             => array(
+                               '<p dir="ltr">foo</p>',
+                               '<p dir="ltr">foo</p>',
+                       ),
+                       'valid dir attribute value, upper case' => array(
+                               '<p DIR="RTL">foo</p>',
+                               '<p DIR="RTL">foo</p>',
+                       ),
+                       'invalid dir attribute value'           => array(
+                               '<p dir="up">foo</p>',
+                               '<p>foo</p>',
+                       ),
+                       'dir attribute with empty value'        => array(
+                               '<p dir="">foo</p>',
+                               '<p>foo</p>',
+                       ),
+                       'dir attribute with no value'           => array(
+                               '<p dir>foo</p>',
+                               '<p>foo</p>',
+                       ),
+               );
+
+               return array_map(
+                       function ( $datum ) {
+                               $datum[] = array(
+                                       'p' => array(
+                                               'dir' => array(
+                                                       'values' => array( 'ltr', 'rtl' ),
+                                               ),
+                                       ),
+                               );
+
+                               return $datum;
+                       },
+                       $data
+               );
+       }
+
+       /**
+        * Test that attributes with the required flag are handled correctly.
+        *
+        * @ticket 54261
+        *
+        * @dataProvider data_wp_kses_required_attribute
+        *
+        * @param string $html         A string of HTML to test.
+        * @param string $expected     The expected result from KSES.
+        * @param array  $allowed_html The allowed HTML to pass to KSES.
+        */
+       function test_wp_kses_required_attribute( $html, $expected, $allowed_html ) {
+               $this->assertSame( $expected, wp_kses( $html, $allowed_html ) );
+       }
+
+       /**
+        * Data provider for test_wp_kses_required_attribute().
+        */
+       function data_wp_kses_required_attribute() {
+               $data = array(
+                       'valid dir attribute value'             => array(
+                               '<p dir="ltr">foo</p>', // Test HTML.
+                               '<p dir="ltr">foo</p>', // Expected result when dir is not required.
+                               '<p dir="ltr">foo</p>', // Expected result when dir is required.
+                               '<p dir="ltr">foo</p>', // Expected result when dir is required, but has no value filter.
+                       ),
+                       'valid dir attribute value, upper case' => array(
+                               '<p DIR="RTL">foo</p>',
+                               '<p DIR="RTL">foo</p>',
+                               '<p DIR="RTL">foo</p>',
+                               '<p DIR="RTL">foo</p>',
+                       ),
+                       'invalid dir attribute value'           => array(
+                               '<p dir="up">foo</p>',
+                               '<p>foo</p>',
+                               '<p>foo</p>',
+                               '<p dir="up">foo</p>',
+                       ),
+                       'dir attribute with empty value'        => array(
+                               '<p dir="">foo</p>',
+                               '<p>foo</p>',
+                               '<p>foo</p>',
+                               '<p dir="">foo</p>',
+                       ),
+                       'dir attribute with no value'           => array(
+                               '<p dir>foo</p>',
+                               '<p>foo</p>',
+                               '<p>foo</p>',
+                               '<p dir>foo</p>',
+                       ),
+                       'dir attribute not set'                 => array(
+                               '<p>foo</p>',
+                               '<p>foo</p>',
+                               '<p>foo</p>',
+                               '<p>foo</p>',
+                       ),
+               );
+
+               $return_data = array();
+
+               foreach ( $data as $description => $datum ) {
+                       // Test that the required flag defaults to false.
+                       $return_data[ "$description - required flag not set" ] = array(
+                               $datum[0],
+                               $datum[1],
+                               array(
+                                       'p' => array(
+                                               'dir' => array(
+                                                       'values' => array( 'ltr', 'rtl' ),
+                                               ),
+                                       ),
+                               ),
+                       );
+
+                       // Test when the attribute is not required, but has allowed values.
+                       $return_data[ "$description - required flag set to false" ] = array(
+                               $datum[0],
+                               $datum[1],
+                               array(
+                                       'p' => array(
+                                               'dir' => array(
+                                                       'required' => false,
+                                                       'values'   => array( 'ltr', 'rtl' ),
+                                               ),
+                                       ),
+                               ),
+                       );
+
+                       // Test when the attribute is required, but has allowed values.
+                       $return_data[ "$description - required flag set to true" ] = array(
+                               $datum[0],
+                               $datum[2],
+                               array(
+                                       'p' => array(
+                                               'dir' => array(
+                                                       'required' => true,
+                                                       'values'   => array( 'ltr', 'rtl' ),
+                                               ),
+                                       ),
+                               ),
+                       );
+
+                       // Test when the attribute is required, but has no allowed values.
+                       $return_data[ "$description - required flag set to true, no allowed values specified" ] = array(
+                               $datum[0],
+                               $datum[3],
+                               array(
+                                       'p' => array(
+                                               'dir' => array(
+                                                       'required' => true,
+                                               ),
+                                       ),
+                               ),
+                       );
+               }
+
+               return $return_data;
+       }
</ins><span class="cx" style="display: block; padding: 0 10px"> }
</span></span></pre>
</div>
</div>

</body>
</html>