/** * Regression tests for the XSS hardening in link_analytics.html (PR #3095) * and the follow-up consistency fix at commit 12a1b11b0 * (`researchDiversity` wrapped in `Number()` at the "Recent Researches * (N total)" header — link_analytics_render.js). * * Unlike pages/news.js, link_analytics writes the rendered HTML directly * to #domain-list via innerHTML — no DOMPurify barrier. Every untrusted * string interpolation must therefore be wrapped in Number() (numeric * coercion), escapeHtml() (entity encoding), encodeURIComponent() * (path segment), or encodeURI() (host). These tests pin each escape * site by injecting a `'; beforeAll(async () => { // xss-protection.js provides window.escapeHtml, which the render // function relies on as a global. In production it's loaded by // base.html before the link_analytics inline script runs. await import('@js/security/xss-protection.js'); // link_analytics_render.js is an IIFE that exposes // window.updateEnhancedDomainList on load. await import('@js/pages/link_analytics_render.js'); }); beforeEach(() => { // The render function only references #domain-list. Reset it between // tests so payload leakage from one test cannot satisfy another. document.body.innerHTML = '
'; }); describe('pages/link_analytics_render.js — XSS regression for PR #3095', () => { it('Number()-coerces researchDiversity header + usage badges (commit 12a1b11b0)', () => { // Regression for the consistency fix: ${researchDiversity} in the // "Recent Researches (N total)" header was the only sibling field // not wrapped in Number(). Injecting a payload must produce 0 // (NaN is falsy, so `Number(x) || 0` falls through) — never the // raw payload. Matches the `|| 0` UX pattern from news.js. const domains = [{ domain: 'safe.example.com', count: 0, percentage: 0, research_count: 0, recent_researches: [{ id: 'r1', query: 'safe query' }], }]; const metrics = { 'safe.example.com': { usage_count: PAYLOAD, usage_percentage: PAYLOAD, research_diversity: PAYLOAD, frequency_rank: PAYLOAD, }, }; window.updateEnhancedDomainList(domains, metrics); const html = document.getElementById('domain-list').innerHTML; // No script element created. expect(document.querySelectorAll('#domain-list script').length).toBe(0); // No raw payload substring anywhere. expect(html).not.toContain(PAYLOAD); // Header ran through Number() || 0 — payload can't leak; NaN // falls through to 0 per the established news.js pattern. expect(html).toContain('Recent Researches (0 total)'); // Frequency badge: both usage_count and usage_percentage hit Number() || 0. expect(html).toContain('📊 0 uses (0%)'); // Diversity badge: research_diversity hit Number() || 0. expect(html).toContain('🔍 0 researches'); // Frequency rank ran through Number() — payload can't leak via the // `#${rank}` interpolation. The broader guards above catch this // if Number() coercion is ever dropped (the payload would render // raw as `#