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|