Files
stack/apps/web/src/components/knowledge/WikiLinkRenderer.tsx
Jason Woltje b42c86360b fix(#190,#191): fix XSS vulnerabilities in Mermaid and WikiLink rendering
CRITICAL SECURITY FIXES for two XSS vulnerabilities

Mermaid XSS Fix (#190):
- Changed securityLevel from "loose" to "strict"
- Disabled htmlLabels to prevent HTML injection
- Blocks script execution and event handlers in SVG output

WikiLink XSS Fix (#191):
- Added alphanumeric whitelist validation for slugs
- Escape HTML entities in title attribute
- Reject slugs with special characters that could break attributes
- Return escaped text for invalid slugs

Security Impact:
- Prevents account takeover via cookie theft
- Blocks malicious script execution in user browsers
- Enforces strict content security for user-provided content

Fixes #190, #191

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 12:05:33 -06:00

118 lines
3.3 KiB
TypeScript

/* eslint-disable security/detect-unsafe-regex */
"use client";
import React from "react";
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(() => {
return parseWikiLinks(html);
}, [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;
// Validate slug contains only safe characters
if (!/^[a-zA-Z0-9\-_./]+$/.test(trimmedSlug)) {
// Invalid slug - return original text without creating a link
return escapeHtml(match);
}
// 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(text)}</a>`;
});
}
/**
* 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,
};
}