mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-08-24 05:04:33 +00:00
fix(modal): stop untrusted content reaching innerHTML and the iframe
Every one of these paths checked a string that the browser reinterprets afterwards. isSafeUrl now hangs the value on an <a> and reads back probe.protocol, so the check sees what the browser will see: a pre-parse test reads "javascript:" as a relative path and passes it, and the HTML parser then decodes it to "javascript:". marked passes raw HTML through and emits hrefs unescaped. renderMarkdown escapes the angle brackets before parsing, parses into an inert DOMParser document where no script runs and no image loads, and drops anchors and images whose scheme is not http, https or mailto. Blockquotes and <autolinks> stop working as a result; neither appears in the configuration. modalTitle and the alternatives list interpolated subitem.name and icon.class into innerHTML. Both are built as nodes now. name is a translatable key, so it arrives from the machine-written catalogues. The link kept its click handler and its class across popups, because one anchor serves all of them: a later, unrelated click opened whatever an earlier popup pointed at, and addEventListener stacked one handler per open. Both are reset per popup and the handler is assigned, not added. openIframe guards its own argument. Removing the href alone left the handler passing the raw URL on, and ?iframe= in the query string reaches the same sink with no configuration involved at all. Verified in headless Chromium: decimal and hex character references, 	- and 
-split schemes, reference-style links, raw HTML as a link's text, and the two name sinks all executed before these changes. injection.spec.js keeps all fifteen payloads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
197
app/cypress/e2e/injection.spec.js
Normal file
197
app/cypress/e2e/injection.spec.js
Normal file
@@ -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: '<img src=x onerror="window.__xss = true">',
|
||||
info: '<a href="javascript:window.__xss = true">x</a>',
|
||||
});
|
||||
|
||||
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: '[<img src=x onerror="window.__xss = true">](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: '<img src=x onerror="window.__xss = true">',
|
||||
icon: { class: 'fa" onmouseover="window.__xss = true' },
|
||||
alternatives: [
|
||||
{
|
||||
name: '<img src=y onerror="window.__xss = true">',
|
||||
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/');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '<').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 = `<i class="${subitem.icon.class}"></i> ${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 = `
|
||||
<span>
|
||||
<i class="${item.icon.class}"></i> ${item.name}
|
||||
</span>
|
||||
<button class="btn btn-outline-secondary btn-sm">Open</button>
|
||||
`;
|
||||
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!'));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user