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
271 lines
9.3 KiB
JavaScript
271 lines
9.3 KiB
JavaScript
/**
|
|
* Tests for security/xss-protection.js
|
|
*
|
|
* Tests HTML escaping, sanitization, and safe DOM manipulation
|
|
* functions that protect against XSS attacks.
|
|
*/
|
|
|
|
import '@js/security/xss-protection.js';
|
|
|
|
const { escapeHtml, escapeHtmlAttribute, sanitizeUserInput,
|
|
safeSetInnerHTML, safeSetTextContent } = window;
|
|
|
|
describe('escapeHtml', () => {
|
|
it('escapes ampersands', () => {
|
|
expect(escapeHtml('a & b')).toBe('a & b');
|
|
});
|
|
|
|
it('escapes angle brackets', () => {
|
|
expect(escapeHtml('<script>alert(1)</script>')).toBe(
|
|
'<script>alert(1)</script>'
|
|
);
|
|
});
|
|
|
|
it('escapes double quotes', () => {
|
|
expect(escapeHtml('"hello"')).toBe('"hello"');
|
|
});
|
|
|
|
it('escapes single quotes', () => {
|
|
expect(escapeHtml("it's")).toBe('it's');
|
|
});
|
|
|
|
it('escapes forward slashes', () => {
|
|
expect(escapeHtml('a/b')).toBe('a/b');
|
|
});
|
|
|
|
it('returns empty string for null', () => {
|
|
expect(escapeHtml(null)).toBe('');
|
|
});
|
|
|
|
it('returns empty string for undefined', () => {
|
|
expect(escapeHtml(undefined)).toBe('');
|
|
});
|
|
|
|
it('converts non-strings to string first', () => {
|
|
expect(escapeHtml(123)).toBe('123');
|
|
expect(escapeHtml(true)).toBe('true');
|
|
});
|
|
|
|
it('handles strings with no special characters', () => {
|
|
expect(escapeHtml('hello world')).toBe('hello world');
|
|
});
|
|
|
|
it('handles empty string', () => {
|
|
expect(escapeHtml('')).toBe('');
|
|
});
|
|
|
|
it('escapes all dangerous characters together', () => {
|
|
expect(escapeHtml('<img onerror="alert(\'xss\')" />')).toBe(
|
|
'<img onerror="alert('xss')" />'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('escapeHtmlAttribute', () => {
|
|
it('escapes double quotes for attribute context', () => {
|
|
expect(escapeHtmlAttribute('value"with"quotes')).toContain('"');
|
|
});
|
|
|
|
it('escapes angle brackets', () => {
|
|
expect(escapeHtmlAttribute('<tag>')).toContain('<');
|
|
});
|
|
|
|
it('converts non-strings', () => {
|
|
expect(escapeHtmlAttribute(42)).toBe('42');
|
|
});
|
|
});
|
|
|
|
describe('sanitizeUserInput', () => {
|
|
it('returns empty string for null/undefined', () => {
|
|
expect(sanitizeUserInput(null)).toBe('');
|
|
expect(sanitizeUserInput(undefined)).toBe('');
|
|
});
|
|
|
|
it('trims whitespace by default', () => {
|
|
const result = sanitizeUserInput(' hello ');
|
|
expect(result).not.toMatch(/^\s/);
|
|
expect(result).not.toMatch(/\s$/);
|
|
});
|
|
|
|
it('does not trim when option is false', () => {
|
|
const result = sanitizeUserInput(' hello ', { trimWhitespace: false });
|
|
// The result is escaped, so check it contains the text
|
|
expect(result).toContain('hello');
|
|
});
|
|
|
|
it('enforces maxLength', () => {
|
|
const long = 'a'.repeat(200);
|
|
const result = sanitizeUserInput(long, { maxLength: 10 });
|
|
// After escaping, the core content is truncated to 10 chars
|
|
expect(result.length).toBeLessThanOrEqual(10);
|
|
});
|
|
|
|
it('normalizes line breaks', () => {
|
|
const input = 'line1\r\nline2\rline3';
|
|
const result = sanitizeUserInput(input, { allowLineBreaks: true });
|
|
expect(result).not.toContain('\r');
|
|
});
|
|
|
|
it('escapes HTML by default (allowHtml=false)', () => {
|
|
const result = sanitizeUserInput('<script>alert(1)</script>');
|
|
expect(result).toContain('<');
|
|
expect(result).not.toContain('<script>');
|
|
});
|
|
|
|
it('converts non-string input to string', () => {
|
|
expect(sanitizeUserInput(42)).toBe('42');
|
|
});
|
|
});
|
|
|
|
describe('safeSetInnerHTML', () => {
|
|
it('does nothing when element is null', () => {
|
|
expect(() => safeSetInnerHTML(null, 'content')).not.toThrow();
|
|
});
|
|
|
|
it('clears content when content is empty/falsy', () => {
|
|
const el = document.createElement('div');
|
|
el.textContent = 'existing';
|
|
safeSetInnerHTML(el, '');
|
|
expect(el.textContent).toBe('');
|
|
});
|
|
|
|
it('sets textContent when allowHtmlTags is false', () => {
|
|
const el = document.createElement('div');
|
|
safeSetInnerHTML(el, '<b>bold</b>', false);
|
|
expect(el.textContent).toBe('<b>bold</b>');
|
|
expect(el.innerHTML).not.toContain('<b>');
|
|
});
|
|
|
|
it('sets textContent when content is null', () => {
|
|
const el = document.createElement('div');
|
|
el.textContent = 'existing';
|
|
safeSetInnerHTML(el, null);
|
|
expect(el.textContent).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('safeSetTextContent', () => {
|
|
it('does nothing when element is null', () => {
|
|
expect(() => safeSetTextContent(null, 'content')).not.toThrow();
|
|
});
|
|
|
|
it('sets text content safely', () => {
|
|
const el = document.createElement('div');
|
|
safeSetTextContent(el, 'hello world');
|
|
expect(el.textContent).toBe('hello world');
|
|
});
|
|
|
|
it('converts content to string', () => {
|
|
const el = document.createElement('div');
|
|
safeSetTextContent(el, 123);
|
|
expect(el.textContent).toBe('123');
|
|
});
|
|
|
|
it('handles null content gracefully', () => {
|
|
const el = document.createElement('div');
|
|
safeSetTextContent(el, null);
|
|
expect(el.textContent).toBe('');
|
|
});
|
|
|
|
it('handles undefined content gracefully', () => {
|
|
const el = document.createElement('div');
|
|
safeSetTextContent(el, undefined);
|
|
expect(el.textContent).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('escapeHtml fuzz / property tests', () => {
|
|
// The most critical security invariant: after escapeHtml, the result
|
|
// should never contain raw <, >, or unescaped quotes that could break
|
|
// out of an HTML context. We fuzz with random injection-like strings
|
|
// to catch any escaping regression.
|
|
|
|
const generatePayload = () => {
|
|
const fragments = [
|
|
'<script>', '</script>', '<img src=x onerror=alert(1)>',
|
|
'"><svg/onload=', "'><iframe", ''', 'javascript:',
|
|
'"', "'", '<', '>', '&', '/', '`', '${', 'data:text/html,',
|
|
'<style>', '</style>', '</textarea>',
|
|
];
|
|
// Build a random combination
|
|
const len = Math.floor(Math.random() * 5) + 1;
|
|
let payload = '';
|
|
for (let i = 0; i < len; i++) {
|
|
payload += fragments[Math.floor(Math.random() * fragments.length)];
|
|
// Insert random characters between
|
|
const noise = Math.floor(Math.random() * 5);
|
|
for (let j = 0; j < noise; j++) {
|
|
payload += String.fromCharCode(Math.floor(Math.random() * 95) + 32);
|
|
}
|
|
}
|
|
return payload;
|
|
};
|
|
|
|
it('escaped output never contains raw < or > (200 random payloads)', () => {
|
|
for (let i = 0; i < 200; i++) {
|
|
const payload = generatePayload();
|
|
const escaped = escapeHtml(payload);
|
|
expect(escaped).not.toMatch(/</);
|
|
expect(escaped).not.toMatch(/>/);
|
|
}
|
|
});
|
|
|
|
it('escaped output never contains raw double quotes', () => {
|
|
for (let i = 0; i < 200; i++) {
|
|
const payload = generatePayload();
|
|
const escaped = escapeHtml(payload);
|
|
expect(escaped).not.toMatch(/"/);
|
|
}
|
|
});
|
|
|
|
it('escaped output is parseable as text content (round-trips through DOM)', () => {
|
|
// If escaping works, the escaped string parsed as innerHTML and read
|
|
// back via textContent should equal the original input.
|
|
const div = document.createElement('div');
|
|
for (let i = 0; i < 100; i++) {
|
|
const payload = generatePayload();
|
|
const escaped = escapeHtml(payload);
|
|
// eslint-disable-next-line no-unsanitized/property -- test code
|
|
div.innerHTML = escaped;
|
|
// textContent decodes the entities back to the original
|
|
expect(div.textContent).toBe(payload);
|
|
// And no script/element tags were created
|
|
expect(div.children.length).toBe(0);
|
|
}
|
|
});
|
|
|
|
it('escaped output never creates executable elements', () => {
|
|
const div = document.createElement('div');
|
|
// Specific known XSS vectors
|
|
const vectors = [
|
|
'<script>alert(1)</script>',
|
|
'<img src=x onerror=alert(1)>',
|
|
'<svg onload=alert(1)>',
|
|
'"><script>alert(1)</script>',
|
|
'<iframe src=javascript:alert(1)>',
|
|
'<a href="javascript:alert(1)">x</a>',
|
|
];
|
|
for (const vector of vectors) {
|
|
div.innerHTML = escapeHtml(vector);
|
|
// No element children should have been created
|
|
expect(div.children.length).toBe(0);
|
|
expect(div.querySelectorAll('script').length).toBe(0);
|
|
expect(div.querySelectorAll('img').length).toBe(0);
|
|
expect(div.querySelectorAll('svg').length).toBe(0);
|
|
expect(div.querySelectorAll('iframe').length).toBe(0);
|
|
}
|
|
});
|
|
|
|
it('idempotent: escaping an already-escaped string does not create XSS', () => {
|
|
// Escaping twice should still be safe (no element creation)
|
|
const div = document.createElement('div');
|
|
for (let i = 0; i < 50; i++) {
|
|
const payload = generatePayload();
|
|
const escapedTwice = escapeHtml(escapeHtml(payload));
|
|
// eslint-disable-next-line no-unsanitized/property -- test code
|
|
div.innerHTML = escapedTwice;
|
|
expect(div.children.length).toBe(0);
|
|
}
|
|
});
|
|
});
|