diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..f6e821a3 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# Review owners. See https://docs.github.com/articles/about-code-owners +# Teams are the ones granted access to this repository in OpenVTC/governance. + +* @OpenVTC/openvtc-maintainers + +# Wiki pages are drafted and refreshed by an LLM agent from upstream sources +# (README, "How This Wiki Is Maintained"), so a page is untrusted input: raw +# HTML written into it is parsed and published to the live site. Content +# changes need a human on them. +/content/ @OpenVTC/openvtc-maintainers @OpenVTC/openvtc-admins + +# Build, deploy and repository automation. +/.github/ @OpenVTC/openvtc-admins diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..859faa2d --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: + - v4 + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + - name: Install dependencies + run: npm ci + # Includes quartz/util/sanitize.test.ts, which builds the fixture pages in + # quartz/util/fixtures/sanitize and fails if any of their payloads reach the + # emitted HTML. + - name: Run tests + run: npm test diff --git a/.prettierignore b/.prettierignore index 3c0687a5..69663031 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,5 @@ public node_modules .quartz-cache +# Deliberately malformed markup that must reach the parser exactly as written. +quartz/util/fixtures diff --git a/package-lock.json b/package-lock.json index f1bd6178..aba7e87a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "rehype-mathjax": "^7.1.0", "rehype-pretty-code": "^0.14.5", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "remark": "^15.0.1", "remark-breaks": "^4.0.0", @@ -4005,6 +4006,21 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", @@ -6288,6 +6304,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-slug": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", diff --git a/package.json b/package.json index 7a292023..d14d892b 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "rehype-mathjax": "^7.1.0", "rehype-pretty-code": "^0.14.5", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "remark": "^15.0.1", "remark-breaks": "^4.0.0", diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 7a523aa5..1282a0a3 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -11,6 +11,8 @@ import { import { Element, Literal, Root as HtmlRoot } from "hast" import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" import rehypeRaw from "rehype-raw" +import rehypeSanitize from "rehype-sanitize" +import { sanitizeSchema } from "../../util/sanitize" import { SKIP, visit } from "unist-util-visit" import path from "path" import { splitAnchor } from "../../util/path" @@ -541,7 +543,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> return plugins }, htmlPlugins() { - const plugins: PluggableList = [rehypeRaw] + // rehypeRaw parses the raw HTML that remarkRehype passed through verbatim, so everything + // an author (or the LLM that maintains the content) wrote becomes real markup here. + // Sanitize it immediately afterwards: every later plugin only ever sees vetted markup. + const plugins: PluggableList = [rehypeRaw, [rehypeSanitize, sanitizeSchema]] if (opts.parseBlockReferences) { plugins.push(() => { diff --git a/quartz/util/fixtures/sanitize/features.md b/quartz/util/fixtures/sanitize/features.md new file mode 100644 index 00000000..2feca1a3 --- /dev/null +++ b/quartz/util/fixtures/sanitize/features.md @@ -0,0 +1,122 @@ +--- +title: Feature fixture +tags: + - fixture + - fixture/nested +--- + +# Heading one + +Intro with a [[target]] link, an [[target|aliased link]], a [[target#Section A|header link]], +a [[second#^blk1|block link]], an [external link](https://example.com/path?q=1), a +[relative link](./target), a [mailto](mailto:a@example.com) and an #inline-tag. + +Arrows -> and => and <- here. ==highlighted text== and `inline code` and **bold** _em_ ~~del~~. + +H~2~O is not GFM; sub sup Ctrl abbr. + +## Callouts + +> [!note] +> A plain note. + +> [!warning] Custom title with **bold** and [[target]] +> Body line one. +> +> Body paragraph two. + +> [!tip]- Collapsed tip +> Hidden body. + +> [!faq]+ Expanded question +> Body. + +> [!my-custom-type|meta] Custom type +> Body. + +> A normal quote. + +## Embeds + +![[target]] + +![[second#^blk1]] + +![[picture.png|Alt text|100x50]] + +![[picture.png]] + +![[doc.pdf]] + +![[clip.mp4]] + +![[song.mp3]] + +![video](movie.webm) + +![yt](https://www.youtube.com/watch?v=dQw4w9WgXcQ) + +![image](https://example.com/image.png "title") + +## Code + +```ts title="example.ts" {2} +const a = 1 +const b = "two" +function f(x: number) { + return x * 2 +} +``` + +``` +plain block +``` + +```mermaid +graph TD + A[Start] --> B{Is it?} + B -->|Yes| C[OK] +``` + +## Math + +Inline $e^{i\pi} + 1 = 0$ math. + +$$ +\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2} +$$ + +## Footnotes + +Text with a footnote[^1] and another[^note]. + +[^1]: The first footnote. +[^note]: A named footnote with [[target]]. + +## Tables and lists + +| Left | Center | Right | +| :--- | :----: | ----: | +| a | [[target]] | c | + +- [ ] open task +- [x] done task +- nested + 1. one + 2. two + +
+Summary text + +Details body. + +
+ +
centered div
+ +raw img + +--- + +Term with line break +next line. diff --git a/quartz/util/fixtures/sanitize/payloads.md b/quartz/util/fixtures/sanitize/payloads.md new file mode 100644 index 00000000..e933521d --- /dev/null +++ b/quartz/util/fixtures/sanitize/payloads.md @@ -0,0 +1,47 @@ +--- +title: "Payloads " +description: '">' +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"', + '