From 9204b579230a172dd1de348322ccb3fa7b2200d5 Mon Sep 17 00:00:00 2001 From: Francisco de Guzman <17106076+franciszver@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:45:16 -0700 Subject: [PATCH 1/3] =?UTF-8?q?test(security):=20red=20=E2=80=94=20XSS=20s?= =?UTF-8?q?inks=20unsanitized,=20javascript:=20href=20unblocked=20(#47=20#?= =?UTF-8?q?48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Claude Code (Sonnet) Co-Authored-By: Claude Fable 5 --- package-lock.json | 28 +++++++++ package.json | 2 + src/test/security-xss.test.tsx | 111 +++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 src/test/security-xss.test.tsx 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/test/security-xss.test.tsx b/src/test/security-xss.test.tsx new file mode 100644 index 0000000..9f1f81d --- /dev/null +++ b/src/test/security-xss.test.tsx @@ -0,0 +1,111 @@ +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 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: 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(); + }); +}); From 73adaefd78129052c4df7eb5907af1cc5fe09356 Mon Sep 17 00:00:00 2001 From: Francisco de Guzman <17106076+franciszver@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:46:39 -0700 Subject: [PATCH 2/3] fix(security): sanitize clause/citation HTML + block non-http(s) hrefs (#47 #48) Assisted-by: Claude Code (Sonnet) Co-Authored-By: Claude Fable 5 --- src/components/CitationBrowser.tsx | 6 +++-- src/components/ClauseBrowser.tsx | 5 ++-- src/components/ClauseIntegration.tsx | 5 ++-- src/components/ClauseLibrary.tsx | 3 ++- .../RightPanel/SuggestionsPanel.tsx | 8 +++--- src/test/security-xss.test.tsx | 2 +- src/utils/citationFormatter.ts | 25 +++++++++++++++---- src/utils/safeHref.ts | 12 +++++++++ 8 files changed, 50 insertions(+), 16 deletions(-) create mode 100644 src/utils/safeHref.ts 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 (
{ const html = citationToHtml(citation); expect(html).not.toContain(' tag', () => { 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; +} From eb44cc7327628c14577eb17dec5076bf81558b0c Mon Sep 17 00:00:00 2001 From: Francisco de Guzman <17106076+franciszver@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:51:21 -0700 Subject: [PATCH 3/3] fix(security): sanitize remaining HTML sinks + guard CitationManager href (#47 #48) Assisted-by: Claude Code (Sonnet) Co-Authored-By: Claude Fable 5 --- src/components/CitationManager.tsx | 3 +- src/components/DocumentFormatter.tsx | 7 ++-- src/components/TemplateEditor.tsx | 5 +-- src/test/security-xss.test.tsx | 48 ++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/components/CitationManager.tsx b/src/components/CitationManager.tsx index f642372..7aa148e 100644 --- a/src/components/CitationManager.tsx +++ b/src/components/CitationManager.tsx @@ -39,6 +39,7 @@ import { getCitationTypeCounts, } from '../utils/citationService'; import { buildCitationString, parseCitationString, validateCitation } from '../utils/citationFormatter'; +import { safeHref } from '../utils/safeHref'; // ============================================ // Type Icons @@ -760,7 +761,7 @@ export function CitationManager() {
{citation.url && ( Document Preview
2000 ? '...' : ''), - ].join('
') + ].join('
')) }} />
diff --git a/src/components/TemplateEditor.tsx b/src/components/TemplateEditor.tsx index 1e46e18..58b50cb 100644 --- a/src/components/TemplateEditor.tsx +++ b/src/components/TemplateEditor.tsx @@ -4,6 +4,7 @@ */ import { useState, useCallback, useMemo, useEffect } from 'react'; +import DOMPurify from 'dompurify'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import { @@ -822,9 +823,9 @@ export function TemplateEditor({ )} {/* Preview Content */} -
{/* Placeholder Styling */} diff --git a/src/test/security-xss.test.tsx b/src/test/security-xss.test.tsx index 9c59027..5ce3e84 100644 --- a/src/test/security-xss.test.tsx +++ b/src/test/security-xss.test.tsx @@ -3,6 +3,7 @@ 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'; // ============================================ @@ -87,6 +88,53 @@ describe('citationToHtml escapes interpolated citation fields', () => { }); }); +// ============================================ +// 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 // ============================================