106 lines
4.4 KiB
TypeScript
106 lines
4.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { renderSanitizedMarkdown, sanitizeIconSvg, sanitizeMarkedHtml } from "./html-sanitizer";
|
|
|
|
describe("html sanitizer", () => {
|
|
it("removes active HTML and unsafe URLs from marked output", () => {
|
|
const html = sanitizeMarkedHtml(`
|
|
<h1 onclick="alert(1)">Title</h1>
|
|
<script>alert(1)</script>
|
|
<style>body { color: red; }</style>
|
|
<iframe src="https://example.com"></iframe>
|
|
<object data="https://example.com"></object>
|
|
<embed src="https://example.com" />
|
|
<link rel="preload" href="https://example.com" />
|
|
<meta http-equiv="refresh" content="0" />
|
|
<base href="https://example.com" />
|
|
<svg onload="alert(1)"><circle /></svg>
|
|
<a href="javascript:alert(1)">bad</a>
|
|
<a href="data:text/vbscript,alert(1)">also bad</a>
|
|
<a href="https://example.com/page" rel="ugc">external</a>
|
|
`);
|
|
|
|
expect(html).not.toMatch(/<script|<style|<iframe|<object|<embed|<link|<meta|<base|<svg/i);
|
|
expect(html).not.toMatch(/onclick|javascript:|data:text\/vbscript/i);
|
|
expect(html).toContain('<a href="https://example.com/page" rel="ugc noopener noreferrer">external</a>');
|
|
});
|
|
|
|
it("sanitizes markdown links before rendering previews", () => {
|
|
const html = renderSanitizedMarkdown(`[unsafe](javascript:alert(1)) [safe](https://example.com)`);
|
|
|
|
expect(html).not.toMatch(/javascript:/i);
|
|
expect(html).toContain('href="https://example.com" rel="noopener noreferrer"');
|
|
});
|
|
|
|
it("escapes author-typed raw HTML in markdown instead of passing it through", () => {
|
|
const html = renderSanitizedMarkdown(`<div><img src="x"/onerror=alert(1)></div>`);
|
|
|
|
// The dangerous markup must survive only as inert escaped text, never as a live tag.
|
|
expect(html).not.toMatch(/<img/i);
|
|
expect(html).not.toMatch(/<div/i);
|
|
expect(html).toContain("<img");
|
|
// legitimate markdown still renders
|
|
expect(renderSanitizedMarkdown("**bold**")).toContain("<strong>bold</strong>");
|
|
});
|
|
|
|
describe("server string fallback (no DOMParser)", () => {
|
|
const originalDomParser = globalThis.DOMParser;
|
|
beforeEach(() => {
|
|
// Force the SSR code path that does not have a real HTML parser.
|
|
delete (globalThis as { DOMParser?: unknown }).DOMParser;
|
|
});
|
|
afterEach(() => {
|
|
globalThis.DOMParser = originalDomParser;
|
|
});
|
|
|
|
it("strips slash-separated event handlers that the previous regex missed", () => {
|
|
const html = sanitizeMarkedHtml(`<img src="x"/onerror=alert(1)>`);
|
|
expect(html).not.toMatch(/onerror/i);
|
|
});
|
|
|
|
it("strips whitespace-separated event handlers and unsafe protocols", () => {
|
|
const html = sanitizeMarkedHtml(`<img src="x" onerror="alert(1)"><a href="javascript:alert(1)">x</a>`);
|
|
expect(html).not.toMatch(/onerror/i);
|
|
expect(html).not.toMatch(/javascript:/i);
|
|
});
|
|
|
|
it("keeps the markdown render path safe even without a DOM parser", () => {
|
|
const html = renderSanitizedMarkdown(`<img/src=x/onerror=alert(1)>`);
|
|
expect(html).not.toMatch(/<img/i);
|
|
expect(html).not.toMatch(/onerror=/i);
|
|
});
|
|
|
|
it("sanitizes inline svg icons without a DOM parser while keeping the drawing", () => {
|
|
const svg = sanitizeIconSvg(
|
|
`<svg viewBox="0 0 10 10" onload="alert(1)"><script>alert(1)</script><a xlink:href="javascript:alert(1)"><rect width="9" height="9"/></a></svg>`,
|
|
"h-5 w-5"
|
|
);
|
|
expect(svg).not.toMatch(/onload/i);
|
|
expect(svg).not.toMatch(/<script/i);
|
|
expect(svg).not.toMatch(/javascript:/i);
|
|
expect(svg).toMatch(/<svg/i);
|
|
expect(svg).toContain("rect");
|
|
expect(svg).toContain('class="h-5 w-5"');
|
|
});
|
|
});
|
|
|
|
describe("sanitizeIconSvg", () => {
|
|
it("strips scriptable svg while preserving paths", () => {
|
|
const svg = sanitizeIconSvg(
|
|
`<svg viewBox="0 0 24 24"><path d="M1 1"/><foreignObject><img src=x onerror=alert(1)></foreignObject></svg>`,
|
|
"size-4"
|
|
);
|
|
expect(svg).not.toMatch(/onerror/i);
|
|
expect(svg).not.toMatch(/foreignobject/i);
|
|
expect(svg).toContain('d="M1 1"');
|
|
expect(svg).toContain('class="size-4"');
|
|
});
|
|
|
|
it("returns empty string for non-svg input", () => {
|
|
expect(sanitizeIconSvg("<img src=x onerror=alert(1)>")).toBe("");
|
|
expect(sanitizeIconSvg("https://example.com/icon.png")).toBe("");
|
|
expect(sanitizeIconSvg(null)).toBe("");
|
|
});
|
|
});
|
|
});
|