From 439cf49de1bdcf8bf9363600ef3f0020644dac2b Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sat, 12 Sep 2026 07:29:17 +0200 Subject: [PATCH 1/3] fix: sanitize rendered HTML from content Content is rendered with raw HTML passed through verbatim: parse.ts uses `remarkRehype({ allowDangerousHtml: true })` and ofm.ts then runs `rehypeRaw` unconditionally, turning whatever a page contains into real markup. Nothing sanitized it, and the result is emitted as static HTML to the published site. That is a bigger deal here than in a hand-written wiki. Per the README these pages are drafted and refreshed by an LLM agent reading upstream repositories and specifications, so a page is untrusted input -- a hostile upstream README, or a single unreviewed content change, is enough. Checked against a scratch page: an `onerror` handler, a `' +tags: + - sanitize-fixture +--- + +Inline image: + + + + + +[markdown link](javascript:alert(4)) + +raw link + +entity-encoded link + + + + + + + +
+ + + + + + + + + +
summary
+ + + +
clickable
+ +==== + +![[target|x" onmouseover="alert(20)]] + +> [!note] +> Callout body. diff --git a/quartz/util/fixtures/sanitize/second.md b/quartz/util/fixtures/sanitize/second.md new file mode 100644 index 00000000..46f3289f --- /dev/null +++ b/quartz/util/fixtures/sanitize/second.md @@ -0,0 +1,9 @@ +--- +title: Second page +--- + +A paragraph with a block reference. ^blk1 + +## Section B + +Section B body. diff --git a/quartz/util/fixtures/sanitize/tags/sanitize-fixture.md b/quartz/util/fixtures/sanitize/tags/sanitize-fixture.md new file mode 100644 index 00000000..b7c6cbab --- /dev/null +++ b/quartz/util/fixtures/sanitize/tags/sanitize-fixture.md @@ -0,0 +1,5 @@ +--- +title: Sanitize fixture tag +--- + +Tag page body diff --git a/quartz/util/fixtures/sanitize/target.md b/quartz/util/fixtures/sanitize/target.md new file mode 100644 index 00000000..36d3dbd2 --- /dev/null +++ b/quartz/util/fixtures/sanitize/target.md @@ -0,0 +1,12 @@ +--- +title: Target page +--- + +Target intro paragraph. + +## Section A + +Section A body. + +> [!info] Info in target +> Content. diff --git a/quartz/util/sanitize.test.ts b/quartz/util/sanitize.test.ts new file mode 100644 index 00000000..6c0b4454 --- /dev/null +++ b/quartz/util/sanitize.test.ts @@ -0,0 +1,150 @@ +import test, { after, before, describe } from "node:test" +import assert from "node:assert" +import { spawnSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +// Builds the pages in fixtures/sanitize with the real Quartz pipeline and checks that the +// payloads in them do not reach the emitted HTML, and that Quartz's own markup still does. + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..") +const fixtureDir = path.join(repoRoot, "quartz", "util", "fixtures", "sanitize") +const outputDir = path.join(repoRoot, "quartz", ".quartz-cache", "sanitize-test-output") + +const rawTextElements = /(<(script|style)\b[^>]*>)([\s\S]*?)(<\/\2>)/gi +const tagPattern = + /<([a-zA-Z][\w:-]*)((?:\s+[^\s"'>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*\/?>/g +const attributePattern = /([^\s"'>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g +const urlAttributes = new Set(["action", "background", "data", "formaction", "href", "src"]) +const forbiddenTags = new Set(["base", "embed", "form", "frame", "frameset", "object"]) +const dangerousUrl = /^(?:javascript|vbscript|data:text\/html)/i + +function decodeEntities(value: string): string { + return value + .replace(/&#x([\da-f]+);?/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16))) + .replace(/&#(\d+);?/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10))) + .replace(/:/gi, ":") + .replace(/"/g, '"') + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") +} + +// Lists script-capable markup in an HTML document: event handler attributes, srcdoc, +// meta refresh, script URLs, embedding elements, and script or style elements carrying the +// fixture marker. Text and attribute values that merely contain escaped payloads are ignored. +function findDangerousMarkup(html: string): string[] { + const problems: string[] = [] + + for (const [, , tag, body] of html.matchAll(rawTextElements)) { + if (body.includes("xss-marker")) problems.push(`<${tag}> element from content`) + } + + const markup = html.replace(rawTextElements, "$1$4") + for (const [, rawTag, attributes] of markup.matchAll(tagPattern)) { + const tag = rawTag.toLowerCase() + if (forbiddenTags.has(tag)) problems.push(`<${tag}> element`) + + for (const [, rawName, doubleQuoted, singleQuoted, unquoted] of attributes.matchAll( + attributePattern, + )) { + const name = rawName.toLowerCase() + const value = decodeEntities(doubleQuoted ?? singleQuoted ?? unquoted ?? "") + if (name.startsWith("on") || name === "srcdoc") { + problems.push(`${name} attribute on <${tag}>`) + } else if (name === "http-equiv" && value.toLowerCase() === "refresh") { + problems.push(`meta refresh`) + } else if (urlAttributes.has(name) && dangerousUrl.test(value.replace(/[\0-\x20]/g, ""))) { + problems.push(`${name}="${value}" on <${tag}>`) + } + } + } + + return problems +} + +describe("sanitization of rendered content", () => { + const pages = new Map() + + before(() => { + fs.rmSync(outputDir, { recursive: true, force: true }) + const build = spawnSync( + process.execPath, + ["./quartz/bootstrap-cli.mjs", "build", "-d", fixtureDir, "-o", outputDir], + { cwd: repoRoot, encoding: "utf8" }, + ) + assert.strictEqual(build.status, 0, `quartz build failed:\n${build.stdout}\n${build.stderr}`) + + for (const page of ["payloads", "features", "tags/sanitize-fixture"]) { + pages.set(page, fs.readFileSync(path.join(outputDir, `${page}.html`), "utf8")) + } + }) + + after(() => { + fs.rmSync(outputDir, { recursive: true, force: true }) + }) + + test("the checker detects the payload shapes", () => { + const problems = findDangerousMarkup( + `x` + + `` + + `

ok

`, + ) + assert.strictEqual(problems.length, 6, problems.join("\n")) + }) + + test("payloads in content do not reach the emitted HTML", () => { + for (const [page, html] of pages) { + assert.deepStrictEqual(findDangerousMarkup(html), [], `dangerous markup in ${page}.html`) + } + }) + + test("the payload page keeps its harmless content", () => { + const html = pages.get("payloads")! + for (const text of ["markdown link", "raw link", "clickable", "Callout body."]) { + assert.ok(html.includes(text), `payloads.html is missing "${text}"`) + } + }) + + test("Quartz markup survives sanitization", () => { + const html = pages.get("features")! + const expected = [ + // callouts + 'class="callout note"', + 'class="callout tip is-collapsible is-collapsed"', + 'data-callout="my-custom-type"', + 'data-callout-metadata="meta"', + 'class="callout-title-inner"', + // wikilinks, tags and transclusions + 'href="./target" class="internal alias"', + 'class="tag-link', + "transclude-src", + "A paragraph with a block reference.", + // media embeds + 'width="100" height="50"', + '