7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
262 lines
12 KiB
JavaScript
262 lines
12 KiB
JavaScript
/**
|
|
* 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 `<script>` payload and asserting:
|
|
* (a) no `<script>` element is created in the DOM, and
|
|
* (b) the specific escape ran (e.g. `<script>` or `NaN` present).
|
|
*
|
|
* The function under test was extracted to
|
|
* src/local_deep_research/web/static/js/pages/link_analytics_render.js
|
|
* (surgical extraction mirroring PR #4584). The inline wrapper at
|
|
* link_analytics.html:797-802 still reassigns it on window; the last
|
|
* test verifies that wrapper pattern still works against the extracted
|
|
* module.
|
|
*/
|
|
|
|
const PAYLOAD = '<script>alert(1)</script>';
|
|
|
|
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 = '<div id="domain-list"></div>';
|
|
});
|
|
|
|
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 `#<script>...`).
|
|
});
|
|
|
|
it('escapeHtml-encodes research link query in title attr and text', () => {
|
|
// Payload includes a quote so the title-attribute assertion can
|
|
// distinguish "escapeHtml ran" from "browser just didn't parse <
|
|
// as a tag inside a quoted attr". If escapeHtml did NOT run, the
|
|
// quote would break out of the title attribute and spawn a
|
|
// rogue onmouseover attribute on the <a>.
|
|
const ATTR_BREAKOUT = `" onmouseover="alert(1)`;
|
|
const domains = [{
|
|
domain: 'safe.example.com',
|
|
count: 1,
|
|
recent_researches: [
|
|
{ id: 'r1', query: PAYLOAD },
|
|
{ id: 'r2', query: 'short ' + PAYLOAD },
|
|
{ id: 'r3', query: ATTR_BREAKOUT },
|
|
],
|
|
}];
|
|
|
|
window.updateEnhancedDomainList(domains, {});
|
|
|
|
const links = document.querySelectorAll('#domain-list .ldr-research-link');
|
|
expect(links.length).toBe(3);
|
|
expect(document.querySelectorAll('#domain-list script').length).toBe(0);
|
|
|
|
// First two links: text node is entity-encoded. innerHTML carries
|
|
// the escaped form for text content (the text is inside an <a>
|
|
// element, not an attribute, so the entities are preserved).
|
|
const html = document.getElementById('domain-list').innerHTML;
|
|
expect(html).toContain('<script>');
|
|
expect(html).not.toContain('>' + PAYLOAD); // no raw payload in any text node
|
|
|
|
// Third link: breakout payload must be contained inside the title
|
|
// attribute, not parsed as a new attribute.
|
|
const breakoutLink = links[2];
|
|
expect(breakoutLink.getAttribute('onmouseover')).toBeNull();
|
|
expect(breakoutLink.getAttribute('title')).toBe(ATTR_BREAKOUT);
|
|
});
|
|
|
|
it('escapes domain name in link text via escapeHtml and in href via encodeURI', () => {
|
|
const domains = [{
|
|
domain: PAYLOAD,
|
|
count: 1,
|
|
recent_researches: [],
|
|
}];
|
|
|
|
window.updateEnhancedDomainList(domains, {});
|
|
|
|
const html = document.getElementById('domain-list').innerHTML;
|
|
const link = document.querySelector('#domain-list .ldr-domain-name a');
|
|
|
|
expect(link).not.toBeNull();
|
|
expect(document.querySelectorAll('#domain-list script').length).toBe(0);
|
|
// Link text is entity-encoded.
|
|
expect(html).toContain('<script>alert(1)</script>');
|
|
// The href is percent-encoded via encodeURI (< > " all escape to
|
|
// %3C %3E %22), so the payload cannot break out of the href
|
|
// attribute or be replayed as an executable URL.
|
|
const href = link.getAttribute('href');
|
|
expect(href).toContain('%3Cscript%3E');
|
|
expect(href.toLowerCase()).not.toContain('javascript:');
|
|
});
|
|
|
|
it('escapeHtml-encodes classification category/subcategory and Number()-coerces confidence', () => {
|
|
// Quote in subcategory tests attribute containment on the badge title.
|
|
const ATTR_BREAKOUT = `" tiles="evil`;
|
|
const domains = [{
|
|
domain: 'classified.example.com',
|
|
count: 1,
|
|
recent_researches: [],
|
|
classification: {
|
|
category: PAYLOAD,
|
|
subcategory: PAYLOAD + ATTR_BREAKOUT,
|
|
confidence: PAYLOAD,
|
|
},
|
|
}];
|
|
|
|
window.updateEnhancedDomainList(domains, {});
|
|
|
|
const badge = document.querySelector('#domain-list .ldr-classified-badge');
|
|
expect(badge).not.toBeNull();
|
|
expect(document.querySelectorAll('#domain-list script').length).toBe(0);
|
|
|
|
// Category text node entity-encoded (whitespace from template
|
|
// literal indentation surrounds the value).
|
|
expect(badge.textContent.trim()).toBe(PAYLOAD);
|
|
|
|
// Subcategory ran through escapeHtml: the breakout quote must be
|
|
// contained inside the title attribute, not parsed as a new
|
|
// attribute. The badge must not gain a `tiles` attribute.
|
|
expect(badge.getAttribute('tiles')).toBeNull();
|
|
expect(badge.getAttribute('title')).toContain(PAYLOAD);
|
|
expect(badge.getAttribute('title')).toContain(ATTR_BREAKOUT);
|
|
|
|
// Confidence ran through Math.round(Number(...)) — produces NaN,
|
|
// never the raw payload.
|
|
expect(badge.getAttribute('title')).toContain('(NaN% confidence)');
|
|
});
|
|
|
|
it('renders the full set of expected selectors on a valid payload', () => {
|
|
const domains = [{
|
|
domain: 'example.com',
|
|
count: 5,
|
|
percentage: 12.5,
|
|
research_count: 3,
|
|
frequency_rank: 1,
|
|
recent_researches: [{ id: 'r1', query: 'a safe query' }],
|
|
classification: { category: 'Tech', subcategory: 'AI', confidence: 0.9 },
|
|
}];
|
|
const metrics = {
|
|
'example.com': {
|
|
usage_count: 5,
|
|
usage_percentage: 12.5,
|
|
research_diversity: 3,
|
|
frequency_rank: 1,
|
|
},
|
|
};
|
|
|
|
expect(() => window.updateEnhancedDomainList(domains, metrics)).not.toThrow();
|
|
|
|
const list = document.getElementById('domain-list');
|
|
expect(list.querySelector('.ldr-domain-item-expanded')).not.toBeNull();
|
|
expect(list.querySelector('.ldr-domain-header')).not.toBeNull();
|
|
expect(list.querySelector('.ldr-domain-name')).not.toBeNull();
|
|
expect(list.querySelector('.ldr-domain-stats')).not.toBeNull();
|
|
expect(list.querySelector('.ldr-frequency')).not.toBeNull();
|
|
expect(list.querySelector('.ldr-diversity')).not.toBeNull();
|
|
expect(list.querySelector('.ldr-research-links')).not.toBeNull();
|
|
expect(list.querySelector('.ldr-research-links-title')).not.toBeNull();
|
|
expect(list.querySelector('.ldr-research-link')).not.toBeNull();
|
|
expect(list.querySelector('.ldr-classified-badge')).not.toBeNull();
|
|
|
|
// Numeric rendering is intact on the happy path.
|
|
const html = list.innerHTML;
|
|
expect(html).toContain('Recent Researches (3 total)');
|
|
expect(html).toContain('📊 5 uses (12.5%)');
|
|
expect(html).toContain('🔍 3 researches');
|
|
expect(html).toContain('(90% confidence)');
|
|
});
|
|
|
|
it('renders the empty-list fallback when domains is []', () => {
|
|
window.updateEnhancedDomainList([], {});
|
|
|
|
const html = document.getElementById('domain-list').innerHTML;
|
|
expect(html).toContain('No domain data available');
|
|
expect(document.querySelectorAll('#domain-list script').length).toBe(0);
|
|
});
|
|
|
|
it('supports the inline-script wrapper pattern (read + reassign + call)', () => {
|
|
// link_analytics.html:797-802 captures the original
|
|
// window.updateEnhancedDomainList and reassigns it to a wrapper
|
|
// that also calls loadClassificationsAndUpdateDisplay(). The
|
|
// surgical extraction must preserve this pattern: window must
|
|
// hold a reassignable function reference, not a frozen export.
|
|
const original = window.updateEnhancedDomainList;
|
|
expect(typeof original).toBe('function');
|
|
|
|
let sideEffectRan = false;
|
|
window.updateEnhancedDomainList = function(domains, metrics) {
|
|
original.call(this, domains, metrics);
|
|
sideEffectRan = true;
|
|
};
|
|
|
|
try {
|
|
window.updateEnhancedDomainList(
|
|
[{ domain: 'wrapper.example.com', count: 1, recent_researches: [] }],
|
|
{}
|
|
);
|
|
expect(sideEffectRan).toBe(true);
|
|
// Original behavior still ran — domain was rendered.
|
|
expect(document.querySelector('#domain-list .ldr-domain-item-expanded')).not.toBeNull();
|
|
} finally {
|
|
// Restore so subsequent tests get the un-wrapped function.
|
|
window.updateEnhancedDomainList = original;
|
|
}
|
|
});
|
|
});
|