Merge pull request #126 from christianlouis/codex/dashboard-safe-rendering
[codex] harden dashboard domain rendering
This commit is contained in:
@@ -673,34 +673,83 @@ function dashboardApp() {
|
|||||||
const tableBody = document.getElementById('domains-table-body');
|
const tableBody = document.getElementById('domains-table-body');
|
||||||
if (!tableBody) return;
|
if (!tableBody) return;
|
||||||
|
|
||||||
tableBody.innerHTML = '';
|
tableBody.textContent = '';
|
||||||
|
|
||||||
domains.forEach(domain => {
|
domains.forEach(domain => {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.className = 'table-row';
|
row.className = 'table-row';
|
||||||
|
|
||||||
row.innerHTML = `
|
row.appendChild(this.createDomainNameCell(domain.domain_name));
|
||||||
<td class="table-cell">
|
row.appendChild(this.createTextCell(this.formatLargeNumber(domain.total_emails || 0)));
|
||||||
<div class="font-medium">${domain.domain_name}</div>
|
row.appendChild(this.createPassRateCell(domain.pass_rate || 0));
|
||||||
</td>
|
row.appendChild(this.createTextCell(this.formatLargeNumber(domain.failed_count || 0)));
|
||||||
<td class="table-cell">${domain.total_emails || 0}</td>
|
row.appendChild(this.createTextCell(this.formatLargeNumber(domain.report_count || 0)));
|
||||||
<td class="table-cell">
|
row.appendChild(this.createDetailsCell(domain));
|
||||||
<span class="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">
|
|
||||||
${domain.pass_rate || 0}%
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="table-cell">${domain.failed_count || 0}</td>
|
|
||||||
<td class="table-cell">${domain.report_count || 0}</td>
|
|
||||||
<td class="table-cell text-right">
|
|
||||||
<a href="/domains/${domain.id}" class="btn btn-outline btn-sm">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-1"><path d="M3 3v18h18"></path><path d="m19 9-5 5-4-4-3 3"></path></svg>
|
|
||||||
Details
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
tableBody.appendChild(row);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user