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:
@@ -194,7 +194,7 @@ export function initFilterDrawer(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', (event) => {
|
const onKeyDown = (event) => {
|
||||||
if (!isOpen) {return;}
|
if (!isOpen) {return;}
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -225,7 +225,8 @@ export function initFilterDrawer(options = {}) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
first.focus();
|
first.focus();
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
|
||||||
const setApplyEnabled = (enabled) => {
|
const setApplyEnabled = (enabled) => {
|
||||||
if (!applyButton) {return;}
|
if (!applyButton) {return;}
|
||||||
@@ -275,6 +276,13 @@ export function initFilterDrawer(options = {}) {
|
|||||||
setApplyCount(0);
|
setApplyCount(0);
|
||||||
setTriggerCount(0);
|
setTriggerCount(0);
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
if (isOpen) {
|
||||||
|
close('discard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
@@ -282,6 +290,7 @@ export function initFilterDrawer(options = {}) {
|
|||||||
drawer,
|
drawer,
|
||||||
setApplyEnabled,
|
setApplyEnabled,
|
||||||
setApplyCount,
|
setApplyCount,
|
||||||
setTriggerCount
|
setTriggerCount,
|
||||||
|
destroy
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ export function initPasswordToggles() {
|
|||||||
toggle.type = 'button';
|
toggle.type = 'button';
|
||||||
toggle.className = 'login-password-toggle';
|
toggle.className = 'login-password-toggle';
|
||||||
toggle.setAttribute('aria-label', showLabel);
|
toggle.setAttribute('aria-label', showLabel);
|
||||||
toggle.innerHTML = '<i class="bi bi-eye" aria-hidden="true"></i>';
|
const icon = document.createElement('i');
|
||||||
|
icon.className = 'bi bi-eye';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
toggle.appendChild(icon);
|
||||||
|
|
||||||
input.replaceWith(wrapper);
|
input.replaceWith(wrapper);
|
||||||
wrapper.appendChild(input);
|
wrapper.appendChild(input);
|
||||||
@@ -46,9 +49,7 @@ export function initPasswordToggles() {
|
|||||||
toggle.addEventListener('click', () => {
|
toggle.addEventListener('click', () => {
|
||||||
const isPassword = input.type === 'password';
|
const isPassword = input.type === 'password';
|
||||||
input.type = isPassword ? 'text' : 'password';
|
input.type = isPassword ? 'text' : 'password';
|
||||||
toggle.innerHTML = isPassword
|
icon.className = isPassword ? 'bi bi-eye-slash' : 'bi bi-eye';
|
||||||
? '<i class="bi bi-eye-slash" aria-hidden="true"></i>'
|
|
||||||
: '<i class="bi bi-eye" aria-hidden="true"></i>';
|
|
||||||
toggle.setAttribute('aria-label', isPassword ? hideLabel : showLabel);
|
toggle.setAttribute('aria-label', isPassword ? hideLabel : showLabel);
|
||||||
input.focus();
|
input.focus();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
*/
|
*/
|
||||||
export function initTabs(root = document) {
|
export function initTabs(root = document) {
|
||||||
const containers = root.querySelectorAll('[data-tabs]');
|
const containers = root.querySelectorAll('[data-tabs]');
|
||||||
|
const instances = [];
|
||||||
|
|
||||||
containers.forEach((container) => {
|
containers.forEach((container) => {
|
||||||
if (container.dataset.tabsBound === '1') {return;}
|
if (container.dataset.tabsBound === '1') {return;}
|
||||||
container.dataset.tabsBound = '1';
|
container.dataset.tabsBound = '1';
|
||||||
container.dataset.tabsReady = '0';
|
container.dataset.tabsReady = '0';
|
||||||
|
const cleanupFns = [];
|
||||||
|
|
||||||
const tabs = Array.from(container.querySelectorAll('[data-tab]'));
|
const tabs = Array.from(container.querySelectorAll('[data-tab]'));
|
||||||
const panels = Array.from(container.querySelectorAll('[data-tab-panel]'));
|
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".
|
// 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.
|
// 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"]');
|
const btn = e.target.closest('button, [type="submit"]');
|
||||||
if (!btn) {return;}
|
if (!btn) {return;}
|
||||||
|
|
||||||
@@ -257,9 +259,9 @@ export function initTabs(root = document) {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
form.reportValidity();
|
form.reportValidity();
|
||||||
});
|
});
|
||||||
}, true);
|
};
|
||||||
|
|
||||||
form.addEventListener('input', (e) => {
|
const onFormInput = (e) => {
|
||||||
const panel = e.target.closest('[data-tab-panel]');
|
const panel = e.target.closest('[data-tab-panel]');
|
||||||
if (!panel) {return;}
|
if (!panel) {return;}
|
||||||
const tabName = panel.dataset.tabPanel;
|
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())) {
|
if (Array.from(fields).every(f => !belongsToForm(f) || f.disabled || f.checkValidity())) {
|
||||||
tab.classList.remove('has-invalid');
|
tab.classList.remove('has-invalid');
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
form.addEventListener('change', (e) => {
|
document.addEventListener('click', onFormClickCapture, true);
|
||||||
const panel = e.target.closest('[data-tab-panel]');
|
form.addEventListener('input', onFormInput);
|
||||||
if (!panel) {return;}
|
form.addEventListener('change', onFormInput);
|
||||||
const tabName = panel.dataset.tabPanel;
|
|
||||||
const tab = tabs.find(t => t.dataset.tab === tabName);
|
cleanupFns.push(() => {
|
||||||
if (!tab) {return;}
|
document.removeEventListener('click', onFormClickCapture, true);
|
||||||
const fields = panel.querySelectorAll(inputSelector);
|
form.removeEventListener('input', onFormInput);
|
||||||
if (Array.from(fields).every(f => !belongsToForm(f) || f.disabled || f.checkValidity())) {
|
form.removeEventListener('change', onFormInput);
|
||||||
tab.classList.remove('has-invalid');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for popstate (back/forward navigation)
|
// Listen for popstate (back/forward navigation)
|
||||||
window.addEventListener('popstate', () => {
|
const onPopState = () => {
|
||||||
const newTab = getUrlParam(urlParamName);
|
const newTab = getUrlParam(urlParamName);
|
||||||
if (newTab && panels.some(p => p.dataset.tabPanel === newTab)) {
|
if (newTab && panels.some(p => p.dataset.tabPanel === newTab)) {
|
||||||
activateTab(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.className = `app-tabs-chevron app-tabs-chevron--${direction}`;
|
||||||
btn.setAttribute('aria-hidden', 'true');
|
btn.setAttribute('aria-hidden', 'true');
|
||||||
btn.setAttribute('tabindex', '-1');
|
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;
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export function initAsidePanels(options = {}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', (event) => {
|
const onKeyDown = (event) => {
|
||||||
if (!(event.metaKey || event.ctrlKey)) {return;}
|
if (!(event.metaKey || event.ctrlKey)) {return;}
|
||||||
if (event.altKey) {return;}
|
if (event.altKey) {return;}
|
||||||
if (isEditableTarget(event.target)) {return;}
|
if (isEditableTarget(event.target)) {return;}
|
||||||
@@ -212,8 +212,14 @@ export function initAsidePanels(options = {}) {
|
|||||||
if (index < 0 || index >= shortcutItems.length) {return;}
|
if (index < 0 || index >= shortcutItems.length) {return;}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
shortcutItems[index].click();
|
shortcutItems[index].click();
|
||||||
});
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { setActive, destroy };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
Reference in New Issue
Block a user