diff --git a/app/cypress/e2e/injection.spec.js b/app/cypress/e2e/injection.spec.js new file mode 100644 index 0000000..85782f8 --- /dev/null +++ b/app/cypress/e2e/injection.spec.js @@ -0,0 +1,197 @@ +// cypress/e2e/injection.spec.js + +describe('Untrusted content in the modal', () => { + const base = { + name: 'Test Item', + identifier: 'ABC123', + icon: { class: 'fa fa-test' }, + }; + + beforeEach(() => { + cy.visit('/'); + cy.window().then(win => { + cy.stub(win.navigator.clipboard, 'writeText').resolves(); + cy.stub(win, 'alert'); + }); + }); + + function open(item = {}) { + cy.window().invoke('openDynamicPopup', { ...base, ...item }); + } + + describe('markdown rendered into innerHTML', () => { + it('strips a plain script URL', () => { + open({ + warning: '[click me](javascript:window.__xss = true)', + info: '![x](data:text/html;base64,PHNjcmlwdD4=)', + }); + + cy.get('#dynamicModalWarningText').find('a').should('not.exist'); + cy.get('#dynamicModalWarningText').should('contain.text', 'click me'); + cy.get('#dynamicModalInfoText').find('img').should('not.exist'); + cy.window().should('not.have.property', '__xss'); + }); + + it('strips a script URL hidden behind character references', () => { + open({ + warning: + '[a](javascript:window.__xss=1) [b](javascript:window.__xss=1)', + info: '[c](java script:window.__xss=1) [d](java script:window.__xss=1)', + }); + + cy.get('#dynamicModalWarningText').find('a').should('not.exist'); + cy.get('#dynamicModalInfoText').find('a').should('not.exist'); + cy.window().should('not.have.property', '__xss'); + }); + + it('strips a script URL written as a reference-style link', () => { + open({ + warning: '[click me][ref]\n\n[ref]: javascript:window.__xss=1', + }); + + cy.get('#dynamicModalWarningText').find('a').should('not.exist'); + cy.window().should('not.have.property', '__xss'); + }); + + it('neutralises raw HTML', () => { + open({ + warning: '', + info: 'x', + }); + + cy.get('#dynamicModalWarningText').find('img').should('not.exist'); + cy.get('#dynamicModalWarningText').should('contain.text', 'onerror'); + cy.get('#dynamicModalInfoText').find('a').should('not.exist'); + cy.window().should('not.have.property', '__xss'); + }); + + it('neutralises raw HTML used as the text of a stripped link', () => { + open({ warning: '[](javascript:bad)' }); + + cy.get('#dynamicModalWarningText').find('img').should('not.exist'); + cy.window().should('not.have.property', '__xss'); + }); + + it('keeps ordinary markdown', () => { + open({ warning: 'See [Matrix](https://matrix.org/) and **mind** this' }); + + cy.get('#dynamicModalWarningText') + .find('a') + .should('have.attr', 'href', 'https://matrix.org/'); + cy.get('#dynamicModalWarningText').find('strong').should('have.text', 'mind'); + }); + }); + + describe('values interpolated outside markdown', () => { + it('does not treat the name or the icon class as markup', () => { + open({ + name: '', + icon: { class: 'fa" onmouseover="window.__xss = true' }, + alternatives: [ + { + name: '', + identifier: 'ALT1', + icon: { class: 'fa-alt' }, + }, + ], + }); + + cy.get('#dynamicModalLabel').find('img').should('not.exist'); + cy.get('#dynamicModalLabel').should('contain.text', 'onerror'); + cy.get('#dynamicAlternativesList').find('img').should('not.exist'); + cy.get('#dynamicAlternativesList').should('contain.text', 'onerror'); + cy.window().should('not.have.property', '__xss'); + }); + }); + + describe('the link the modal offers', () => { + it('drops a URL that uses an unsafe scheme', () => { + open({ url: 'javascript:window.__xss = true', description: 'Bad' }); + + cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href'); + cy.get('#dynamicModalLinkHref').should('have.text', 'Bad'); + cy.window().should('not.have.property', '__xss'); + }); + + it('keeps an ordinary URL', () => { + open({ url: 'https://example.com', description: 'Good' }); + + cy.get('#dynamicModalLinkHref').should( + 'have.attr', + 'href', + 'https://example.com', + ); + }); + + it('keeps a mailto URL', () => { + open({ url: 'mailto:kevin@veen.world', description: 'Write' }); + + cy.get('#dynamicModalLinkHref').should( + 'have.attr', + 'href', + 'mailto:kevin@veen.world', + ); + }); + + it('restores the link after a popup whose URL was dropped', () => { + open({ url: 'javascript:window.__xss = true', description: 'Bad' }); + cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href'); + + open({ url: 'https://example.com', description: 'Good' }); + cy.get('#dynamicModalLinkHref').should( + 'have.attr', + 'href', + 'https://example.com', + ); + }); + + it('does not let one popup iframe handler outlive it', () => { + open({ url: 'https://a.test/', description: 'A', iframe: true }); + cy.get('#dynamicModalLinkHref').should('have.class', 'iframe'); + + open({ url: 'https://b.test/', description: 'B' }); + + cy.get('#dynamicModalLinkHref').should('not.have.class', 'iframe'); + cy.get('#dynamicModalLinkHref').should($anchor => { + expect($anchor[0].onclick, 'stale click handler').to.equal(null); + }); + }); + }); +}); + +describe('Untrusted content reaching the iframe', () => { + const AFTER_THE_FADE = 3000; + + it('refuses to open a script URL handed over by the modal', () => { + cy.visit('/'); + cy.window().invoke('openDynamicPopup', { + name: 'Bad', + icon: { class: 'fa fa-test' }, + url: 'javascript:window.__xss = true', + description: 'Watch', + iframe: true, + }); + + cy.get('#dynamicModalLinkHref').click({ force: true }); + + cy.wait(AFTER_THE_FADE); + cy.get('#main').find('iframe').should('not.exist'); + cy.window().should('not.have.property', '__xss'); + }); + + it('refuses a script URL supplied through the query string', () => { + cy.visit('/?iframe=javascript:window.__xss%20%3D%20true'); + + cy.wait(AFTER_THE_FADE); + cy.get('#main').find('iframe').should('not.exist'); + cy.window().should('not.have.property', '__xss'); + }); + + it('still opens an ordinary URL from the query string', () => { + cy.visit('/?iframe=https://example.com/'); + + cy.get('#main') + .find('iframe', { timeout: AFTER_THE_FADE }) + .should('have.attr', 'src', 'https://example.com/'); + }); +}); diff --git a/app/static/js/iframe.js b/app/static/js/iframe.js index aa19bae..1895ee5 100644 --- a/app/static/js/iframe.js +++ b/app/static/js/iframe.js @@ -34,6 +34,10 @@ function syncIframeHeight() { // Function to open a URL in an iframe (jQuery version mit 1500 ms Fade) function openIframe(url) { + if (!isSafeUrl(url)) { + return; + } + var $container = scrollbarContainer ? $(scrollbarContainer) : null; var $customScroll = customScrollbar ? $(customScrollbar) : null; var $main = $(mainElement); diff --git a/app/static/js/modal.js b/app/static/js/modal.js index 9109f45..ed77827 100644 --- a/app/static/js/modal.js +++ b/app/static/js/modal.js @@ -1,11 +1,48 @@ +function t(source) { + return (window.I18N || {})[source] || source; +} + +const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:']; + +function isSafeUrl(url) { + const probe = document.createElement('a'); + probe.href = String(url == null ? '' : url); + return SAFE_URL_SCHEMES.includes(probe.protocol); +} + +function iconAndName(item) { + const nodes = []; + if (item.icon && item.icon.class) { + const icon = document.createElement('i'); + icon.className = item.icon.class; + nodes.push(icon, document.createTextNode(' ')); + } + nodes.push(document.createTextNode(item.name == null ? '' : item.name)); + return nodes; +} + +function renderMarkdown(content) { + const escaped = String(content).replace(//g, '>'); + const parsed = new DOMParser().parseFromString(marked.parse(escaped), 'text/html'); + + parsed.querySelectorAll('a[href]').forEach((anchor) => { + if (!SAFE_URL_SCHEMES.includes(anchor.protocol)) { + anchor.replaceWith(...anchor.childNodes); + } + }); + parsed.querySelectorAll('img[src]').forEach((image) => { + if (!SAFE_URL_SCHEMES.includes(image.protocol)) { + image.replaceWith(image.alt || ''); + } + }); + + return parsed.body.innerHTML; +} + function openDynamicPopup(subitem) { closeAllModals(); const modalTitle = document.getElementById('dynamicModalLabel'); - if (subitem.icon && subitem.icon.class) { - modalTitle.innerHTML = ` ${subitem.name}`; - } else { - modalTitle.innerText = subitem.name; - } + modalTitle.replaceChildren(...iconAndName(subitem)); const identifierBox = document.getElementById('dynamicIdentifierBox'); const modalContent = document.getElementById('dynamicModalContent'); @@ -21,7 +58,7 @@ function openDynamicPopup(subitem) { const box = document.getElementById(boxId); if (content) { box.classList.remove('d-none'); - document.getElementById(textId).innerHTML = marked.parse(content); + document.getElementById(textId).innerHTML = renderMarkdown(content); } else { box.classList.add('d-none'); } @@ -44,16 +81,19 @@ function openDynamicPopup(subitem) { if (subitem.url) { linkBox.classList.remove('d-none'); linkHref.href = subitem.url; - linkHref.innerText = subitem.description || "Open Link"; + if (!isSafeUrl(subitem.url)) { + linkHref.removeAttribute('href'); + } + linkHref.innerText = subitem.description || t("Open Link"); + linkHref.classList.remove('iframe'); + linkHref.onclick = null; if (subitem.iframe) { linkHref.classList.add('iframe'); - // Attach an event listener that prevents the default behavior and - // opens the URL in an iframe when clicked. - linkHref.addEventListener('click', function(event) { + linkHref.onclick = function(event) { event.preventDefault(); openIframe(subitem.url); - closeAllModals() - }); + closeAllModals(); + }; } } else { linkBox.classList.add('d-none'); @@ -69,13 +109,13 @@ function openDynamicPopup(subitem) { items.forEach(item => { const listItem = document.createElement('li'); listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center'); - listItem.innerHTML = ` - - ${item.name} - - - `; - listItem.querySelector('button').addEventListener('click', () => onClickHandler(item)); + const label = document.createElement('span'); + label.replaceChildren(...iconAndName(item)); + const button = document.createElement('button'); + button.className = 'btn btn-outline-secondary btn-sm'; + button.textContent = t('Open'); + listItem.replaceChildren(label, button); + button.addEventListener('click', () => onClickHandler(item)); list.appendChild(listItem); }); } else { @@ -90,7 +130,7 @@ function openDynamicPopup(subitem) { copyButton.onclick = () => { modalContent.select(); navigator.clipboard.writeText(modalContent.value).then(() => { - alert('Identifier copied to clipboard!'); + alert(t('Identifier copied to clipboard!')); }); };