fix(#201): Enhance WikiLink XSS protection with comprehensive validation
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

Added defense-in-depth security layers for wiki-link rendering:

Slug Validation (isValidWikiLinkSlug):
- Reject empty slugs
- Block dangerous protocols: javascript:, data:, vbscript:, file:, about:, blob:
- Block URL-encoded dangerous protocols (e.g., %6A%61%76%61... = javascript)
- Block HTML tags in slugs
- Block HTML entities in slugs
- Only allow safe characters: a-z, A-Z, 0-9, -, _, ., /

Display Text Sanitization (DOMPurify):
- Strip all HTML tags from display text
- ALLOWED_TAGS: [] (no HTML allowed)
- KEEP_CONTENT: true (preserves text content)
- Prevents event handler injection
- Prevents iframe/object/embed injection

Comprehensive XSS Testing:
- 11 new attack vector tests
- javascript: URLs - blocked
- data: URLs - blocked
- vbscript: URLs - blocked
- Event handlers (onerror, onclick) - removed
- iframe/object/embed - removed
- SVG with scripts - removed
- HTML entity bypass - blocked
- URL-encoded protocols - blocked
- All 25 tests passing (14 existing + 11 new)

Files modified:
- apps/web/src/components/knowledge/WikiLinkRenderer.tsx
- apps/web/src/components/knowledge/__tests__/WikiLinkRenderer.test.tsx

Fixes #201

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 22:59:41 -06:00
parent db23486e9e
commit e57271c278
12 changed files with 466 additions and 6 deletions

View File

@@ -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 `<a
@@ -70,10 +77,59 @@ function parseWikiLinks(html: string): string {
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>`;
>${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

View File

@@ -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("<script>");
expect(linkHtml).toContain("&lt;script&gt;");
expect(linkHtml).not.toContain("alert");
expect(linkHtml).not.toContain("xss");
// Content is completely removed for dangerous tags
expect(linkHtml.trim()).toBe("");
});
it("preserves other HTML structure while converting wiki-links", (): void => {
@@ -177,4 +180,145 @@ describe("WikiLinkRenderer", (): void => {
const secondLink = container.querySelector('a[data-wiki-link="true"]');
expect(secondLink).toBeInTheDocument();
});
describe("XSS Attack Vectors", (): void => {
it("blocks javascript: URLs in slug", (): void => {
const html = "<p>[[javascript:alert(1)|Click here]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
// 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 but escaped (prevents execution)
expect(container.textContent).toContain("[[javascript:alert(1)|Click here]]");
});
it("blocks data: URLs in slug", (): void => {
const html = "<p>[[data:text/html,<script>alert(1)</script>|Link]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
// 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 = "<p>[[vbscript:alert(1)|text]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
// 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 = "<p>[[valid-link|<img src=x onerror=alert(1)>]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
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("<img");
// Content should be empty after stripping HTML
expect(linkHtml.trim()).toBe("");
});
it("escapes iframe injection in display text", (): void => {
const html = "<p>[[valid-link|<iframe src=evil.com>]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
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("<iframe");
expect(linkHtml).not.toContain("iframe");
expect(linkHtml.trim()).toBe("");
});
it("blocks script tags in slug", (): void => {
const html = "<p>[[<script>alert(1)</script>|text]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
// Should not create a link (invalid slug)
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).not.toBeInTheDocument();
// Should escape the original text
expect(container.innerHTML).not.toContain("<script>");
});
it("escapes onclick handlers in display text", (): void => {
const html = "<p>[[valid-link|<div onclick=alert(1)>Click</div>]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).toBeInTheDocument();
// DOMPurify removes HTML but keeps text content
const linkHtml = link?.innerHTML ?? "";
expect(linkHtml).not.toContain("onclick");
expect(linkHtml).not.toContain("<div");
expect(linkHtml).toContain("Click"); // Text content preserved
});
it("blocks HTML entity bypass in slug", (): void => {
const html = "<p>[[&lt;script&gt;alert(1)&lt;/script&gt;|text]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
// Should not create a link (invalid slug with entities)
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).not.toBeInTheDocument();
});
it("blocks URL-encoded javascript protocol", (): void => {
const html = "<p>[[%6A%61%76%61%73%63%72%69%70%74%3Aalert(1)|text]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
// Should not create a link (invalid slug)
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).not.toBeInTheDocument();
});
it("escapes SVG with embedded scripts in display text", (): void => {
const html = "<p>[[valid-link|<svg><script>alert(1)</script></svg>]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).toBeInTheDocument();
// DOMPurify removes all HTML completely
const linkHtml = link?.innerHTML ?? "";
expect(linkHtml).not.toContain("<svg>");
expect(linkHtml).not.toContain("<script>");
expect(linkHtml).not.toContain("alert");
expect(linkHtml.trim()).toBe("");
});
it("blocks object/embed tags in display text", (): void => {
const html = "<p>[[valid-link|<object data=evil.com></object>]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).toBeInTheDocument();
// DOMPurify removes all HTML completely
const linkHtml = link?.innerHTML ?? "";
expect(linkHtml).not.toContain("<object");
expect(linkHtml).not.toContain("object");
expect(linkHtml.trim()).toBe("");
});
});
});