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:
@@ -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