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
412 lines
16 KiB
JavaScript
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('<script>');
|
|
expect(escapeHtml('a & b')).toBe('a & b');
|
|
expect(escapeHtml('"quoted"')).toBe('"quoted"');
|
|
expect(escapeHtml("'single'")).toBe(''single'');
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|