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

412 lines
16 KiB
JavaScript

/**
* JavaScript tests for XSS Protection and Dropdown Highlighting
* Run with: npm test tests/infrastructure_tests/test_xss_protection.test.js
*
* Tests the safeSetInnerHTML function and dropdown search highlighting
* which requires DOMPurify to be loaded.
*/
// Mock DOMPurify for testing
const mockDOMPurify = {
sanitize: jest.fn((content) => {
// Simple mock that allows span tags with class attribute
// but removes script tags
let result = content;
// Remove script tags
result = result.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
// Keep allowed tags like span with class
return result;
}),
addHook: jest.fn((hookName, callback) => {
// Mock addHook for tabnabbing protection
// Stores callbacks but doesn't execute them in tests
mockDOMPurify._hooks ||= {};
mockDOMPurify._hooks[hookName] ||= [];
mockDOMPurify._hooks[hookName].push(callback);
}),
_hooks: {}
};
// Setup global mocks
global.DOMPurify = mockDOMPurify;
global.window = {
DOMPurify: mockDOMPurify
};
global.document = {
createElement: jest.fn(() => ({
className: '',
textContent: '',
innerHTML: '',
style: {},
setAttribute: jest.fn(),
appendChild: jest.fn(),
removeChild: jest.fn(),
addEventListener: jest.fn(),
querySelectorAll: jest.fn(() => []),
firstChild: null
}))
};
// Load the XSS protection module
require('../../src/local_deep_research/web/static/js/security/xss-protection.js');
describe('XSS Protection Module', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('DOMPurify Integration', () => {
test('DOMPurify should be available globally', () => {
expect(global.DOMPurify).toBeDefined();
expect(global.DOMPurify.sanitize).toBeDefined();
});
test('window.DOMPurify should be set', () => {
expect(global.window.DOMPurify).toBeDefined();
});
});
describe('escapeHtml', () => {
test('should escape HTML special characters', () => {
const { escapeHtml } = global.window;
expect(escapeHtml('<script>')).toBe('&lt;script&gt;');
expect(escapeHtml('a & b')).toBe('a &amp; b');
expect(escapeHtml('"quoted"')).toBe('&quot;quoted&quot;');
expect(escapeHtml("'single'")).toBe('&#39;single&#39;');
});
test('should handle non-string input', () => {
const { escapeHtml } = global.window;
expect(escapeHtml(123)).toBe('123');
expect(escapeHtml(null)).toBe('');
expect(escapeHtml(undefined)).toBe('');
});
});
describe('safeSetInnerHTML', () => {
test('should use DOMPurify when allowHtmlTags is true', () => {
const { safeSetInnerHTML } = global.window;
const element = {
innerHTML: '',
textContent: ''
};
const htmlContent = '<span class="ldr-highlight">test</span>';
safeSetInnerHTML(element, htmlContent, true);
expect(mockDOMPurify.sanitize).toHaveBeenCalled();
expect(element.innerHTML).toBe(htmlContent);
});
test('should use textContent when allowHtmlTags is false', () => {
const { safeSetInnerHTML } = global.window;
const element = {
innerHTML: '',
textContent: ''
};
const content = 'plain text';
safeSetInnerHTML(element, content, false);
expect(element.textContent).toBe(content);
});
test('should handle null element gracefully', () => {
const { safeSetInnerHTML } = global.window;
expect(() => safeSetInnerHTML(null, 'content')).not.toThrow();
});
test('should handle null content gracefully', () => {
const { safeSetInnerHTML } = global.window;
const element = { innerHTML: '', textContent: '' };
expect(() => safeSetInnerHTML(element, null)).not.toThrow();
});
});
describe('safeSetTextContent', () => {
test('should set textContent safely', () => {
const { safeSetTextContent } = global.window;
const element = { textContent: '' };
safeSetTextContent(element, '<script>alert("xss")</script>');
expect(element.textContent).toBe('<script>alert("xss")</script>');
});
test('should handle null element gracefully', () => {
const { safeSetTextContent } = global.window;
expect(() => safeSetTextContent(null, 'content')).not.toThrow();
});
});
describe('sanitizeHtml', () => {
test('should use DOMPurify to sanitize HTML', () => {
const { sanitizeHtml } = global.window;
const dirty = '<script>alert("xss")</script><b>safe</b>';
const result = sanitizeHtml(dirty);
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
dirty,
expect.any(Object)
);
});
test('should return empty string for empty input', () => {
const { sanitizeHtml } = global.window;
expect(sanitizeHtml('')).toBe('');
expect(sanitizeHtml(null)).toBe('');
expect(sanitizeHtml(undefined)).toBe('');
});
});
});
describe('Dropdown Highlight Rendering', () => {
describe('Highlight HTML Structure', () => {
test('highlight span should have correct class', () => {
const highlightHtml = '<span class="ldr-highlight">search</span>';
expect(highlightHtml).toContain('class="ldr-highlight"');
});
test('highlight pattern should match search terms', () => {
// Simulate the highlightText function from custom_dropdown.js (indexOf implementation)
const showAllOptions = false;
function highlightText(text, search) {
if (!search.trim() || showAllOptions) return text;
const lowerText = text.toLowerCase();
const lowerSearch = search.toLowerCase();
let result = '';
let lastIdx = 0;
let idx;
while ((idx = lowerText.indexOf(lowerSearch, lastIdx)) !== -1) {
result += text.substring(lastIdx, idx);
result += '<span class="ldr-highlight">' + text.substring(idx, idx + search.length) + '</span>';
lastIdx = idx + search.length;
}
result += text.substring(lastIdx);
return result;
}
const result = highlightText('bytedance-seed/seed-1.6-flash-h', 'flas');
expect(result).toBe('bytedance-seed/seed-1.6-<span class="ldr-highlight">flas</span>h-h');
});
test('highlight should be case-insensitive', () => {
const showAllOptions = false;
function highlightText(text, search) {
if (!search.trim() || showAllOptions) return text;
const lowerText = text.toLowerCase();
const lowerSearch = search.toLowerCase();
let result = '';
let lastIdx = 0;
let idx;
while ((idx = lowerText.indexOf(lowerSearch, lastIdx)) !== -1) {
result += text.substring(lastIdx, idx);
result += '<span class="ldr-highlight">' + text.substring(idx, idx + search.length) + '</span>';
lastIdx = idx + search.length;
}
result += text.substring(lastIdx);
return result;
}
const result = highlightText('FLASH Model', 'flash');
expect(result).toContain('ldr-highlight');
expect(result).toContain('FLASH');
});
test('highlight should handle multiple matches', () => {
const showAllOptions = false;
function highlightText(text, search) {
if (!search.trim() || showAllOptions) return text;
const lowerText = text.toLowerCase();
const lowerSearch = search.toLowerCase();
let result = '';
let lastIdx = 0;
let idx;
while ((idx = lowerText.indexOf(lowerSearch, lastIdx)) !== -1) {
result += text.substring(lastIdx, idx);
result += '<span class="ldr-highlight">' + text.substring(idx, idx + search.length) + '</span>';
lastIdx = idx + search.length;
}
result += text.substring(lastIdx);
return result;
}
const result = highlightText('test test test', 'test');
const matches = result.match(/ldr-highlight/g);
expect(matches).toHaveLength(3);
});
test('highlight should handle special characters in search without error', () => {
const showAllOptions = false;
function highlightText(text, search) {
if (!search.trim() || showAllOptions) return text;
const lowerText = text.toLowerCase();
const lowerSearch = search.toLowerCase();
let result = '';
let lastIdx = 0;
let idx;
while ((idx = lowerText.indexOf(lowerSearch, lastIdx)) !== -1) {
result += text.substring(lastIdx, idx);
result += '<span class="ldr-highlight">' + text.substring(idx, idx + search.length) + '</span>';
lastIdx = idx + search.length;
}
result += text.substring(lastIdx);
return result;
}
// indexOf naturally handles special characters — no regex to break
expect(() => highlightText('test (parentheses)', '(paren')).not.toThrow();
expect(() => highlightText('test [brackets]', '[brack')).not.toThrow();
expect(() => highlightText('test.dot', '.')).not.toThrow();
});
});
describe('DOMPurify Sanitization Config', () => {
test('should allow span tags', () => {
const { sanitizeHtml } = global.window;
const input = '<span class="ldr-highlight">text</span>';
sanitizeHtml(input);
// Verify DOMPurify was called with config that allows span
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
input,
expect.objectContaining({
ALLOWED_TAGS: expect.arrayContaining(['span'])
})
);
});
test('should allow class attribute', () => {
const { sanitizeHtml } = global.window;
const input = '<span class="ldr-highlight">text</span>';
sanitizeHtml(input);
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
input,
expect.objectContaining({
ALLOWED_ATTR: expect.arrayContaining(['class'])
})
);
});
test('should forbid script tags', () => {
const { sanitizeHtml } = global.window;
const input = '<script>alert("xss")</script>';
sanitizeHtml(input);
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
input,
expect.objectContaining({
FORBID_TAGS: expect.arrayContaining(['script'])
})
);
});
test('should forbid event handlers', () => {
const { sanitizeHtml } = global.window;
const input = '<div onclick="alert()">test</div>';
sanitizeHtml(input);
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
input,
expect.objectContaining({
FORBID_ATTR: expect.arrayContaining(['onclick'])
})
);
});
});
});
describe('App.js DOMPurify Export', () => {
test('DOMPurify should be exported to window object', () => {
// This test verifies the fix - DOMPurify must be on window
// for safeSetInnerHTML to use it instead of falling back to textContent
expect(global.window.DOMPurify).toBeDefined();
expect(typeof global.window.DOMPurify.sanitize).toBe('function');
});
});
describe('DOMPurify KEEP_CONTENT Configuration', () => {
test('should preserve text content when sanitizing highlighted dropdown items', () => {
const { sanitizeHtml } = global.window;
// Simulate a model name with highlighting like in dropdowns
const modelWithHighlight = 'google/<span class="ldr-highlight">gemma</span>-3n-e2b-it:free (OPENROUTER)';
const result = sanitizeHtml(modelWithHighlight);
// Verify DOMPurify was called with KEEP_CONTENT: true
// This ensures text content is preserved even if tags are processed
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
modelWithHighlight,
expect.objectContaining({
KEEP_CONTENT: true
})
);
});
test('sanitized output should contain original text content', () => {
// The mock preserves content, simulating KEEP_CONTENT: true behavior
const input = 'google/<span class="ldr-highlight">gemma</span>-3n-e2b-it:free';
const result = mockDOMPurify.sanitize(input, {});
// Text content should be preserved, not empty
expect(result).toContain('google/');
expect(result).toContain('gemma');
expect(result).toContain('-3n-e2b-it:free');
});
test('should handle complex model names with special characters', () => {
const { sanitizeHtml } = global.window;
// Test various model name formats that appear in dropdowns
const testCases = [
'meta-llama/llama-3.3-70b-instruct:free',
'anthropic/claude-3.5-sonnet:beta',
'openai/gpt-4o-2024-11-20',
'bytedance-seed/seed-1.6-flash-h'
];
testCases.forEach(modelName => {
const highlighted = modelName.replace(/(llama|claude|gpt|seed)/gi,
'<span class="ldr-highlight">$1</span>');
const result = sanitizeHtml(highlighted);
// Should call sanitize and preserve content
expect(mockDOMPurify.sanitize).toHaveBeenCalled();
});
});
});
describe('Dynamic DOMPurify Detection', () => {
test('safeSetInnerHTML should check for DOMPurify availability at call time', () => {
const { safeSetInnerHTML } = global.window;
const element = { innerHTML: '', textContent: '' };
// Clear previous calls
mockDOMPurify.sanitize.mockClear();
// Call safeSetInnerHTML with HTML content
safeSetInnerHTML(element, '<span class="ldr-highlight">test</span>', true);
// DOMPurify should be used since it's available
expect(mockDOMPurify.sanitize).toHaveBeenCalled();
});
test('safeSetInnerHTML should work even if DOMPurify loaded after script init', () => {
// This test verifies the dynamic check behavior
// In production, Vite modules load after regular scripts
// The hasDOMPurify() function checks availability at call time, not load time
const { safeSetInnerHTML } = global.window;
const element = { innerHTML: '', textContent: '' };
// Simulate calling after DOMPurify is available
expect(global.DOMPurify).toBeDefined();
safeSetInnerHTML(element, '<b>test</b>', true);
// Should use DOMPurify, not fall back to textContent
expect(mockDOMPurify.sanitize).toHaveBeenCalled();
expect(element.innerHTML).toBeTruthy();
});
});