Merge branch 'fix/201-wikilink-xss-protection' into develop
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
interface WikiLinkRendererProps {
|
interface WikiLinkRendererProps {
|
||||||
/** HTML content with wiki-links to parse */
|
/** HTML content with wiki-links to parse */
|
||||||
@@ -56,12 +57,18 @@ function parseWikiLinks(html: string): string {
|
|||||||
const trimmedSlug = slug.trim();
|
const trimmedSlug = slug.trim();
|
||||||
const text = displayText?.trim() ?? trimmedSlug;
|
const text = displayText?.trim() ?? trimmedSlug;
|
||||||
|
|
||||||
// Validate slug contains only safe characters
|
// Enhanced slug validation - reject dangerous protocols and special characters
|
||||||
if (!/^[a-zA-Z0-9\-_./]+$/.test(trimmedSlug)) {
|
if (!isValidWikiLinkSlug(trimmedSlug)) {
|
||||||
// Invalid slug - return original text without creating a link
|
// Invalid slug - return original text escaped
|
||||||
return escapeHtml(match);
|
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
|
// Create a styled link
|
||||||
// Using data-wiki-link attribute for styling and click handling
|
// Using data-wiki-link attribute for styling and click handling
|
||||||
return `<a
|
return `<a
|
||||||
@@ -70,10 +77,59 @@ function parseWikiLinks(html: string): string {
|
|||||||
data-slug="${encodeURIComponent(trimmedSlug)}"
|
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"
|
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)}"
|
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
|
* Handle wiki-link clicks
|
||||||
* Intercepts clicks on wiki-links to use Next.js navigation
|
* 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"]');
|
const link = container.querySelector('a[data-wiki-link="true"]');
|
||||||
expect(link).toBeInTheDocument();
|
expect(link).toBeInTheDocument();
|
||||||
|
|
||||||
// Script tags should be escaped
|
// Script tags should be removed by DOMPurify (including content)
|
||||||
const linkHtml = link?.innerHTML ?? "";
|
const linkHtml = link?.innerHTML ?? "";
|
||||||
expect(linkHtml).not.toContain("<script>");
|
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 => {
|
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"]');
|
const secondLink = container.querySelector('a[data-wiki-link="true"]');
|
||||||
expect(secondLink).toBeInTheDocument();
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
80
docs/scratchpads/201-wikilink-xss-enhancement.md
Normal file
80
docs/scratchpads/201-wikilink-xss-enhancement.md
Normal 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
|
||||||
Reference in New Issue
Block a user