Skip to content

Commit fb7f8cb

Browse files
committed
fix(depgraph): build the viewer's panels as DOM, not HTML strings
CodeQL flagged four "DOM text reinterpreted as HTML" findings on #1405. They are real: the graph payload is read out of the page with `textContent` and was then interpolated into `innerHTML` in five places, so every one was a text-to-HTML reinterpretation. The `escapeHtml` helper covered the paths it was called on, which is exactly the weakness — correctness depended on every interpolation remembering to call it, and the numeric-entity form it used is not a form the scanner recognizes as a sanitizer either. The stats strip, zone legend, hotspot list and detail panel now build elements and set `textContent`, so there is no HTML sink left to escape into and a file path containing `<` renders as that path. `escapeHtml` is gone with them. Rendering is unchanged: verified against the same commit's graph with the Playwright smoke check — panels, pills, dependency lists with their edge tags, and zero console errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
1 parent 44036c3 commit fb7f8cb

1 file changed

Lines changed: 124 additions & 60 deletions

File tree

scripts/depgraph/viewer.js

Lines changed: 124 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -423,44 +423,80 @@
423423

424424
const detail = document.getElementById('detail');
425425

426-
function escapeHtml(value) {
427-
return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
426+
// Everything below builds DOM nodes and sets `textContent` rather than assembling an
427+
// HTML string. The graph payload is read out of the page (`textContent` of an inline
428+
// JSON block), which makes any interpolation of it into `innerHTML` a text-to-HTML
429+
// reinterpretation — CodeQL flagged four, and hand-escaping only moves the problem to
430+
// "did every interpolation remember to escape". With no HTML sink there is nothing to
431+
// escape: a file path containing `<` renders as that path.
432+
function el(tag, props, children) {
433+
const node = document.createElement(tag);
434+
for (const [key, value] of Object.entries(props ?? {})) {
435+
if (value === undefined || value === false) continue;
436+
if (key === 'text') node.textContent = String(value);
437+
else if (key === 'class') node.className = value;
438+
else if (key === 'style') node.setAttribute('style', value);
439+
else if (key.startsWith('data-') || key.startsWith('aria-')) {
440+
node.setAttribute(key, String(value));
441+
} else node[key] = value;
442+
}
443+
for (const child of children ?? []) {
444+
if (child === null || child === undefined || child === false) continue;
445+
node.append(child);
446+
}
447+
return node;
448+
}
449+
450+
function replaceChildren(target, children) {
451+
target.replaceChildren(...children.filter(Boolean));
428452
}
429453

430454
function edgeTags(edge, index) {
431455
const tags = [];
432-
if (edge[2] !== 0) tags.push(`<span class="tag ${KIND[edge[2]]}">${KIND[edge[2]]}</span>`);
433-
if (edge[3] & FLAG_BACK) tags.push('<span class="tag back">back-edge</span>');
456+
if (edge[2] !== 0)
457+
tags.push(el('span', { class: `tag ${KIND[edge[2]]}`, text: KIND[edge[2]] }));
458+
if (edge[3] & FLAG_BACK) tags.push(el('span', { class: 'tag back', text: 'back-edge' }));
434459
if (edge[3] & FLAG_TYPE_INVERSION) {
435-
tags.push('<span class="tag redundant">type inversion</span>');
460+
tags.push(el('span', { class: 'tag redundant', text: 'type inversion' }));
436461
}
437-
if (edge[3] & FLAG_REDUNDANT) tags.push('<span class="tag redundant">redundant</span>');
438-
if (cycleEdges.has(index)) tags.push('<span class="tag back">cycle</span>');
439-
return tags.join('');
462+
if (edge[3] & FLAG_REDUNDANT) {
463+
tags.push(el('span', { class: 'tag redundant', text: 'redundant' }));
464+
}
465+
if (cycleEdges.has(index)) tags.push(el('span', { class: 'tag back', text: 'cycle' }));
466+
return tags;
440467
}
441468

442469
function depList(title, edgeIndices, endpoint) {
470+
const heading = el('h3', { text: `${title}${edgeIndices.length}` });
443471
if (edgeIndices.length === 0) {
444-
return `<div class="deps"><h3>${title} — 0</h3><p class="empty">none</p></div>`;
472+
return el('div', { class: 'deps' }, [heading, el('p', { class: 'empty', text: 'none' })]);
445473
}
446474
const rows = edgeIndices
447475
.map((index) => ({ index, other: edges[index][endpoint] }))
448476
.sort((left, right) => nodes[left.other].id.localeCompare(nodes[right.other].id))
449-
.map(
450-
({ index, other }) =>
451-
`<li><button type="button" data-goto="${other}">${escapeHtml(
452-
nodes[other].id,
453-
)}${edgeTags(edges[index], index)}</button></li>`,
454-
)
455-
.join('');
456-
return `<div class="deps"><h3>${title}${edgeIndices.length}</h3><ul>${rows}</ul></div>`;
477+
.map(({ index, other }) =>
478+
el('li', {}, [
479+
el('button', { type: 'button', 'data-goto': other }, [
480+
el('span', { text: nodes[other].id }),
481+
...edgeTags(edges[index], index),
482+
]),
483+
]),
484+
);
485+
return el('div', { class: 'deps' }, [heading, el('ul', {}, rows)]);
486+
}
487+
488+
function fact(term, value) {
489+
return el('div', { class: 'fact' }, [
490+
el('dt', { text: term }),
491+
el('dd', { text: String(value) }),
492+
]);
457493
}
458494

459495
function select(index) {
460496
state.selected = index;
461497
if (index === null) {
462498
detail.classList.remove('open');
463-
detail.innerHTML = '';
499+
detail.replaceChildren();
464500
syncToggle();
465501
schedule();
466502
return;
@@ -473,35 +509,44 @@
473509
const redundantOut = outEdges[index].filter((edge) => edges[edge][3] & FLAG_REDUNDANT).length;
474510

475511
const pills = [
476-
`<span class="pill rank">${escapeHtml(zone.id)}${
477-
zone.rank === null ? ' · unranked' : ` · rank ${zone.rank}`
478-
}</span>`,
512+
el('span', {
513+
class: 'pill rank',
514+
text: `${zone.id}${zone.rank === null ? ' · unranked' : ` · rank ${zone.rank}`}`,
515+
}),
479516
];
480517
if (cycleIndex !== undefined) {
481518
pills.push(
482-
`<span class="pill critical">${escapeHtml(data.cycles[cycleIndex].kind)} cycle</span>`,
519+
el('span', { class: 'pill critical', text: `${data.cycles[cycleIndex].kind} cycle` }),
483520
);
484521
}
485-
if (backOut > 0) pills.push(`<span class="pill critical">${backOut} back-edge</span>`);
522+
if (backOut > 0) {
523+
pills.push(el('span', { class: 'pill critical', text: `${backOut} back-edge` }));
524+
}
486525
if (redundantOut > 0) {
487-
pills.push(`<span class="pill warning">${redundantOut} redundant</span>`);
526+
pills.push(el('span', { class: 'pill warning', text: `${redundantOut} redundant` }));
488527
}
489528

490-
detail.innerHTML = `
491-
<div class="detail-head">
492-
<h2>src/${escapeHtml(node.id)}</h2>
493-
<button type="button" class="close" data-close aria-label="Close details">×</button>
494-
</div>
495-
<div>${pills.join(' ')}</div>
496-
<dl class="facts">
497-
<div class="fact"><dt>Dependents</dt><dd>${node.in}</dd></div>
498-
<div class="fact"><dt>Dependencies</dt><dd>${node.out}</dd></div>
499-
<div class="fact"><dt>Lines</dt><dd>${node.loc}</dd></div>
500-
<div class="fact"><dt>Depth to sink</dt><dd>${node.lvl}</dd></div>
501-
</dl>
502-
${depList('Imported by', inEdges[index], 0)}
503-
${depList('Imports', outEdges[index], 1)}
504-
`;
529+
replaceChildren(detail, [
530+
el('div', { class: 'detail-head' }, [
531+
el('h2', { text: `src/${node.id}` }),
532+
el('button', {
533+
type: 'button',
534+
class: 'close',
535+
'data-close': '',
536+
'aria-label': 'Close details',
537+
text: '×',
538+
}),
539+
]),
540+
el('div', {}, pills),
541+
el('dl', { class: 'facts' }, [
542+
fact('Dependents', node.in),
543+
fact('Dependencies', node.out),
544+
fact('Lines', node.loc),
545+
fact('Depth to sink', node.lvl),
546+
]),
547+
depList('Imported by', inEdges[index], 0),
548+
depList('Imports', outEdges[index], 1),
549+
]);
505550
detail.classList.add('open');
506551
syncToggle();
507552
schedule();
@@ -554,9 +599,15 @@
554599
tone: redundant === 0 ? 'is-ok' : 'is-warning',
555600
},
556601
];
557-
document.getElementById('stats').innerHTML = cells
558-
.map((cell) => `<div class="stat ${cell.tone}"><b>${cell.value}</b>${cell.label}</div>`)
559-
.join('');
602+
replaceChildren(
603+
document.getElementById('stats'),
604+
cells.map((cell) =>
605+
el('div', { class: `stat ${cell.tone}` }, [
606+
el('b', { text: String(cell.value) }),
607+
document.createTextNode(cell.label),
608+
]),
609+
),
610+
);
560611
document.getElementById('commit').textContent = `@ ${data.generated.commit}`;
561612
}
562613

