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: '',
+ });
+
+ 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!'));
});
};