diff --git a/package-lock.json b/package-lock.json index b6daf8b..dbc90c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tiptap/starter-kit": "^3.13.0", "classnames": "^2.5.1", "date-fns": "^3.6.0", + "dompurify": "^3.4.12", "file-saver": "^2.0.5", "html-to-docx": "^1.8.0", "lodash-es": "^4.17.21", @@ -31,6 +32,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/dompurify": "^3.0.5", "@types/file-saver": "^2.0.7", "@types/html-to-docx": "^1.8.0", "@types/lodash-es": "^4.17.12", @@ -2685,6 +2687,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2783,6 +2795,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -4287,6 +4306,15 @@ "domelementtype": "1" } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", diff --git a/package.json b/package.json index ccb01aa..986df7e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@tiptap/starter-kit": "^3.13.0", "classnames": "^2.5.1", "date-fns": "^3.6.0", + "dompurify": "^3.4.12", "file-saver": "^2.0.5", "html-to-docx": "^1.8.0", "lodash-es": "^4.17.21", @@ -34,6 +35,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/dompurify": "^3.0.5", "@types/file-saver": "^2.0.7", "@types/html-to-docx": "^1.8.0", "@types/lodash-es": "^4.17.12", diff --git a/src/components/CitationBrowser.tsx b/src/components/CitationBrowser.tsx index e1829ab..a9f4a3e 100644 --- a/src/components/CitationBrowser.tsx +++ b/src/components/CitationBrowser.tsx @@ -4,6 +4,7 @@ */ import { useState, useEffect, useCallback, useMemo } from 'react'; +import DOMPurify from 'dompurify'; import { Search, Filter, @@ -47,6 +48,7 @@ import { } from '../utils/citationService'; import type { CitationStyle } from '../utils/citationTypes'; import { formatCitation, citationToHtml } from '../utils/citationFormatter'; +import { safeHref } from '../utils/safeHref'; // ============================================ // Type Icons @@ -207,7 +209,7 @@ function CitationPreview({ citation, style, onInsert, onClose, onCopyToClipboard
- +
)} -
)} @@ -594,7 +596,7 @@ function SuggestionCard({ suggestion, isCollapsed, isArchived, onPin, onToggleCo return (
{/* Placeholder Styling */} diff --git a/src/test/security-xss.test.tsx b/src/test/security-xss.test.tsx new file mode 100644 index 0000000..5ce3e84 --- /dev/null +++ b/src/test/security-xss.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import DOMPurify from 'dompurify'; +import { citationToHtml } from '../utils/citationFormatter'; +import { safeHref } from '../utils/safeHref'; +import { generatePreview } from '../utils/placeholderResolver'; +import type { Citation } from '../utils/citationTypes'; + +// ============================================ +// Test Data +// ============================================ + +const IMG_PAYLOAD = ''; +const SCRIPT_PAYLOAD = ''; + +const createMockCitation = (overrides: Partial = {}): Citation => ({ + id: 'cite-1', + title: 'Brown v. Board of Education', + citation: 'Brown v. Board of Education, 347 U.S. 483 (1954)', + type: 'case', + court: 'Supreme Court of the United States', + year: 1954, + volume: '347', + reporter: 'U.S.', + page: '483', + pinpoint: '495', + tags: [], + usageCount: 0, + isVerified: true, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ...overrides, +}); + +// ============================================ +// Tests: dangerouslySetInnerHTML sinks must sanitize +// ============================================ + +describe('XSS sinks are sanitized before dangerouslySetInnerHTML', () => { + it('strips onerror attributes from untrusted HTML content (img payload)', () => { + const { container } = render( + // eslint-disable-next-line react/no-danger +
+ ); + + expect(container.querySelector('[onerror]')).toBeNull(); + expect((window as unknown as { __xss?: number }).__xss).toBeUndefined(); + }); + + it('strips script elements from untrusted HTML content', () => { + const { container } = render( + // eslint-disable-next-line react/no-danger +
+ ); + + expect(container.querySelector('script')).toBeNull(); + }); + + it('un-sanitized rendering (regression guard showing the raw sink is unsafe) still contains onerror', () => { + // This documents the vulnerability: raw content, without sanitization, + // retains the dangerous attribute. Sanitized output (above) must not. + const div = document.createElement('div'); + div.innerHTML = IMG_PAYLOAD; + expect(div.querySelector('[onerror]')).not.toBeNull(); + }); +}); + +// ============================================ +// Tests: citationToHtml must escape interpolated fields +// ============================================ + +describe('citationToHtml escapes interpolated citation fields', () => { + it('escapes an XSS payload injected via the parenthetical field', () => { + const citation = createMockCitation({ + parenthetical: '">', + }); + + const html = citationToHtml(citation); + + expect(html).not.toContain(' tag', () => { + const citation = createMockCitation(); + const html = citationToHtml(citation); + expect(html).toContain('Brown v. Board of Education'); + }); +}); + +// ============================================ +// Tests: TemplateEditor preview sink (generatePreview -> DOMPurify.sanitize) +// ============================================ + +describe('generatePreview output is safe once sanitized (TemplateEditor preview sink)', () => { + it('a resolved placeholder value containing an XSS payload is stripped of onerror after sanitization', () => { + const preview = generatePreview( + 'Client: {{clientName}}', + { clientName: '' } + ); + + // Document that generatePreview itself does not escape/sanitize (defense-in-depth + // lives at the render sink, per TemplateEditor.tsx wrapping with DOMPurify.sanitize). + expect(preview).toContain('onerror='); + + const sanitized = DOMPurify.sanitize(preview); + const { container } = render( + // eslint-disable-next-line react/no-danger +
+ ); + + expect(container.querySelector('[onerror]')).toBeNull(); + expect(sanitized).not.toContain('onerror'); + }); +}); + +// ============================================ +// Tests: DocumentFormatter preview sink (join('
') -> DOMPurify.sanitize) +// ============================================ + +describe('DocumentFormatter preview join is safe once sanitized, and keeps
separators', () => { + it('strips a script payload injected via the document body while preserving the
separator', () => { + const caption = 'IN THE SUPERIOR COURT'; + const body = 'Body text'; + + const sanitized = DOMPurify.sanitize([caption, body].join('
')); + const { container } = render( + // eslint-disable-next-line react/no-danger +
+ ); + + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('hr')).not.toBeNull(); + expect(container.textContent).toContain('Body text'); + }); +}); + +// ============================================ +// Tests: safeHref blocks non-http(s) URLs +// ============================================ + +describe('safeHref', () => { + it('returns undefined for javascript: URLs', () => { + expect(safeHref('javascript:alert(1)')).toBeUndefined(); + }); + + it('returns undefined for data: URLs', () => { + expect(safeHref('data:text/html,')).toBeUndefined(); + }); + + it('returns the url for http(s) URLs', () => { + expect(safeHref('https://example.com')).toBe('https://example.com'); + expect(safeHref('http://example.com')).toBe('http://example.com'); + }); + + it('returns undefined for an undefined input', () => { + expect(safeHref(undefined)).toBeUndefined(); + }); +}); diff --git a/src/utils/citationFormatter.ts b/src/utils/citationFormatter.ts index c0936fa..cfe8a36 100644 --- a/src/utils/citationFormatter.ts +++ b/src/utils/citationFormatter.ts @@ -482,18 +482,33 @@ export function validateCitation(citation: Partial): { valid: boolean; }; } +/** + * Escape HTML-significant characters in a string. + * Used to defend against XSS when interpolating user-supplied citation + * fields into HTML output. + */ +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + /** * Generate HTML for displaying a formatted citation */ export function citationToHtml(citation: Citation, style: CitationStyle = 'bluebook'): string { const formatted = formatCitation(citation, style); - + const escapedFull = escapeHtml(formatted.full); + // For cases, italicize the case name if (citation.type === 'case') { - const caseName = citation.title; - return formatted.full.replace(caseName, `${caseName}`); + const escapedCaseName = escapeHtml(citation.title); + return escapedFull.replace(escapedCaseName, `${escapedCaseName}`); } - - return formatted.full; + + return escapedFull; } diff --git a/src/utils/safeHref.ts b/src/utils/safeHref.ts new file mode 100644 index 0000000..d7945c1 --- /dev/null +++ b/src/utils/safeHref.ts @@ -0,0 +1,12 @@ +/** + * Returns the given URL only if it is a safe http(s) link, otherwise undefined. + * Blocks javascript:, data:, and other potentially dangerous URL schemes. + */ +export function safeHref(url?: string): string | undefined { + if (!url) return undefined; + const trimmed = url.trim(); + if (/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + return undefined; +}