@@ -566,32 +617,45 @@
566617
const rankRight = zones[right].rank ?? 99;
567618
return rankLeft - rankRight || zones[left].id.localeCompare(zones[right].id);
568619
});
569-
document.getElementById('legend').innerHTML = ordered
570-
.map((index) => {
620+
replaceChildren(
621+
document.getElementById('legend'),
622+
ordered.map((index) => {
571623
const zone = zones[index];
572624
const rank = zone.rank === null ? 'unranked' : `rank ${zone.rank}`;
573-
const hidden = state.hiddenZones.has(index);
574-
return `<button type="button" class="legend-row" data-zone="${index}" aria-pressed="${!hidden}">
575-
<span class="swatch" style="background:${zoneColor(index)}"></span>
576-
<span class="zone">${escapeHtml(zone.id)} <i>${rank}</i></span>
577-
<span class="count">${zone.files}</span>
578-
</button>`;
579-
})
580-
.join('');
625+
return el(
626+
'button',
627+
{
628+
type: 'button',
629+
class: 'legend-row',
630+
'data-zone': index,
631+
'aria-pressed': String(!state.hiddenZones.has(index)),
632+
},
633+
[
634+
el('span', { class: 'swatch', style: `background:${zoneColor(index)}` }),
635+
el('span', { class: 'zone' }, [
636+
document.createTextNode(`${zone.id} `),
637+
el('i', { text: rank }),
638+
]),
639+
el('span', { class: 'count', text: String(zone.files) }),
640+
],
641+
);
642+
}),
643+
);
581644
}
582645

583646
function renderHotspots() {
584647
const top = [...nodes.keys()]
585648
.sort((left, right) => nodes[right].in - nodes[left].in || nodes[right].out - nodes[left].out)
586649
.slice(0, 14);
587-
document.getElementById('hotspots').innerHTML = top
588-
.map(
589-
(index) =>
590-
`<button type="button" class="hotspot" data-focus="${index}"><span>${escapeHtml(
591-
nodes[index].id,
592-
)}</span><span class="metric">${nodes[index].in}</span></button>`,
593-
)
594-
.join('');
650+
replaceChildren(
651+
document.getElementById('hotspots'),
652+
top.map((index) =>
653+
el('button', { type: 'button', class: 'hotspot', 'data-focus': index }, [
654+
el('span', { text: nodes[index].id }),
655+
el('span', { class: 'metric', text: String(nodes[index].in) }),
656+
]),
657+
),
658+
);
595659
}
596660

597661
const layoutHint = document.getElementById('layout-hint');

0 commit comments

Comments
 (0)