Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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. `&lt;script&gt;` 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('&lt;script&gt;');
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('&lt;script&gt;alert(1)&lt;/script&gt;');
// 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;
}
});
});