3 namespace Drupal\Tests\editor\Functional;
5 use Drupal\Component\Serialization\Json;
6 use Drupal\editor\Entity\Editor;
7 use Drupal\filter\Entity\FilterFormat;
8 use Drupal\Tests\BrowserTestBase;
11 * Tests XSS protection for content creators when using text editors.
15 class EditorSecurityTest extends BrowserTestBase {
18 * The sample content to use in all tests.
22 protected static $sampleContent = '<p style="color: red">Hello, Dumbo Octopus!</p><script>alert(0)</script><embed type="image/svg+xml" src="image.svg" />';
25 * The secured sample content to use in most tests.
29 protected static $sampleContentSecured = '<p>Hello, Dumbo Octopus!</p>alert(0)';
32 * The secured sample content to use in tests when the <embed> tag is allowed.
36 protected static $sampleContentSecuredEmbedAllowed = '<p>Hello, Dumbo Octopus!</p>alert(0)<embed type="image/svg+xml" src="image.svg" />';
43 public static $modules = ['filter', 'editor', 'editor_test', 'node'];
46 * User with access to Restricted HTML text format without text editor.
48 * @var \Drupal\user\UserInterface
50 protected $untrustedUser;
53 * User with access to Restricted HTML text format with text editor.
55 * @var \Drupal\user\UserInterface
57 protected $normalUser;
60 * User with access to Restricted HTML text format, dangerous tags allowed
63 * @var \Drupal\user\UserInterface
65 protected $trustedUser;
68 * User with access to all text formats and text editors.
70 * @var \Drupal\user\UserInterface
72 protected $privilegedUser;
74 protected function setUp() {
77 // Create 5 text formats, to cover all potential use cases:
78 // 1. restricted_without_editor (untrusted: anonymous)
79 // 2. restricted_with_editor (normal: authenticated)
80 // 3. restricted_plus_dangerous_tag_with_editor (privileged: trusted)
81 // 4. unrestricted_without_editor (privileged: admin)
82 // 5. unrestricted_with_editor (privileged: admin)
83 // With text formats 2, 3 and 5, we also associate a text editor that does
84 // not guarantee XSS safety. "restricted" means the text format has XSS
85 // filters on output, "unrestricted" means the opposite.
86 $format = FilterFormat::create([
87 'format' => 'restricted_without_editor',
88 'name' => 'Restricted HTML, without text editor',
91 // A filter of the FilterInterface::TYPE_HTML_RESTRICTOR type.
95 'allowed_html' => '<h2> <h3> <h4> <h5> <h6> <p> <br> <strong> <a>',
101 $format = FilterFormat::create([
102 'format' => 'restricted_with_editor',
103 'name' => 'Restricted HTML, with text editor',
106 // A filter of the FilterInterface::TYPE_HTML_RESTRICTOR type.
110 'allowed_html' => '<h2> <h3> <h4> <h5> <h6> <p> <br> <strong> <a>',
116 $editor = Editor::create([
117 'format' => 'restricted_with_editor',
118 'editor' => 'unicorn',
121 $format = FilterFormat::create([
122 'format' => 'restricted_plus_dangerous_tag_with_editor',
123 'name' => 'Restricted HTML, dangerous tag allowed, with text editor',
126 // A filter of the FilterInterface::TYPE_HTML_RESTRICTOR type.
130 'allowed_html' => '<h2> <h3> <h4> <h5> <h6> <p> <br> <strong> <a> <embed>',
136 $editor = Editor::create([
137 'format' => 'restricted_plus_dangerous_tag_with_editor',
138 'editor' => 'unicorn',
141 $format = FilterFormat::create([
142 'format' => 'unrestricted_without_editor',
143 'name' => 'Unrestricted HTML, without text editor',
148 $format = FilterFormat::create([
149 'format' => 'unrestricted_with_editor',
150 'name' => 'Unrestricted HTML, with text editor',
155 $editor = Editor::create([
156 'format' => 'unrestricted_with_editor',
157 'editor' => 'unicorn',
162 $this->drupalCreateContentType([
167 // Create 4 users, each with access to different text formats/editors:
168 // - "untrusted": restricted_without_editor
169 // - "normal": restricted_with_editor,
170 // - "trusted": restricted_plus_dangerous_tag_with_editor
171 // - "privileged": restricted_without_editor, restricted_with_editor,
172 // restricted_plus_dangerous_tag_with_editor,
173 // unrestricted_without_editor and unrestricted_with_editor
174 $this->untrustedUser = $this->drupalCreateUser([
175 'create article content',
176 'edit any article content',
177 'use text format restricted_without_editor',
179 $this->normalUser = $this->drupalCreateUser([
180 'create article content',
181 'edit any article content',
182 'use text format restricted_with_editor',
184 $this->trustedUser = $this->drupalCreateUser([
185 'create article content',
186 'edit any article content',
187 'use text format restricted_plus_dangerous_tag_with_editor',
189 $this->privilegedUser = $this->drupalCreateUser([
190 'create article content',
191 'edit any article content',
192 'use text format restricted_without_editor',
193 'use text format restricted_with_editor',
194 'use text format restricted_plus_dangerous_tag_with_editor',
195 'use text format unrestricted_without_editor',
196 'use text format unrestricted_with_editor',
199 // Create an "article" node for each possible text format, with the same
200 // sample content, to do our tests on.
202 ['author' => $this->untrustedUser->id(), 'format' => 'restricted_without_editor'],
203 ['author' => $this->normalUser->id(), 'format' => 'restricted_with_editor'],
204 ['author' => $this->trustedUser->id(), 'format' => 'restricted_plus_dangerous_tag_with_editor'],
205 ['author' => $this->privilegedUser->id(), 'format' => 'unrestricted_without_editor'],
206 ['author' => $this->privilegedUser->id(), 'format' => 'unrestricted_with_editor'],
208 foreach ($samples as $sample) {
209 $this->drupalCreateNode([
212 ['value' => self::$sampleContent, 'format' => $sample['format']],
214 'uid' => $sample['author'],
220 * Tests initial security: is the user safe without switching text formats?
222 * Tests 8 scenarios. Tests only with a text editor that is not XSS-safe.
224 public function testInitialSecurity() {
228 'format' => 'restricted_without_editor',
229 // No text editor => no XSS filtering.
230 'value' => self::$sampleContent,
232 $this->untrustedUser,
233 $this->privilegedUser,
238 'format' => 'restricted_with_editor',
239 // Text editor => XSS filtering.
240 'value' => self::$sampleContentSecured,
243 $this->privilegedUser,
248 'format' => 'restricted_plus_dangerous_tag_with_editor',
249 // Text editor => XSS filtering.
250 'value' => self::$sampleContentSecuredEmbedAllowed,
253 $this->privilegedUser,
258 'format' => 'unrestricted_without_editor',
259 // No text editor => no XSS filtering.
260 'value' => self::$sampleContent,
262 $this->privilegedUser,
267 'format' => 'unrestricted_with_editor',
268 // Text editor, no security filter => no XSS filtering.
269 'value' => self::$sampleContent,
271 $this->privilegedUser,
276 // Log in as each user that may edit the content, and assert the value.
277 foreach ($expected as $case) {
278 foreach ($case['users'] as $account) {
279 $this->pass(format_string('Scenario: sample %sample_id, %format.', [
280 '%sample_id' => $case['node_id'],
281 '%format' => $case['format'],
283 $this->drupalLogin($account);
284 $this->drupalGet('node/' . $case['node_id'] . '/edit');
285 $dom_node = $this->xpath('//textarea[@id="edit-body-0-value"]');
286 $this->assertIdentical($case['value'], $dom_node[0]->getText(), 'The value was correctly filtered for XSS attack vectors.');
292 * Tests administrator security: is the user safe when switching text formats?
294 * Tests 24 scenarios. Tests only with a text editor that is not XSS-safe.
296 * When changing from a more restrictive text format with a text editor (or a
297 * text format without a text editor) to a less restrictive text format, it is
298 * possible that a malicious user could trigger an XSS.
300 * E.g. when switching a piece of text that uses the Restricted HTML text
301 * format and contains a <script> tag to the Full HTML text format, the
302 * <script> tag would be executed. Unless we apply appropriate filtering.
304 public function testSwitchingSecurity() {
308 // No text editor => no XSS filtering.
309 'value' => self::$sampleContent,
310 'format' => 'restricted_without_editor',
312 'restricted_with_editor' => self::$sampleContentSecured,
313 // Intersection of restrictions => most strict XSS filtering.
314 'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecured,
315 // No text editor => no XSS filtering.
316 'unrestricted_without_editor' => FALSE,
317 'unrestricted_with_editor' => self::$sampleContentSecured,
322 // Text editor => XSS filtering.
323 'value' => self::$sampleContentSecured,
324 'format' => 'restricted_with_editor',
326 // No text editor => no XSS filtering.
327 'restricted_without_editor' => FALSE,
328 // Intersection of restrictions => most strict XSS filtering.
329 'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecured,
330 // No text editor => no XSS filtering.
331 'unrestricted_without_editor' => FALSE,
332 'unrestricted_with_editor' => self::$sampleContentSecured,
337 // Text editor => XSS filtering.
338 'value' => self::$sampleContentSecuredEmbedAllowed,
339 'format' => 'restricted_plus_dangerous_tag_with_editor',
341 // No text editor => no XSS filtering.
342 'restricted_without_editor' => FALSE,
343 // Intersection of restrictions => most strict XSS filtering.
344 'restricted_with_editor' => self::$sampleContentSecured,
345 // No text editor => no XSS filtering.
346 'unrestricted_without_editor' => FALSE,
347 // Intersection of restrictions => most strict XSS filtering.
348 'unrestricted_with_editor' => self::$sampleContentSecured,
353 // No text editor => no XSS filtering.
354 'value' => self::$sampleContent,
355 'format' => 'unrestricted_without_editor',
357 // No text editor => no XSS filtering.
358 'restricted_without_editor' => FALSE,
359 'restricted_with_editor' => self::$sampleContentSecured,
360 // Intersection of restrictions => most strict XSS filtering.
361 'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecured,
362 // From no editor, no security filters, to editor, still no security
363 // filters: resulting content when viewed was already vulnerable, so
364 // it must be intentional.
365 'unrestricted_with_editor' => FALSE,
370 // Text editor => XSS filtering.
371 'value' => self::$sampleContentSecured,
372 'format' => 'unrestricted_with_editor',
374 // From editor, no security filters to security filters, no editor: no
376 'restricted_without_editor' => FALSE,
377 'restricted_with_editor' => self::$sampleContentSecured,
378 // Intersection of restrictions => most strict XSS filtering.
379 'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecured,
380 // From no editor, no security filters, to editor, still no security
381 // filters: resulting content when viewed was already vulnerable, so
382 // it must be intentional.
383 'unrestricted_without_editor' => FALSE,
388 // Log in as the privileged user, and for every sample, do the following:
389 // - switch to every other text format/editor
390 // - assert the XSS-filtered values that we get from the server
391 $this->drupalLogin($this->privilegedUser);
392 $cookies = $this->getSessionCookies();
394 foreach ($expected as $case) {
395 $this->drupalGet('node/' . $case['node_id'] . '/edit');
397 // Verify data- attributes.
398 $dom_node = $this->xpath('//textarea[@id="edit-body-0-value"]');
399 $this->assertIdentical(self::$sampleContent, $dom_node[0]->getAttribute('data-editor-value-original'), 'The data-editor-value-original attribute is correctly set.');
400 $this->assertIdentical('false', (string) $dom_node[0]->getAttribute('data-editor-value-is-changed'), 'The data-editor-value-is-changed attribute is correctly set.');
402 // Switch to every other text format/editor and verify the results.
403 foreach ($case['switch_to'] as $format => $expected_filtered_value) {
404 $this->pass(format_string('Scenario: sample %sample_id, switch from %original_format to %format.', [
405 '%sample_id' => $case['node_id'],
406 '%original_format' => $case['format'],
407 '%format' => $format,
411 'value' => self::$sampleContent,
412 'original_format_id' => $case['format'],
414 $client = $this->getHttpClient();
415 $response = $client->post($this->buildUrl('/editor/filter_xss/' . $format), [
416 'body' => http_build_query($post),
417 'cookies' => $cookies,
419 'Accept' => 'application/json',
420 'Content-Type' => 'application/x-www-form-urlencoded',
422 'http_errors' => FALSE,
425 $this->assertEquals(200, $response->getStatusCode());
427 $json = Json::decode($response->getBody());
428 $this->assertIdentical($json, $expected_filtered_value, 'The value was correctly filtered for XSS attack vectors.');
434 * Tests the standard text editor XSS filter being overridden.
436 public function testEditorXssFilterOverride() {
437 // First: the Standard text editor XSS filter.
438 $this->drupalLogin($this->normalUser);
439 $this->drupalGet('node/2/edit');
440 $dom_node = $this->xpath('//textarea[@id="edit-body-0-value"]');
441 $this->assertIdentical(self::$sampleContentSecured, $dom_node[0]->getText(), 'The value was filtered by the Standard text editor XSS filter.');
443 // Enable editor_test.module's hook_editor_xss_filter_alter() implementation
444 // to alter the text editor XSS filter class being used.
445 \Drupal::state()->set('editor_test_editor_xss_filter_alter_enabled', TRUE);
447 // First: the Insecure text editor XSS filter.
448 $this->drupalGet('node/2/edit');
449 $dom_node = $this->xpath('//textarea[@id="edit-body-0-value"]');
450 $this->assertIdentical(self::$sampleContent, $dom_node[0]->getText(), 'The value was filtered by the Insecure text editor XSS filter.');