|
| 1 | +/** |
| 2 | + * Regression test for a Cross-Site Scripting (XSS) vulnerability that was introduced by |
| 3 | + * https://github.com/DSpace/dspace-angular/pull/4776. |
| 4 | + * |
| 5 | + * This test creates a new item submission whose abstract contains such a payload and verifies that: |
| 6 | + * - the payload is NOT executed (no JavaScript side effect happens), and |
| 7 | + * - the dangerous `onerror` attribute is stripped from the rendered markup (while safe, surrounding |
| 8 | + * markup/tags are preserved), |
| 9 | + * when the abstract is displayed via the `[dsMetadata]` directive. |
| 10 | + * |
| 11 | + */ |
| 12 | +describe('Metadata XSS sanitization', () => { |
| 13 | + // A classic XSS payload: an image with a broken `src` so that its `onerror` handler fires as soon as |
| 14 | + // the browser tries (and fails) to load it. If the payload is not sanitized, `onerror` will run and set |
| 15 | + // `window.dsXssExecuted = true`. (NOTE: This uses "role=presentation" to avoid failing accessibility checks) |
| 16 | + const XSS_PAYLOAD = 'XSS Test <img src="x" onerror="window.dsXssExecuted = true;" role="presentation"/>'; |
| 17 | + const SAFE_TEXT = 'XSS Test'; |
| 18 | + const UNIQUE_TITLE = `XSS sanitization test item ${Date.now()}`; |
| 19 | + |
| 20 | + /** |
| 21 | + * Asserts that the XSS payload has NOT executed on the current page. |
| 22 | + */ |
| 23 | + function assertXssDidNotExecute(): void { |
| 24 | + cy.window().then((win: any) => { |
| 25 | + expect(win.dsXssExecuted).to.not.equal(true); |
| 26 | + }); |
| 27 | + } |
| 28 | + |
| 29 | + it('should sanitize a malicious item abstract and not execute injected script when rendered via [dsMetadata]', () => { |
| 30 | + cy.visit('/mydspace'); |
| 31 | + |
| 32 | + // This page is restricted, so we will be shown the login form. Fill it out & submit. |
| 33 | + cy.env(['DSPACE_TEST_SUBMIT_USER', 'DSPACE_TEST_SUBMIT_USER_PASSWORD']).then(({ DSPACE_TEST_SUBMIT_USER, DSPACE_TEST_SUBMIT_USER_PASSWORD }) => { |
| 34 | + cy.loginViaForm(DSPACE_TEST_SUBMIT_USER, DSPACE_TEST_SUBMIT_USER_PASSWORD); |
| 35 | + }); |
| 36 | + |
| 37 | + // Start a submission |
| 38 | + cy.get('button[data-test="submission-dropdown"]').click(); |
| 39 | + cy.get('#entityControlsDropdownMenu button[title="none"]').click(); |
| 40 | + cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.expose('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); |
| 41 | + cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.expose('DSPACE_TEST_SUBMIT_COLLECTION_NAME')).concat('"]')).click(); |
| 42 | + |
| 43 | + // Give the item a unique (safe) title so we can reliably find it again afterward |
| 44 | + cy.get('#dc_title', { timeout: 10000 }).type(UNIQUE_TITLE); |
| 45 | + |
| 46 | + // Enter our malicious abstract into the dc.description.abstract field |
| 47 | + cy.get('#dc_description_abstract').type(XSS_PAYLOAD); |
| 48 | + |
| 49 | + // Save for Later to persist the (unsanitized, as stored) abstract on the workspace item |
| 50 | + cy.get('ds-submission-form-footer [data-test="save-for-later"]').click(); |
| 51 | + |
| 52 | + // "Save for Later" should send us to MyDSpace |
| 53 | + cy.url().should('include', '/mydspace'); |
| 54 | + // The malicious payload should NOT have executed while the submission form/footer rendered the abstract |
| 55 | + assertXssDidNotExecute(); |
| 56 | + |
| 57 | + // Close any open notifications, to make sure they don't get in the way of next steps |
| 58 | + cy.get('[data-bs-dismiss="alert"]').click({ multiple: true }); |
| 59 | + |
| 60 | + // Search for the item we just created via its unique title |
| 61 | + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); |
| 62 | + cy.get('[data-test="search-box"]').type(UNIQUE_TITLE); |
| 63 | + cy.get('[data-test="search-button"]').click(); |
| 64 | + cy.wait('@search-results'); |
| 65 | + |
| 66 | + // Find the specific result matching our unique title, and scope all further assertions to it. |
| 67 | + cy.contains('[data-test="list-object"]', UNIQUE_TITLE, { timeout: 10000 }) |
| 68 | + .should('exist') |
| 69 | + .as('result'); |
| 70 | + |
| 71 | + // The XSS payload must NOT have executed while the [dsMetadata] directive rendered the abstract |
| 72 | + assertXssDidNotExecute(); |
| 73 | + |
| 74 | + // The abstract should be rendered (via the [dsMetadata] directive) inside a truncatable part |
| 75 | + cy.get('@result').find('.item-list-abstract span').first().then(($abstract) => { |
| 76 | + // Sanitization removes *dangerous attributes* (like `onerror`), but it does NOT necessarily |
| 77 | + // remove the surrounding element itself (e.g. `<img>` is a permitted tag). So we assert that: |
| 78 | + // - the safe text content is still present, |
| 79 | + // - the `onerror` attribute is gone from the markup entirely, |
| 80 | + // - if the `<img>` tag survived sanitization, it has no `onerror` attribute on it. |
| 81 | + expect($abstract.text()).to.include(SAFE_TEXT); |
| 82 | + expect($abstract.html()).to.not.include('onerror'); |
| 83 | + |
| 84 | + const img = $abstract.find('img'); |
| 85 | + if (img.length > 0) { |
| 86 | + // eslint-disable-next-line no-unused-expressions,@typescript-eslint/no-unused-expressions |
| 87 | + expect(img.attr('onerror')).to.be.undefined; |
| 88 | + } |
| 89 | + }); |
| 90 | + }); |
| 91 | +}); |
| 92 | + |
0 commit comments