From 32129a41c9f3f532125dbb3eabd079ec7f10973a Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sat, 23 May 2026 00:17:34 +0200 Subject: [PATCH] fix: render dashboard domain rows safely --- backend/app/templates/index.html | 93 ++++++++++++++----- .../tests/test_dashboard_template_security.py | 20 ++++ 2 files changed, 91 insertions(+), 22 deletions(-) create mode 100644 backend/app/tests/test_dashboard_template_security.py diff --git a/backend/app/templates/index.html b/backend/app/templates/index.html index 407949f..c78c8ee 100644 --- a/backend/app/templates/index.html +++ b/backend/app/templates/index.html @@ -673,34 +673,83 @@ function dashboardApp() { const tableBody = document.getElementById('domains-table-body'); if (!tableBody) return; - tableBody.innerHTML = ''; + tableBody.textContent = ''; domains.forEach(domain => { const row = document.createElement('tr'); row.className = 'table-row'; - - row.innerHTML = ` - -
${domain.domain_name}
- - ${domain.total_emails || 0} - - - ${domain.pass_rate || 0}% - - - ${domain.failed_count || 0} - ${domain.report_count || 0} - - - - Details - - - `; - + + row.appendChild(this.createDomainNameCell(domain.domain_name)); + row.appendChild(this.createTextCell(this.formatLargeNumber(domain.total_emails || 0))); + row.appendChild(this.createPassRateCell(domain.pass_rate || 0)); + row.appendChild(this.createTextCell(this.formatLargeNumber(domain.failed_count || 0))); + row.appendChild(this.createTextCell(this.formatLargeNumber(domain.report_count || 0))); + row.appendChild(this.createDetailsCell(domain)); + tableBody.appendChild(row); }); + }, + + createDomainNameCell(domainName) { + const cell = document.createElement('td'); + cell.className = 'table-cell'; + + const content = document.createElement('div'); + content.className = 'font-medium'; + content.textContent = domainName || 'Unknown'; + cell.appendChild(content); + + return cell; + }, + + createPassRateCell(passRate) { + const cell = document.createElement('td'); + cell.className = 'table-cell'; + + const badge = document.createElement('span'); + badge.className = 'inline-flex items-center rounded-md bg-green-50 dark:bg-green-900/20 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300'; + badge.textContent = `${passRate}%`; + cell.appendChild(badge); + + return cell; + }, + + createDetailsCell(domain) { + const cell = document.createElement('td'); + cell.className = 'table-cell text-right'; + + const link = document.createElement('a'); + link.className = 'btn btn-outline btn-sm'; + const domainId = String(domain.id ?? domain.domain_name ?? ''); + link.href = `/domains/${encodeURIComponent(domainId)}`; + link.appendChild(this.createDetailsIcon()); + link.appendChild(document.createTextNode('Details')); + cell.appendChild(link); + + return cell; + }, + + createDetailsIcon() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '14'); + svg.setAttribute('height', '14'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('class', 'mr-1'); + + const axis = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + axis.setAttribute('d', 'M3 3v18h18'); + svg.appendChild(axis); + + const trend = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + trend.setAttribute('d', 'm19 9-5 5-4-4-3 3'); + svg.appendChild(trend); + + return svg; } } } diff --git a/backend/app/tests/test_dashboard_template_security.py b/backend/app/tests/test_dashboard_template_security.py new file mode 100644 index 0000000..595ba6d --- /dev/null +++ b/backend/app/tests/test_dashboard_template_security.py @@ -0,0 +1,20 @@ +from pathlib import Path + + +def test_dashboard_domain_table_uses_safe_dom_rendering(): + """Domain names and counts come from report data and must not be HTML-rendered.""" + template = (Path(__file__).resolve().parents[1] / "templates" / "index.html").read_text() + populate_start = template.index("populateDomainsTable(domains)") + helper_start = template.index("createDomainNameCell(domainName)") + populate_body = template[populate_start:helper_start] + + assert "innerHTML" not in populate_body + assert ".textContent" in populate_body + assert "createDomainNameCell" in populate_body + assert "createDetailsCell" in populate_body + + +def test_dashboard_domain_details_links_are_encoded(): + template = (Path(__file__).resolve().parents[1] / "templates" / "index.html").read_text() + + assert "encodeURIComponent(domainId)" in template