fix(ui): jsAttrEsc for inline onclick handlers — apostrophe in product_name broke SKU mapping modal

HTML parser decodes ' back to ' inside onclick="..." before the JS parser
runs, so esc() left inline handlers vulnerable: product names containing an
apostrophe terminated the JS string literal ("missing ) after argument list").

New jsAttrEsc() escapes for JS-string-inside-HTML-attribute (\\, ', \n first;
then &, ", <, >). Applied to all inline onclick sites that interpolate
user-controlled sku/product_name/codmat: shared.js detail modal (lines
879/939), missing_skus.html (4 sites), mappings.js (3 sites).

Cache-bust: shared.js v49→50, mappings.js v17→18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-05-25 14:07:17 +00:00
parent 32974e3b85
commit a530ebfaff
5 changed files with 28 additions and 11 deletions

View File

@@ -109,12 +109,12 @@ function renderTable(mappings, showDeleted) {
const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
html += `<div class="flat-row" style="background:var(--surface-raised);font-weight:600;border-top:1px solid var(--border);${inactiveStyle}">
<span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${jsAttrEsc(m.sku)}', '${jsAttrEsc(m.codmat)}', ${m.activ})"`}
title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
<strong class="me-1 text-nowrap">${esc(m.sku)}</strong>${kitBadge}
<span class="grow truncate text-muted" style="font-size:0.875rem">${esc(m.product_name || '')}</span>
${m.sters
? `<button class="btn btn-sm btn-outline-success" onclick="event.stopPropagation();restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza" style="padding:0.1rem 0.4rem"><i class="bi bi-arrow-counterclockwise"></i></button>`
? `<button class="btn btn-sm btn-outline-success" onclick="event.stopPropagation();restoreMapping('${jsAttrEsc(m.sku)}', '${jsAttrEsc(m.codmat)}')" title="Restaureaza" style="padding:0.1rem 0.4rem"><i class="bi bi-arrow-counterclockwise"></i></button>`
: `<button class="context-menu-trigger" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}" data-cantitate="${m.cantitate_roa}">&#8942;</button>`
}
</div>`;
@@ -128,7 +128,7 @@ function renderTable(mappings, showDeleted) {
<span class="grow truncate text-muted" style="font-size:0.85rem">${esc(m.denumire || '')}</span>
<span class="text-nowrap" style="font-size:0.875rem">
<span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>${isKitRow ? kitPriceSlot : inlinePrice}
${m.sters ? '' : `onclick="editFlatValue(this, '${jsAttrEsc(m.sku)}', '${jsAttrEsc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>${isKitRow ? kitPriceSlot : inlinePrice}
</span>
</div>`;

View File

@@ -33,6 +33,23 @@ function esc(s) {
.replace(/'/g, '&#39;');
}
// JS-string-inside-HTML-attribute escape.
// HTML parser decodes entities (incl. &#39;) BEFORE the JS parser runs the inline
// handler, so esc() is unsafe for `onclick="fn('${...}')"`. This escapes for the
// JS string layer (\\, \') first, then HTML-escapes the dangerous attribute chars
// (&, ", <, >) — leaving apostrophes literal so JS sees \' after attribute decoding.
function jsAttrEsc(s) {
if (s == null) return '';
return String(s)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\r?\n/g, '\\n')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ── Date formatting ───────────────────────────────
function fmtDate(dateStr, includeSeconds) {
if (!dateStr) return '-';
@@ -876,7 +893,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
: `<code>${esc(item.codmat || '')}</code>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
const clickAttr = opts.onQuickMap ? `onclick="_sharedModalQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}',${idx})"` : '';
const clickAttr = opts.onQuickMap ? `onclick="_sharedModalQuickMap('${jsAttrEsc(item.sku)}','${jsAttrEsc(item.product_name||'')}','${jsAttrEsc(orderNumber)}',${idx})"` : '';
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
@@ -936,7 +953,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
// Desktop items table
const clickAttrFn = (item, idx) => opts.onQuickMap
? `onclick="_sharedModalQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare"`
? `onclick="_sharedModalQuickMap('${jsAttrEsc(item.sku)}', '${jsAttrEsc(item.product_name || '')}', '${jsAttrEsc(orderNumber)}', ${idx})" title="Click pentru mapare"`
: '';
let tableHtml = items.map((item, idx) => {

View File

@@ -169,7 +169,7 @@
<script>window.ROOT_PATH = "{{ rp }}";</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ rp }}/static/js/shared.js?v=49"></script>
<script src="{{ rp }}/static/js/shared.js?v=50"></script>
<script>
// Dark mode toggle
function toggleDarkMode() {

View File

@@ -159,5 +159,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=17"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=18"></script>
{% endblock %}

View File

@@ -187,7 +187,7 @@ function renderMissingSkusTable(skus, data) {
tbody.innerHTML = skus.map(s => {
const trAttrs = !s.resolved
? ` style="cursor:pointer" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')"`
? ` style="cursor:pointer" onclick="openMapModal('${jsAttrEsc(s.sku)}', '${jsAttrEsc(s.product_name || '')}')"`
: '';
return `<tr${trAttrs}>
<td>${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}</td>
@@ -195,7 +195,7 @@ function renderMissingSkusTable(skus, data) {
<td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td>
<td>
${!s.resolved
? `<a href="#" class="btn-map-icon" onclick="event.stopPropagation(); openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
? `<a href="#" class="btn-map-icon" onclick="event.stopPropagation(); openMapModal('${jsAttrEsc(s.sku)}', '${jsAttrEsc(s.product_name || '')}'); return false;" title="Mapeaza">
<i class="bi bi-link-45deg"></i>
</a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
@@ -206,10 +206,10 @@ function renderMissingSkusTable(skus, data) {
if (mobileList) {
mobileList.innerHTML = skus.map(s => {
const actionHtml = !s.resolved
? `<a href="#" class="btn-map-icon" onclick="event.stopPropagation(); openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;"><i class="bi bi-link-45deg"></i></a>`
? `<a href="#" class="btn-map-icon" onclick="event.stopPropagation(); openMapModal('${jsAttrEsc(s.sku)}', '${jsAttrEsc(s.product_name || '')}'); return false;"><i class="bi bi-link-45deg"></i></a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`;
const flatRowAttrs = !s.resolved
? ` onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')" style="cursor:pointer"`
? ` onclick="openMapModal('${jsAttrEsc(s.sku)}', '${jsAttrEsc(s.product_name || '')}')" style="cursor:pointer"`
: '';
return `<div class="flat-row"${flatRowAttrs}>
${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}