From e57271c278dfd560eeb233e6444422567b93790e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 3 Feb 2026 22:59:41 -0600 Subject: [PATCH] fix(#201): Enhance WikiLink XSS protection with comprehensive validation Added defense-in-depth security layers for wiki-link rendering: Slug Validation (isValidWikiLinkSlug): - Reject empty slugs - Block dangerous protocols: javascript:, data:, vbscript:, file:, about:, blob: - Block URL-encoded dangerous protocols (e.g., %6A%61%76%61... = javascript) - Block HTML tags in slugs - Block HTML entities in slugs - Only allow safe characters: a-z, A-Z, 0-9, -, _, ., / Display Text Sanitization (DOMPurify): - Strip all HTML tags from display text - ALLOWED_TAGS: [] (no HTML allowed) - KEEP_CONTENT: true (preserves text content) - Prevents event handler injection - Prevents iframe/object/embed injection Comprehensive XSS Testing: - 11 new attack vector tests - javascript: URLs - blocked - data: URLs - blocked - vbscript: URLs - blocked - Event handlers (onerror, onclick) - removed - iframe/object/embed - removed - SVG with scripts - removed - HTML entity bypass - blocked - URL-encoded protocols - blocked - All 25 tests passing (14 existing + 11 new) Files modified: - apps/web/src/components/knowledge/WikiLinkRenderer.tsx - apps/web/src/components/knowledge/__tests__/WikiLinkRenderer.test.tsx Fixes #201 Co-Authored-By: Claude Sonnet 4.5 --- .../components/knowledge/WikiLinkRenderer.tsx | 64 +++++++- .../__tests__/WikiLinkRenderer.test.tsx | 148 +++++++++++++++++- ....tsx_20260203-2257_1_remediation_needed.md | 20 +++ ....tsx_20260203-2257_2_remediation_needed.md | 20 +++ ....tsx_20260203-2257_1_remediation_needed.md | 20 +++ ....tsx_20260203-2258_1_remediation_needed.md | 20 +++ ....tsx_20260203-2258_2_remediation_needed.md | 20 +++ ....tsx_20260203-2258_3_remediation_needed.md | 20 +++ ....tsx_20260203-2258_4_remediation_needed.md | 20 +++ ....tsx_20260203-2258_5_remediation_needed.md | 20 +++ ....tsx_20260203-2259_1_remediation_needed.md | 20 +++ .../201-wikilink-xss-enhancement.md | 80 ++++++++++ 12 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-WikiLinkRenderer.tsx_20260203-2257_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-WikiLinkRenderer.tsx_20260203-2257_2_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2257_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2258_1_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2258_2_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2258_3_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2258_4_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2258_5_remediation_needed.md create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2259_1_remediation_needed.md create mode 100644 docs/scratchpads/201-wikilink-xss-enhancement.md diff --git a/apps/web/src/components/knowledge/WikiLinkRenderer.tsx b/apps/web/src/components/knowledge/WikiLinkRenderer.tsx index 25b5400..ffa3511 100644 --- a/apps/web/src/components/knowledge/WikiLinkRenderer.tsx +++ b/apps/web/src/components/knowledge/WikiLinkRenderer.tsx @@ -2,6 +2,7 @@ "use client"; import React from "react"; +import DOMPurify from "dompurify"; interface WikiLinkRendererProps { /** HTML content with wiki-links to parse */ @@ -56,12 +57,18 @@ function parseWikiLinks(html: string): string { const trimmedSlug = slug.trim(); const text = displayText?.trim() ?? trimmedSlug; - // Validate slug contains only safe characters - if (!/^[a-zA-Z0-9\-_./]+$/.test(trimmedSlug)) { - // Invalid slug - return original text without creating a link + // Enhanced slug validation - reject dangerous protocols and special characters + if (!isValidWikiLinkSlug(trimmedSlug)) { + // Invalid slug - return original text escaped return escapeHtml(match); } + // Sanitize display text with DOMPurify (text-only, no HTML) + const sanitizedText = DOMPurify.sanitize(text, { + ALLOWED_TAGS: [], // No HTML tags allowed in display text + KEEP_CONTENT: true, // Keep the text content + }); + // Create a styled link // Using data-wiki-link attribute for styling and click handling return `${escapeHtml(text)}`; + >${escapeHtml(sanitizedText)}`; }); } +/** + * Validate wiki-link slug + * Rejects dangerous protocols and invalid characters + */ +function isValidWikiLinkSlug(slug: string): boolean { + // Reject empty slugs + if (!slug || slug.length === 0) { + return false; + } + + // Reject dangerous protocols + const dangerousProtocols = ["javascript:", "data:", "vbscript:", "file:", "about:", "blob:"]; + + const lowerSlug = slug.toLowerCase(); + for (const protocol of dangerousProtocols) { + if (lowerSlug.includes(protocol)) { + return false; + } + } + + // Reject URL-encoded dangerous protocols + if (lowerSlug.includes("%")) { + try { + const decoded = decodeURIComponent(lowerSlug); + for (const protocol of dangerousProtocols) { + if (decoded.includes(protocol)) { + return false; + } + } + } catch { + // If decoding fails, reject it + return false; + } + } + + // Reject HTML tags in slug + if (/<[^>]*>/.test(slug)) { + return false; + } + + // Reject HTML entities in slug + if (/&[a-z]+;/i.test(slug)) { + return false; + } + + // Only allow safe characters: alphanumeric, hyphens, underscores, dots, slashes + return /^[a-zA-Z0-9\-_./]+$/.test(slug); +} + /** * Handle wiki-link clicks * Intercepts clicks on wiki-links to use Next.js navigation diff --git a/apps/web/src/components/knowledge/__tests__/WikiLinkRenderer.test.tsx b/apps/web/src/components/knowledge/__tests__/WikiLinkRenderer.test.tsx index c6e9f6b..03ffb47 100644 --- a/apps/web/src/components/knowledge/__tests__/WikiLinkRenderer.test.tsx +++ b/apps/web/src/components/knowledge/__tests__/WikiLinkRenderer.test.tsx @@ -75,10 +75,13 @@ describe("WikiLinkRenderer", (): void => { const link = container.querySelector('a[data-wiki-link="true"]'); expect(link).toBeInTheDocument(); - // Script tags should be escaped + // Script tags should be removed by DOMPurify (including content) const linkHtml = link?.innerHTML ?? ""; expect(linkHtml).not.toContain("|Link]]

"; + const { container } = render(); + + // Should not create a link (invalid slug) + const link = container.querySelector('a[data-wiki-link="true"]'); + expect(link).not.toBeInTheDocument(); + + // Original text should be preserved (prevents execution) + expect(container.textContent).toContain("[[data:text/html"); + }); + + it("blocks vbscript: URLs in slug", (): void => { + const html = "

[[vbscript:alert(1)|text]]

"; + const { container } = render(); + + // Should not create a link (invalid slug) + const link = container.querySelector('a[data-wiki-link="true"]'); + expect(link).not.toBeInTheDocument(); + + // Original text should be preserved (prevents execution) + expect(container.textContent).toContain("[[vbscript:alert(1)|text]]"); + }); + + it("escapes event handlers in display text", (): void => { + const html = "

[[valid-link|]]

"; + const { container } = render(); + + const link = container.querySelector('a[data-wiki-link="true"]'); + expect(link).toBeInTheDocument(); + + // DOMPurify removes all HTML tags completely + const linkHtml = link?.innerHTML ?? ""; + expect(linkHtml).not.toContain("onerror"); + expect(linkHtml).not.toContain("alert(1)"); + expect(linkHtml).not.toContain(" { + const html = "

[[valid-link|