<?php /** * @file * Tests for WYSIWYG module. */ /** * Test security aspects of the WYSIWYG module. */ class WysiwygXssTest extends DrupalWebTestCase { /** * The sample content to use in all tests. */ protected static $sampleContent = '<p style="color: red">Hello, Dumbo Octopus!</p><script>alert(0)</script><embed type="image/svg+xml" src="image.svg" />'; /** * The secured sample content to use in most tests. */ protected static $sampleContentSecured = '<p>Hello, Dumbo Octopus!</p>alert(0)'; /** * The secured sample content to use in tests when the <embed> tag is allowed. */ protected static $sampleContentSecuredEmbedAllowed = '<p>Hello, Dumbo Octopus!</p>alert(0)<embed type="image/svg+xml" src="image.svg" />'; /** * User with access to Restricted HTML text format without text editor. */ protected $untrustedUser; /** * User with access to Restricted HTML text format with text editor. */ protected $normalUser; /** * User with access to Restricted HTML text format, dangerous tags allowed * with text editor. */ protected $trustedUser; /** * User with access to all text formats and text editors. */ protected $privilegedUser; /** * Define this test class. */ public static function getinfo() { return array( 'name' => 'Wysiwyg XSS filtering', 'description' => 'Ensure Wysiwyg runs the configured filters', 'group' => 'wysiwyg', ); } /** * {@inheritdoc} */ public function setUp(array $modules = array()) { $modules[] = 'wysiwyg_test'; parent::setUp($modules); // Create 5 text formats, to cover all potential use cases: // 1. restricted_without_editor (untrusted: anonymous) // 2. restricted_with_editor (normal: authenticated) // 3. restricted_plus_dangerous_tag_with_editor (privileged: trusted) // 4. unrestricted_without_editor (privileged: admin) // 5. unrestricted_with_editor (privileged: admin) // With text formats 2, 3 and 5, we also associate a text editor that does // not guarantee XSS safety. "restricted" means the text format has XSS // filters on output, "unrestricted" means the opposite. $format = new stdClass(); $format->format = 'restricted_without_editor'; $format->name = 'Restricted HTML, without text editor'; $format->filters = array( 'filter_html' => array( 'status' => 1, 'settings' => array( 'allowed_html' => '<h2> <h3> <h4> <h5> <h6> <p> <br> <strong> <a>', 'filter_html_help' => 1, 'filter_html_nofollow' => 0, ), ), ); filter_format_save($format); $format = new stdClass(); $format->format = 'restricted_with_editor'; $format->name = 'Restricted HTML, with text editor'; $format->filters = array( 'filter_html' => array( 'status' => 1, 'settings' => array( 'allowed_html' => '<h2> <h3> <h4> <h5> <h6> <p> <br> <strong> <a>', 'filter_html_help' => 1, 'filter_html_nofollow' => 0, ), ), ); filter_format_save($format); db_insert('wysiwyg')->fields(array( 'format' => 'restricted_with_editor', 'editor' => 'unicorn', 'settings' => serialize(array( 'default' => 1, )), ))->execute(); $format = new stdClass(); $format->format = 'restricted_plus_dangerous_tag_with_editor'; $format->name = 'Restricted HTML, dangerous tag allowed, with text editor'; $format->filters = array( 'filter_html' => array( 'status' => 1, 'settings' => array( 'allowed_html' => '<h2> <h3> <h4> <h5> <h6> <p> <br> <strong> <a> <embed>', 'filter_html_help' => 1, 'filter_html_nofollow' => 0, ), ), ); filter_format_save($format); db_insert('wysiwyg')->fields(array( 'format' => 'restricted_plus_dangerous_tag_with_editor', 'editor' => 'unicorn', 'settings' => serialize(array( 'default' => 1, )), ))->execute(); $format = new stdClass(); $format->format = 'unrestricted_without_editor'; $format->name = 'Unrestricted HTML, without text editor'; $format->filters = array(); filter_format_save($format); $format = new stdClass(); $format->format = 'unrestricted_with_editor'; $format->name = 'Unrestricted HTML, with text editor'; $format->filters = array(); filter_format_save($format); db_insert('wysiwyg')->fields(array( 'format' => 'unrestricted_with_editor', 'editor' => 'unicorn', 'settings' => serialize(array( 'default' => 1, )), ))->execute(); filter_formats_reset(); wysiwyg_profile_cache_clear(); // Create node type. $this->drupalCreateContentType(array( 'type' => 'textblob', 'name' => 'Textblob', )); // Create 4 users, each with access to different text formats/editors: // - "untrusted": restricted_without_editor // - "normal": restricted_with_editor, // - "trusted": restricted_plus_dangerous_tag_with_editor // - "privileged": restricted_without_editor, restricted_with_editor, // restricted_plus_dangerous_tag_with_editor, // unrestricted_without_editor and unrestricted_with_editor. $this->untrustedUser = $this->drupalCreateUser(array( 'create textblob content', 'edit any textblob content', 'use text format restricted_without_editor', )); $this->normalUser = $this->drupalCreateUser(array( 'create textblob content', 'edit any textblob content', 'use text format restricted_with_editor', )); $this->trustedUser = $this->drupalCreateUser(array( 'create textblob content', 'edit any textblob content', 'use text format restricted_plus_dangerous_tag_with_editor', )); $this->privilegedUser = $this->drupalCreateUser(array( 'create textblob content', 'edit any textblob content', 'use text format restricted_without_editor', 'use text format restricted_with_editor', 'use text format restricted_plus_dangerous_tag_with_editor', 'use text format unrestricted_without_editor', 'use text format unrestricted_with_editor', )); // Create an "textblob" node for each possible text format, with the same // sample content, to do our tests on. $samples = array( array('author' => $this->untrustedUser->uid, 'format' => 'restricted_without_editor'), array('author' => $this->normalUser->uid, 'format' => 'restricted_with_editor'), array('author' => $this->trustedUser->uid, 'format' => 'restricted_plus_dangerous_tag_with_editor'), array('author' => $this->privilegedUser->uid, 'format' => 'unrestricted_without_editor'), array('author' => $this->privilegedUser->uid, 'format' => 'unrestricted_with_editor'), ); foreach ($samples as $sample) { $this->drupalCreateNode(array( 'type' => 'textblob', 'body' => array( LANGUAGE_NONE => array(array('value' => self::$sampleContent, 'format' => $sample['format'])), ), 'uid' => $sample['author'], )); } } /** * Test filtering on the initial content loaded into the form fields. */ public function testInitialSecurity() { $expected = array( array( 'node_id' => 1, 'format' => 'restricted_without_editor', // No text editor => no XSS filtering. 'value' => '', 'users' => array( $this->untrustedUser, $this->privilegedUser, ), ), array( 'node_id' => 2, 'format' => 'restricted_with_editor', // Text editor => XSS filtering. 'value' => self::$sampleContentSecured, 'users' => array( $this->normalUser, $this->privilegedUser, ), ), array( 'node_id' => 3, 'format' => 'restricted_plus_dangerous_tag_with_editor', // Text editor => XSS filtering. 'value' => self::$sampleContentSecuredEmbedAllowed, 'users' => array( $this->trustedUser, $this->privilegedUser, ), ), array( 'node_id' => 4, 'format' => 'unrestricted_without_editor', // No text editor => no XSS filtering. 'value' => '', 'users' => array( $this->privilegedUser, ), ), array( 'node_id' => 5, 'format' => 'unrestricted_with_editor', // Text editor, no security filter => no XSS filtering. 'value' => '', 'users' => array( $this->privilegedUser, ), ), ); // Log in as each user that may edit the content, and assert the value. foreach ($expected as $case) { foreach ($case['users'] as $account) { $this->pass(format_string('Scenario: sample %sample_id, %format.', array( '%sample_id' => $case['node_id'], '%format' => $case['format'], ))); $this->drupalLogin($account); $this->drupalGet('node/' . $case['node_id'] . '/edit'); $dom_node = $this->xpath('//textarea[@id="edit-body-und-0-value"]'); // Verify the filtered attribute value. This is different from D8 which // puts the original content in an attribute and filtered content as the // field value. Wysiwyg does the opposite to not unintentionally modify // the data if JavaScript is not there to swap it back before a save. $this->assertIdentical($case['value'], (string) $dom_node[0]['data-wysiwyg-value-filtered'], 'The value was correctly filtered for XSS attack vectors.'); } } } /** * Tests administrator security: is the user safe when switching text formats? * * Tests 24 scenarios. Tests only with a text editor that is not XSS-safe. * * When changing from a more restrictive text format with a text editor (or a * text format without a text editor) to a less restrictive text format, it is * possible that a malicious user could trigger an XSS. * * E.g. when switching a piece of text that uses the Restricted HTML text * format and contains a <script> tag to the Full HTML text format, the * <script> tag would be executed. Unless we apply appropriate filtering. */ public function testSwitchingSecurity() { $expected = array( array( 'node_id' => 1, // No text editor => no XSS filtering. 'format' => 'restricted_without_editor', 'switch_to' => array( 'restricted_with_editor' => self::$sampleContentSecured, // Intersection of restrictions => most strict XSS filtering. 'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecured, // No text editor => no XSS filtering. 'unrestricted_without_editor' => FALSE, 'unrestricted_with_editor' => self::$sampleContentSecured, ), ), array( 'node_id' => 2, // Text editor => XSS filtering. 'format' => 'restricted_with_editor', 'switch_to' => array( // No text editor => no XSS filtering. 'restricted_without_editor' => FALSE, // Intersection of restrictions => most strict XSS filtering. 'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecured, // No text editor => no XSS filtering. 'unrestricted_without_editor' => FALSE, 'unrestricted_with_editor' => self::$sampleContentSecured, ), ), array( 'node_id' => 3, // Text editor => XSS filtering. 'format' => 'restricted_plus_dangerous_tag_with_editor', 'switch_to' => array( // No text editor => no XSS filtering. 'restricted_without_editor' => FALSE, // Intersection of restrictions => most strict XSS filtering. 'restricted_with_editor' => self::$sampleContentSecured, // No text editor => no XSS filtering. 'unrestricted_without_editor' => FALSE, // Intersection of restrictions => most strict XSS filtering. 'unrestricted_with_editor' => self::$sampleContentSecuredEmbedAllowed, ), ), array( 'node_id' => 4, // No text editor => no XSS filtering. 'format' => 'unrestricted_without_editor', 'switch_to' => array( // No text editor => no XSS filtering. 'restricted_without_editor' => FALSE, 'restricted_with_editor' => self::$sampleContentSecured, // Intersection of restrictions => most strict XSS filtering. 'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecuredEmbedAllowed, // From no editor, no security filters, to editor, still no security // filters: resulting content when viewed was already vulnerable, so // it must be intentional. 'unrestricted_with_editor' => FALSE, ), ), array( 'node_id' => 5, // Text editor => XSS filtering. 'format' => 'unrestricted_with_editor', 'switch_to' => array( // From editor, no security filters to security filters, no editor: no // risk. 'restricted_without_editor' => FALSE, 'restricted_with_editor' => self::$sampleContentSecured, // Intersection of restrictions => most strict XSS filtering. 'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecuredEmbedAllowed, // From no editor, no security filters, to editor, still no security // filters: resulting content when viewed was already vulnerable, so // it must be intentional. 'unrestricted_without_editor' => FALSE, ), ), ); // Log in as the privileged user, and for every sample, do the following: // - switch to every other text format/editor // - assert the XSS-filtered values that we get from the server $this->drupalLogin($this->privilegedUser); $token = $this->getToken(); foreach ($expected as $case) { $this->drupalGet('node/' . $case['node_id'] . '/edit'); // Verify the original value is intact. This is different from D8 which // puts the original content in an attribute and filtered content as the // field value. Wysiwyg does the opposite to not unintentionally modify // the data if JavaScript is not there to swap it back before a save. $dom_node = $this->xpath('//textarea[@id="edit-body-und-0-value"]'); $this->assertIdentical(self::$sampleContent, (string) $dom_node[0], 'The original value is intact.'); // Switch to every other text format/editor and verify the results. foreach ($case['switch_to'] as $format => $expected_filtered_value) { $this->pass(format_string('Scenario: sample %sample_id, switch from %original_format to %format.', array( '%sample_id' => $case['node_id'], '%original_format' => $case['format'], '%format' => $format, ))); $post = array( 'token' => $token, 'text' => self::$sampleContent, 'original_input_format' => $case['format'], 'input_format' => $format, ); $response = $this->simplePost('wysiwyg/xss', 'application/json', array('post' => $post)); $this->assertResponse(200); $json = drupal_json_decode($response); $this->assertIdentical($json, $expected_filtered_value, 'The value was correctly filtered for XSS attack vectors.'); } } } /** * Get the users token needed to post filtering requests. */ private function getToken() { return $this->simplePost('wysiwyg-test-get-token', 'text/plain'); } /** * Make a simple Curl request to a path. * * Defaults to a simple GET request, returns the raw response body. * * @param $path * The path to request. * @param $accept * The content-type to accept, defaults to 'application/json'. * @param $options * An array with the following keys, all optional: * - 'curl': An associative array keyed by CURLOPT_* constants and values. * - 'headers': An associative array with HTTP header keys and values. * - 'post': An associative array with POST payload data as keys and values. * Setting this automatically adds the headers to switch to a POST * request with Content-Type 'application/x-www-form-urlencoded' as it * auto-encodes all POST keys and values. * * @return string * The response contents. */ protected function simplePost($path, $accept = 'application/json', $options = array()) { $curl_options = (!empty($options['curl']) ? $options['curl'] : array()) + array( CURLOPT_URL => url($path, array('absolute' => TRUE)), ); $headers = (!empty($options['headers']) ? $options['headers'] : array()); if (!empty($options['post'])) { $post = array(); foreach ($options['post'] as $key => $value) { $post[$key] = urlencode($key) . '=' . urlencode($value); } $post = implode('&', $post); $curl_options = array( CURLOPT_POST => TRUE, CURLOPT_POSTFIELDS => $post, ) + $curl_options; $headers['Content-Type'] = 'application/x-www-form-urlencoded'; } foreach ($headers as $name => $value) { $curl_options[CURLOPT_HTTPHEADER][] = $name . ': ' . $value; } return $this->curlExec($curl_options); } }