fix(#201): Enhance WikiLink XSS protection with comprehensive validation
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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:
@@ -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
|
||||
|
||||
@@ -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("<script>");
|
||||
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>[[<script>alert(1)</script>|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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user