From e57271c278dfd560eeb233e6444422567b93790e Mon Sep 17 00:00:00 2001
From: Jason Woltje
Date: Tue, 3 Feb 2026 22:59:41 -0600
Subject: [PATCH] fix(#201): Enhance WikiLink XSS protection with comprehensive
validation
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
---
.../components/knowledge/WikiLinkRenderer.tsx | 64 +++++++-
.../__tests__/WikiLinkRenderer.test.tsx | 148 +++++++++++++++++-
....tsx_20260203-2257_1_remediation_needed.md | 20 +++
....tsx_20260203-2257_2_remediation_needed.md | 20 +++
....tsx_20260203-2257_1_remediation_needed.md | 20 +++
....tsx_20260203-2258_1_remediation_needed.md | 20 +++
....tsx_20260203-2258_2_remediation_needed.md | 20 +++
....tsx_20260203-2258_3_remediation_needed.md | 20 +++
....tsx_20260203-2258_4_remediation_needed.md | 20 +++
....tsx_20260203-2258_5_remediation_needed.md | 20 +++
....tsx_20260203-2259_1_remediation_needed.md | 20 +++
.../201-wikilink-xss-enhancement.md | 80 ++++++++++
12 files changed, 466 insertions(+), 6 deletions(-)
create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-WikiLinkRenderer.tsx_20260203-2257_1_remediation_needed.md
create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-WikiLinkRenderer.tsx_20260203-2257_2_remediation_needed.md
create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2257_1_remediation_needed.md
create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2258_1_remediation_needed.md
create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2258_2_remediation_needed.md
create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2258_3_remediation_needed.md
create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2258_4_remediation_needed.md
create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2258_5_remediation_needed.md
create mode 100644 docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-__tests__-WikiLinkRenderer.test.tsx_20260203-2259_1_remediation_needed.md
create mode 100644 docs/scratchpads/201-wikilink-xss-enhancement.md
diff --git a/apps/web/src/components/knowledge/WikiLinkRenderer.tsx b/apps/web/src/components/knowledge/WikiLinkRenderer.tsx
index 25b5400..ffa3511 100644
--- a/apps/web/src/components/knowledge/WikiLinkRenderer.tsx
+++ b/apps/web/src/components/knowledge/WikiLinkRenderer.tsx
@@ -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 `${escapeHtml(text)}`;
+ >${escapeHtml(sanitizedText)}`;
});
}
+/**
+ * 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
diff --git a/apps/web/src/components/knowledge/__tests__/WikiLinkRenderer.test.tsx b/apps/web/src/components/knowledge/__tests__/WikiLinkRenderer.test.tsx
index c6e9f6b..03ffb47 100644
--- a/apps/web/src/components/knowledge/__tests__/WikiLinkRenderer.test.tsx
+++ b/apps/web/src/components/knowledge/__tests__/WikiLinkRenderer.test.tsx
@@ -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("|Link]]
";
+ const { container } = render();
+
+ // 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 = "[[vbscript:alert(1)|text]]
";
+ const { container } = render();
+
+ // 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 = "[[valid-link|
]]
";
+ const { container } = render();
+
+ 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("
{
+ const html = "[[valid-link|
";
+ const { container } = render();
+
+ 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("