/* eslint-disable security/detect-unsafe-regex */ "use client"; import React from "react"; import DOMPurify from "dompurify"; interface WikiLinkRendererProps { /** HTML content with wiki-links to parse */ html: string; /** Additional CSS classes */ className?: string; } /** * WikiLinkRenderer - Parses and renders wiki-links in HTML content * * Converts: * - [[slug]] → clickable link to /knowledge/slug * - [[slug|display text]] → clickable link with custom text * * Features: * - Distinct styling for wiki-links (blue color, underline) * - Graceful handling of broken links (gray out) * - Preserves all other HTML formatting */ export function WikiLinkRenderer({ html, className = "", }: WikiLinkRendererProps): React.ReactElement { const processedHtml = React.useMemo(() => { // SEC-WEB-2 FIX: Sanitize ENTIRE HTML input BEFORE processing wiki-links // This prevents stored XSS via knowledge entry content const sanitizedHtml = DOMPurify.sanitize(html, { // Allow common formatting tags that are safe ALLOWED_TAGS: [ "p", "br", "strong", "b", "em", "i", "u", "s", "strike", "del", "ins", "mark", "small", "sub", "sup", "code", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "hr", "span", "div", ], // Allow safe attributes only ALLOWED_ATTR: ["class", "id", "title", "lang", "dir"], // Remove any data: or javascript: URIs ALLOW_DATA_ATTR: false, }); return parseWikiLinks(sanitizedHtml); }, [html]); return (
); } /** * Parse wiki-links in HTML and convert to anchor tags * * Supports: * - [[slug]] - basic link * - [[slug|display text]] - link with custom display text */ function parseWikiLinks(html: string): string { // Match [[...]] patterns // Group 1: target slug // Group 2: optional display text after | const wikiLinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; return html.replace(wikiLinkRegex, (match, slug: string, displayText?: string) => { const trimmedSlug = slug.trim(); const text = displayText?.trim() ?? trimmedSlug; // 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(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 */ function handleWikiLinkClick(e: React.MouseEvent