Files
homepage.veen.world/app/static/js/modal.js
Kevin Veen-Birkenbach 2400cc2ea1 fix(csp): ship the interface strings as a JSON data block, not an inline script
base.html.j2 set window.I18N from an executable inline <script>. A host
CSP can only allow an inline script by hash or by 'unsafe-inline', and this
script's content changes with every language, so no hash can cover it.
Infinito.Nexus serves the dashboard with a hash-based script-src-elem
whenever its logout feature is off, and there the script was blocked:
every page logged "Executing inline script violates the following Content
Security Policy directive 'script-src-elem ...'" and window.I18N stayed
undefined.

The strings now ship as <script id="i18n" type="application/json">, which
the browser does not execute and CSP does not govern; modal.js parses the
block before its first use. tojson escapes <, > and &, so a string that
contains "</script>" cannot end the block early.

Integration tests require that a page ships no executable inline script
and that a catalogue string containing "</script>" survives the round trip
through the block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 23:25:51 +02:00

161 lines
5.3 KiB
JavaScript

const i18nBlock = document.getElementById('i18n');
window.I18N = i18nBlock ? JSON.parse(i18nBlock.textContent) : {};
function t(source) {
return (window.I18N || {})[source] || source;
}
const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:'];
function isSafeUrl(url) {
try {
const parsed = new URL(String(url == null ? '' : url), window.location.href);
return SAFE_URL_SCHEMES.includes(parsed.protocol);
} catch (error) {
return false;
}
}
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, '&lt;').replace(/>/g, '&gt;');
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');
modalTitle.replaceChildren(...iconAndName(subitem));
const identifierBox = document.getElementById('dynamicIdentifierBox');
const modalContent = document.getElementById('dynamicModalContent');
if (subitem.identifier) {
identifierBox.classList.remove('d-none');
modalContent.value = subitem.identifier;
} else {
identifierBox.classList.add('d-none');
modalContent.value = '';
}
function toggleBox(boxId, textId, content) {
const box = document.getElementById(boxId);
if (content) {
box.classList.remove('d-none');
document.getElementById(textId).innerHTML = renderMarkdown(content);
} else {
box.classList.add('d-none');
}
}
toggleBox('dynamicModalWarning', 'dynamicModalWarningText', subitem.warning);
toggleBox('dynamicModalInfo', 'dynamicModalInfoText', subitem.info);
const descriptionText = document.getElementById('dynamicDescriptionText');
if (!subitem.url && subitem.description) {
descriptionText.classList.remove('d-none');
descriptionText.innerText = subitem.description;
} else {
descriptionText.classList.add('d-none');
descriptionText.innerText = '';
}
const linkBox = document.getElementById('dynamicModalLink');
const linkHref = document.getElementById('dynamicModalLinkHref');
if (subitem.url) {
linkBox.classList.remove('d-none');
linkHref.href = subitem.url;
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');
linkHref.onclick = function(event) {
event.preventDefault();
openIframe(subitem.url);
closeAllModals();
};
}
} else {
linkBox.classList.add('d-none');
linkHref.href = '#';
}
function populateSection(sectionId, listId, items, onClickHandler) {
const section = document.getElementById(sectionId);
const list = document.getElementById(listId);
list.innerHTML = '';
if (items && items.length > 0) {
section.classList.remove('d-none');
items.forEach(item => {
const listItem = document.createElement('li');
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center');
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 {
section.classList.add('d-none');
}
}
populateSection('dynamicAlternativesSection', 'dynamicAlternativesList', subitem.alternatives, openDynamicPopup);
populateSection('dynamicChildrenSection', 'dynamicChildrenList', subitem.children, openDynamicPopup);
const copyButton = document.getElementById('dynamicCopyButton');
copyButton.onclick = () => {
modalContent.select();
navigator.clipboard.writeText(modalContent.value).then(() => {
alert(t('Identifier copied to clipboard!'));
});
};
const modal = new bootstrap.Modal(document.getElementById('dynamicModal'));
modal.show();
}
function closeAllModals() {
const modals = document.querySelectorAll('.modal.show');
modals.forEach(modal => {
const modalInstance = bootstrap.Modal.getInstance(modal);
if (modalInstance) {
modalInstance.hide();
}
});
const backdrops = document.querySelectorAll('.modal-backdrop');
backdrops.forEach(backdrop => backdrop.remove());
document.body.classList.remove('modal-open');
document.body.style.overflow = '';
document.body.style.paddingRight = '';
}