Files
stack/apps/web/src/components/knowledge/WikiLinkRenderer.tsx
T
Jason WoltjeandClaude Opus 4.5 aa14b580b3 fix(#337): Sanitize HTML before wiki-link processing in WikiLinkRenderer
- Apply DOMPurify to entire HTML input before parseWikiLinks()
- Prevents stored XSS via knowledge entry content (SEC-WEB-2)
- Allow safe formatting tags (p, strong, em, etc.) but strip scripts, iframes, event handlers
- Update tests to reflect new sanitization behavior

Refs #337

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-05 15:25:57 -06:00

225 lines
5.8 KiB
TypeScript

/* 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 (
<div
className={`wiki-link-content ${className}`}
dangerouslySetInnerHTML={{ __html: processedHtml }}
onClick={handleWikiLinkClick}
/>
);
}
/**
* 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 `<a
href="/knowledge/${encodeURIComponent(trimmedSlug)}"
data-wiki-link="true"
data-slug="${encodeURIComponent(trimmedSlug)}"
class="wiki-link text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline decoration-dotted hover:decoration-solid transition-colors"
title="Go to ${escapeHtml(trimmedSlug)}"
>${escapeHtml(sanitizedText)}</a>`;
});
}
/**
* 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<HTMLDivElement>): void {
const target = e.target as HTMLElement;
// Check if the clicked element is a wiki-link
if (target.tagName === "A" && target.dataset.wikiLink === "true") {
const href = target.getAttribute("href");
if (href?.startsWith("/knowledge/")) {
// Let Next.js Link handle navigation naturally
// No need to preventDefault - the href will work
}
}
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
/**
* Custom hook to check if a wiki-link target exists
* (For future enhancement - mark broken links differently)
*/
export function useWikiLinkValidation(_slug: string): {
isValid: boolean;
isLoading: boolean;
} {
// Placeholder for future implementation
// Could fetch from /api/knowledge/entries/:slug to check existence
return {
isValid: true,
isLoading: false,
};
}