fix(security): replace innerHTML with safe DOM APIs, add destroy() cleanup

Replace innerHTML assignments in app-tabs.js and app-password-toggle.js
with createElement/appendChild to prevent potential XSS vectors. Add
destroy() methods to app-tabs, app-filter-drawer, and
app-toggle-aside-panels so document/window-level listeners can be
properly removed when components are torn down.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 22:35:01 +01:00
parent 2b915f5a43
commit 35a36f1144
4 changed files with 59 additions and 26 deletions

View File

@@ -15,11 +15,13 @@
*/
export function initTabs(root = document) {
const containers = root.querySelectorAll('[data-tabs]');
const instances = [];
containers.forEach((container) => {
if (container.dataset.tabsBound === '1') {return;}
container.dataset.tabsBound = '1';
container.dataset.tabsReady = '0';
const cleanupFns = [];
const tabs = Array.from(container.querySelectorAll('[data-tab]'));
const panels = Array.from(container.querySelectorAll('[data-tab-panel]'));
@@ -218,7 +220,7 @@ export function initTabs(root = document) {
// Intercept submit buttons — both inside the form and external ones linked via form="id".
// We use document-level click (capture) so we catch ALL submit triggers before native validation.
document.addEventListener('click', (e) => {
const onFormClickCapture = (e) => {
const btn = e.target.closest('button, [type="submit"]');
if (!btn) {return;}
@@ -257,9 +259,9 @@ export function initTabs(root = document) {
requestAnimationFrame(() => {
form.reportValidity();
});
}, true);
};
form.addEventListener('input', (e) => {
const onFormInput = (e) => {
const panel = e.target.closest('[data-tab-panel]');
if (!panel) {return;}
const tabName = panel.dataset.tabPanel;
@@ -269,29 +271,41 @@ export function initTabs(root = document) {
if (Array.from(fields).every(f => !belongsToForm(f) || f.disabled || f.checkValidity())) {
tab.classList.remove('has-invalid');
}
});
};
form.addEventListener('change', (e) => {
const panel = e.target.closest('[data-tab-panel]');
if (!panel) {return;}
const tabName = panel.dataset.tabPanel;
const tab = tabs.find(t => t.dataset.tab === tabName);
if (!tab) {return;}
const fields = panel.querySelectorAll(inputSelector);
if (Array.from(fields).every(f => !belongsToForm(f) || f.disabled || f.checkValidity())) {
tab.classList.remove('has-invalid');
}
document.addEventListener('click', onFormClickCapture, true);
form.addEventListener('input', onFormInput);
form.addEventListener('change', onFormInput);
cleanupFns.push(() => {
document.removeEventListener('click', onFormClickCapture, true);
form.removeEventListener('input', onFormInput);
form.removeEventListener('change', onFormInput);
});
}
// Listen for popstate (back/forward navigation)
window.addEventListener('popstate', () => {
const onPopState = () => {
const newTab = getUrlParam(urlParamName);
if (newTab && panels.some(p => p.dataset.tabPanel === newTab)) {
activateTab(newTab);
}
});
};
window.addEventListener('popstate', onPopState);
cleanupFns.push(() => window.removeEventListener('popstate', onPopState));
const destroy = () => {
cleanupFns.forEach((fn) => fn());
cleanupFns.length = 0;
delete container.dataset.tabsBound;
delete container.dataset.tabsReady;
};
container._tabsApi = { activateTab, destroy };
instances.push(container._tabsApi);
});
return instances;
}
// ------------------------------------------------------------------
@@ -359,7 +373,10 @@ function createChevronBtn(direction, iconClass) {
btn.className = `app-tabs-chevron app-tabs-chevron--${direction}`;
btn.setAttribute('aria-hidden', 'true');
btn.setAttribute('tabindex', '-1');
btn.innerHTML = `<i class="bi ${iconClass}"></i>`;
const icon = document.createElement('i');
icon.className = `bi ${iconClass}`;
icon.setAttribute('aria-hidden', 'true');
btn.appendChild(icon);
return btn;
}