- 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]>
225 lines
5.8 KiB
TypeScript
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,
|
|
};
|
|
}
|