Merge branch 'fix/201-wikilink-xss-protection' into develop
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
2026-02-03 23:00:04 -06:00
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("");
});
});
});

View File

@@ -0,0 +1,20 @@
# QA Remediation Report
**File:** /home/jwoltje/src/mosaic-stack/apps/web/src/components/knowledge/WikiLinkRenderer.tsx
**Tool Used:** Edit
**Epic:** general
**Iteration:** 1
**Generated:** 2026-02-03 22:57:25
## Status
Pending QA validation
## Next Steps
This report was created by the QA automation hook.
To process this report, run:
```bash
claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-WikiLinkRenderer.tsx_20260203-2257_1_remediation_needed.md"
```

View File

@@ -0,0 +1,20 @@
# QA Remediation Report
**File:** /home/jwoltje/src/mosaic-stack/apps/web/src/components/knowledge/WikiLinkRenderer.tsx
**Tool Used:** Edit
**Epic:** general
**Iteration:** 2
**Generated:** 2026-02-03 22:57:44
## Status
Pending QA validation
## Next Steps
This report was created by the QA automation hook.
To process this report, run:
```bash
claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-web-src-components-knowledge-WikiLinkRenderer.tsx_20260203-2257_2_remediation_needed.md"
```

View File

@@ -0,0 +1,20 @@
# QA Remediation Report
**File:** /home/jwoltje/src/mosaic-stack/apps/web/src/components/knowledge/**tests**/WikiLinkRenderer.test.tsx
**Tool Used:** Edit
**Epic:** general
**Iteration:** 1
**Generated:** 2026-02-03 22:57:12
## Status
Pending QA validation
## Next Steps
This report was created by the QA automation hook.
To process this report, run:
```bash
claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/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"
```

View File

@@ -0,0 +1,20 @@
# QA Remediation Report
**File:** /home/jwoltje/src/mosaic-stack/apps/web/src/components/knowledge/**tests**/WikiLinkRenderer.test.tsx
**Tool Used:** Edit
**Epic:** general
**Iteration:** 1
**Generated:** 2026-02-03 22:58:04
## Status
Pending QA validation
## Next Steps
This report was created by the QA automation hook.
To process this report, run:
```bash
claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/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"
```

View File

@@ -0,0 +1,20 @@
# QA Remediation Report
**File:** /home/jwoltje/src/mosaic-stack/apps/web/src/components/knowledge/**tests**/WikiLinkRenderer.test.tsx
**Tool Used:** Edit
**Epic:** general
**Iteration:** 2
**Generated:** 2026-02-03 22:58:10
## Status
Pending QA validation
## Next Steps
This report was created by the QA automation hook.
To process this report, run:
```bash
claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/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"
```

View File

@@ -0,0 +1,20 @@
# QA Remediation Report
**File:** /home/jwoltje/src/mosaic-stack/apps/web/src/components/knowledge/**tests**/WikiLinkRenderer.test.tsx
**Tool Used:** Edit
**Epic:** general
**Iteration:** 3
**Generated:** 2026-02-03 22:58:17
## Status
Pending QA validation
## Next Steps
This report was created by the QA automation hook.
To process this report, run:
```bash
claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/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"
```

View File

@@ -0,0 +1,20 @@
# QA Remediation Report
**File:** /home/jwoltje/src/mosaic-stack/apps/web/src/components/knowledge/**tests**/WikiLinkRenderer.test.tsx
**Tool Used:** Edit
**Epic:** general
**Iteration:** 4
**Generated:** 2026-02-03 22:58:27
## Status
Pending QA validation
## Next Steps
This report was created by the QA automation hook.
To process this report, run:
```bash
claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/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"
```

View File

@@ -0,0 +1,20 @@
# QA Remediation Report
**File:** /home/jwoltje/src/mosaic-stack/apps/web/src/components/knowledge/**tests**/WikiLinkRenderer.test.tsx
**Tool Used:** Edit
**Epic:** general
**Iteration:** 5
**Generated:** 2026-02-03 22:58:44
## Status
Pending QA validation
## Next Steps
This report was created by the QA automation hook.
To process this report, run:
```bash
claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/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"
```

View File

@@ -0,0 +1,20 @@
# QA Remediation Report
**File:** /home/jwoltje/src/mosaic-stack/apps/web/src/components/knowledge/**tests**/WikiLinkRenderer.test.tsx
**Tool Used:** Edit
**Epic:** general
**Iteration:** 1
**Generated:** 2026-02-03 22:59:06
## Status
Pending QA validation
## Next Steps
This report was created by the QA automation hook.
To process this report, run:
```bash
claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/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"
```

View File

@@ -0,0 +1,80 @@
# Issue #201: Enhance WikiLink XSS protection
## Objective
Add comprehensive XSS validation for wiki-style links [[link]] to prevent all attack vectors.
## Current State
- WikiLinkRenderer already has basic XSS protection:
- Validates slug format with regex
- Escapes HTML in display text
- Has 1 XSS test for script tags
- Need to enhance with comprehensive attack vector testing
## Attack Vectors to Test
1. `[[javascript:alert(1)|Click here]]` - JavaScript URLs in slug
2. `[[data:text/html,<script>alert(1)</script>|Link]]` - Data URLs in slug
3. `[[valid-link|<img src=x onerror=alert(1)>]]` - Event handlers in display text
4. `[[valid-link|<iframe src=evil.com>]]` - Iframe injection in display text
5. `[[<script>alert(1)</script>|text]]` - Script in slug
6. `[[vbscript:alert(1)|text]]` - VBScript URLs
7. HTML entity bypass attempts
## Implementation Plan
- [x] Create scratchpad
- [x] Add comprehensive XSS tests (TDD) - 11 new tests
- [x] Enhance slug validation
- [x] Enhance display text sanitization
- [x] Verify all tests pass (25/25 passing)
- [x] Check 85%+ coverage
- [x] Type checking passing
- [x] Commit and close issue
## Changes Made
### WikiLinkRenderer.tsx
- Imported DOMPurify
- Added `isValidWikiLinkSlug()` function:
- Rejects empty slugs
- Blocks dangerous protocols (javascript:, data:, vbscript:, file:, about:, blob:)
- Blocks URL-encoded dangerous protocols
- Blocks HTML tags in slugs
- Blocks HTML entities in slugs
- Only allows safe characters: a-z, A-Z, 0-9, -, \_, ., /
- Enhanced `parseWikiLinks()` to use DOMPurify for display text:
- ALLOWED_TAGS: [] (no HTML allowed)
- KEEP_CONTENT: true (preserves text content)
- Completely strips all HTML from display text
### WikiLinkRenderer.test.tsx
- Added 11 comprehensive XSS attack tests:
- javascript: URLs in slug
- data: URLs in slug
- vbscript: URLs in slug
- Event handlers in display text
- iframe injection
- script tags in slug
- onclick handlers
- HTML entity bypass
- URL-encoded protocols
- SVG with scripts
- object/embed tags
- All 25 tests passing (14 existing + 11 new)
## Security Improvements
- Defense-in-depth: Multiple layers of validation
- Slug validation prevents malicious URLs at source
- DOMPurify removes all HTML from display text
- HTML escaping as final layer
- Comprehensive test coverage for all attack vectors
## Files to Modify
- apps/web/src/components/knowledge/WikiLinkRenderer.tsx
- apps/web/src/components/knowledge/**tests**/WikiLinkRenderer.test.tsx