All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
SEC-WEB-33: Replace raw diagram source and detailed error messages in MermaidViewer error UI with a generic "Diagram rendering failed" message. Detailed errors are logged to console.error for debugging only. SEC-WEB-35: Add console.warn in useWorkspaceId when no workspace ID is found in localStorage, making it easier to distinguish "no workspace selected" from silent hook failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
/**
|
|
* MermaidViewer XSS Protection Tests
|
|
* Tests defense-in-depth security layers for Mermaid diagram rendering
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, waitFor } from "@testing-library/react";
|
|
import { MermaidViewer } from "./MermaidViewer";
|
|
|
|
// Mock mermaid
|
|
vi.mock("mermaid", () => ({
|
|
default: {
|
|
initialize: vi.fn(),
|
|
render: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
describe("MermaidViewer XSS Protection", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("Script tag injection", () => {
|
|
it("should block script tags in labels", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<script>alert('XSS')</script>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
// Should not contain script tags
|
|
expect(content).not.toContain("<script>");
|
|
expect(content).not.toContain("alert");
|
|
});
|
|
});
|
|
|
|
it("should block script tags with various casings", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<ScRiPt>alert('XSS')</sCrIpT>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML.toLowerCase();
|
|
expect(content).not.toContain("<script>");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Event handler injection", () => {
|
|
it("should block onerror event handlers", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<img src=x onerror=alert(1)>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
expect(content).not.toContain("onerror");
|
|
expect(content).not.toContain("alert(1)");
|
|
});
|
|
});
|
|
|
|
it("should block onclick event handlers", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<div onclick=alert(1)>Click</div>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
expect(content).not.toContain("onclick");
|
|
});
|
|
});
|
|
|
|
it("should block onload event handlers", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<body onload=alert(1)>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
expect(content).not.toContain("onload");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("JavaScript URL injection", () => {
|
|
it("should block javascript: URLs in href", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<a href='javascript:alert(1)'>Click</a>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
expect(content).not.toContain("javascript:");
|
|
});
|
|
});
|
|
|
|
it("should block javascript: URLs in src", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<img src='javascript:alert(1)'>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
expect(content).not.toContain("javascript:");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Data URL injection", () => {
|
|
it("should block data URLs with scripts", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<img src='data:text/html,<script>alert(1)</script>'>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
expect(content).not.toContain("data:text/html");
|
|
expect(content).not.toContain("<script>");
|
|
});
|
|
});
|
|
|
|
it("should block data URLs with base64 encoded scripts", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<object data='data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=='>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
expect(content).not.toContain("data:text/html");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("SVG injection", () => {
|
|
it("should block SVG with embedded scripts", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<svg><script>alert(1)</script></svg>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
// SVG should be sanitized to remove scripts
|
|
expect(content).not.toContain("<script>");
|
|
});
|
|
});
|
|
|
|
it("should block SVG with xlink:href", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<svg><use xlink:href='javascript:alert(1)'/></svg>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
expect(content).not.toContain("javascript:");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("HTML entity bypass", () => {
|
|
it("should block HTML entity encoded scripts", async () => {
|
|
const maliciousDiagram = `graph TD
|
|
A["<script>alert(1)</script>"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={maliciousDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
// Should not execute the decoded script
|
|
expect(content).not.toContain("alert(1)");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Safe content", () => {
|
|
it("should allow safe plain text labels", async () => {
|
|
const safeDiagram = `graph TD
|
|
A["Safe Label"]
|
|
B["Another Safe Label"]
|
|
A --> B`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={safeDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
// Should render without errors
|
|
expect(container.querySelector(".mermaid-container")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("should allow safe special characters", async () => {
|
|
const safeDiagram = `graph TD
|
|
A["Label with & and # and @"]`;
|
|
|
|
const { container } = render(<MermaidViewer diagram={safeDiagram} />);
|
|
|
|
await waitFor(() => {
|
|
expect(container.querySelector(".mermaid-container")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Error display (SEC-WEB-33)", () => {
|
|
it("should not display raw diagram source when rendering fails", async () => {
|
|
const sensitiveSource = `graph TD
|
|
A["SECRET_API_KEY=abc123"]
|
|
B["password: hunter2"]`;
|
|
|
|
// Mock mermaid to throw an error containing the diagram source
|
|
const mermaid = await import("mermaid");
|
|
vi.mocked(mermaid.default.render).mockRejectedValue(
|
|
new Error(`Parse error in diagram: ${sensitiveSource}`)
|
|
);
|
|
|
|
const { container } = render(<MermaidViewer diagram={sensitiveSource} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
// Should show generic error message, not raw source or detailed error
|
|
expect(content).toContain("Diagram rendering failed");
|
|
expect(content).not.toContain("SECRET_API_KEY");
|
|
expect(content).not.toContain("password: hunter2");
|
|
expect(content).not.toContain("Parse error in diagram");
|
|
});
|
|
});
|
|
|
|
it("should not expose detailed error messages in the UI", async () => {
|
|
const diagram = `graph TD
|
|
A["Test"]`;
|
|
|
|
const mermaid = await import("mermaid");
|
|
vi.mocked(mermaid.default.render).mockRejectedValue(
|
|
new Error("Lexical error on line 2. Unrecognized text at /internal/path/file.ts")
|
|
);
|
|
|
|
const { container } = render(<MermaidViewer diagram={diagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
expect(content).toContain("Diagram rendering failed");
|
|
expect(content).not.toContain("Lexical error");
|
|
expect(content).not.toContain("/internal/path/file.ts");
|
|
});
|
|
});
|
|
|
|
it("should not display a pre tag with raw diagram source on error", async () => {
|
|
const diagram = `graph TD
|
|
A["Node A"]`;
|
|
|
|
const mermaid = await import("mermaid");
|
|
vi.mocked(mermaid.default.render).mockRejectedValue(new Error("render failed"));
|
|
|
|
const { container } = render(<MermaidViewer diagram={diagram} />);
|
|
|
|
await waitFor(() => {
|
|
// There should be no <pre> element showing raw diagram source
|
|
const preElements = container.querySelectorAll("pre");
|
|
expect(preElements.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("should log the detailed error to console.error", async () => {
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
const diagram = `graph TD
|
|
A["Test"]`;
|
|
const originalError = new Error("Detailed parse error at line 2");
|
|
|
|
const mermaid = await import("mermaid");
|
|
vi.mocked(mermaid.default.render).mockRejectedValue(originalError);
|
|
|
|
render(<MermaidViewer diagram={diagram} />);
|
|
|
|
await waitFor(() => {
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Mermaid rendering failed:", originalError);
|
|
});
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("DOMPurify integration", () => {
|
|
it("should sanitize rendered SVG output", async () => {
|
|
const diagram = `graph TD
|
|
A["Test Node"]`;
|
|
|
|
// Mock mermaid to return SVG with potential XSS
|
|
const mermaid = await import("mermaid");
|
|
vi.mocked(mermaid.default.render).mockResolvedValue({
|
|
svg: "<svg><script>alert(1)</script><text>Test</text></svg>",
|
|
bindFunctions: vi.fn(),
|
|
diagramType: "flowchart",
|
|
});
|
|
|
|
const { container } = render(<MermaidViewer diagram={diagram} />);
|
|
|
|
await waitFor(() => {
|
|
const content = container.innerHTML;
|
|
// DOMPurify should remove the script tag
|
|
expect(content).not.toContain("<script>");
|
|
expect(content).not.toContain("alert(1)");
|
|
// But keep the safe SVG elements
|
|
expect(content).toContain("<svg");
|
|
});
|
|
});
|
|
});
|
|
});
|