diff --git a/package-lock.json b/package-lock.json index f3906c4..18a2130 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "dagre-d3-es": "7.0.14", "electron-updater": "^6.8.9", "embla-carousel-react": "^8.6.0", - "katex": "^0.16.47", + "katex": "0.18.2", "linkedom": "^0.18.13", "lucide-react": "^1.33.0", "media-chrome": "^4.19.2", @@ -11775,9 +11775,9 @@ } }, "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "version": "0.18.2", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.2.tgz", + "integrity": "sha512-3snve4y0SXTMequLim1FMiPvLGElySam0blN5xQZBqEY3lOJz1TnuaaiugnBZBqHzlSAGmFTYL7MCQkORuLKCA==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" diff --git a/package.json b/package.json index 78509c7..10b3d86 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "dagre-d3-es": "7.0.14", "electron-updater": "^6.8.9", "embla-carousel-react": "^8.6.0", - "katex": "^0.16.47", + "katex": "0.18.2", "linkedom": "^0.18.13", "lucide-react": "^1.33.0", "media-chrome": "^4.19.2", @@ -70,6 +70,9 @@ "use-stick-to-bottom": "^1.1.6", "zod": "^4.4.3" }, + "overrides": { + "katex": "$katex" + }, "devDependencies": { "@next/eslint-plugin-next": "^16.3.3", "@tailwindcss/postcss": "^4.3.3", diff --git a/tests/katex-security.test.ts b/tests/katex-security.test.ts new file mode 100644 index 0000000..09aabf7 --- /dev/null +++ b/tests/katex-security.test.ts @@ -0,0 +1,83 @@ +/** GHSA-238p-pmpm-9mq7: inherited properties must not become KaTeX authority. + * Output is inspected as inert strings/LinkeDOM; no resource or script is run. + * node:test runs files in separate processes. Within this file all prototype + * mutations are synchronous, non-concurrent, and restored even on failure. + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseHTML } from "linkedom"; +import katex from "katex"; +import { MessageResponse } from "../components/ai-elements/message.tsx"; + +(globalThis as Record).React = React; +const blocked = String.raw`\href{https://katex-security.invalid/blocked}{blocked}`; +function withoutAuthority(html: string) { + const { document } = parseHTML(html); + assert.equal(document.querySelectorAll("[href], [src], a, img, script, iframe").length, 0, "inherited authority must not emit active HTML or URL attributes"); + assert.ok(html.includes("blocked"), "the formula was actually processed"); +} +function withPrototype(key: string, value: unknown, run: () => void) { + const previous = Object.getOwnPropertyDescriptor(Object.prototype, key); + try { + Object.defineProperty(Object.prototype, key, { value, configurable: true, writable: true }); + run(); + } finally { + if (previous) Object.defineProperty(Object.prototype, key, previous); + else Reflect.deleteProperty(Object.prototype, key); + } +} +function markdown() { + return renderToStaticMarkup(React.createElement(MessageResponse, { mode: "static" }, `$${blocked}$`)); +} + +describe("KaTeX prototype read-side security", { concurrency: false }, () => { + it("ignores Object.prototype.trust in direct and app markdown rendering", () => { + withPrototype("trust", true, () => { + withoutAuthority(katex.renderToString(blocked, { strict: false })); + withoutAuthority(markdown()); + }); + }); + it("ignores trust inherited from a custom options prototype", () => { + const options = Object.assign(Object.create({ trust: true }), { strict: false }); + withoutAuthority(katex.renderToString(blocked, options)); + }); + it("ignores inherited schema defaults in direct and app markdown rendering", () => { + withPrototype("default", true, () => { + withoutAuthority(katex.renderToString(blocked, { strict: false })); + withoutAuthority(markdown()); + }); + }); + it("ignores inherited schema processors, including for own trust:false", () => { + withPrototype("processor", (value: unknown) => value === false ? true : value, () => { + withoutAuthority(katex.renderToString(blocked, { strict: false, trust: false })); + withoutAuthority(markdown()); + }); + }); + it("keeps own trust:false and own restricted callbacks restrictive", () => { + withPrototype("trust", true, () => { + withoutAuthority(katex.renderToString(blocked, { strict: false, trust: false })); + const commands: string[] = []; + withoutAuthority(katex.renderToString(blocked, { + strict: false, + trust: (context) => { commands.push(context.command); return context.command === "\\url"; }, + })); + assert.deepEqual(commands, ["\\href"]); + }); + }); + it("ignores inherited builtin namespace entries", () => { + withPrototype("\\securityInherited", "INHERITED", () => { + // The parser may reject it as an unknown function rather than an + // undefined command; either way the inherited macro must not expand. + assert.throws(() => katex.renderToString(String.raw`\securityInherited`), /KaTeX parse error/); + }); + }); + it("does not restore an inherited macro as an own definition after a group", () => { + const macros = Object.create({ "\\securityRestore": "INHERITED" }); + const html = katex.renderToString(String.raw`{\def\securityRestore{L}\securityRestore}`, { macros }); + assert.ok(parseHTML(html).document.querySelector(".katex-html")?.textContent?.includes("L")); + assert.equal(Object.hasOwn(macros, "\\securityRestore"), false, "group restoration must delete, not promote an inherited value"); + assert.throws(() => katex.renderToString(String.raw`\securityRestore`, { macros }), /Undefined control sequence/); + }); +}); diff --git a/tests/repair-math-display.test.tsx b/tests/repair-math-display.test.tsx index 6ab3d22..117f123 100644 --- a/tests/repair-math-display.test.tsx +++ b/tests/repair-math-display.test.tsx @@ -9,6 +9,8 @@ import { parseHTML } from "linkedom"; import { validateToolArguments } from "@earendil-works/pi-ai"; import { problemInputObject } from "../src/agent/tools/practice.ts"; import { PracticeDock } from "../components/practice-dock.tsx"; +import { QuestionDock } from "../components/question-dock.tsx"; +import { LatexGlyph } from "../components/latex-glyph.tsx"; import { ProblemCard, ConcludeCard } from "../components/practice-cards.tsx"; import { MessageResponse } from "../components/ai-elements/message.tsx"; import { @@ -77,6 +79,52 @@ describe("repairMathForDisplay — the existing composition, unchanged", () => { }); }); +describe("upgraded KaTeX rendering on real question and practice surfaces", () => { + for (const [name, formula, glyphs] of [ + ["fraction and root", String.raw`\frac{1}{\sqrt{x}}`, ["1", "x"]], + ["accents", String.raw`\hat{x}+\bar{y}`, ["x", "y"]], + ["sum", String.raw`\sum_{i=1}^{n}i`, ["∑", "i", "n"]], + ["matrix rows", String.raw`\begin{pmatrix}1&2\\3&4\end{pmatrix}`, ["1", "2", "3", "4"]], + ["aligned rows", String.raw`\begin{aligned}a&=b\\ c&=d\end{aligned}`, ["a", "b", "c", "d", "="]], + ] as const) { + it(`${name}: dollar and legacy delimiters preserve glyphs and copied TeX`, () => { + for (const raw of [`$${formula}$`, `\\(${formula}\\)`, `$$\n${formula}\n$$`, `\\[\n${formula}\n\\]`]) { + for (const element of [ + {raw}, + {}} onSubmit={() => {}} />, + dock({ statement: raw }), + ]) { + const html = renderToStaticMarkup(element); + assertMath(html, [formula], [...glyphs]); + const { document } = parseHTML(html); + assert.ok(document.querySelector(".katex-base .katex-strut"), "matching 0.18 internal layout classes"); + if (name === "fraction and root") assert.ok(document.querySelector(".mfrac .sqrt")); + if (name === "accents") assert.ok(document.querySelectorAll(".katex-accent").length >= 2); + if (name.endsWith("rows")) assert.ok(document.querySelectorAll(".mtable .mtr").length >= 2 || document.querySelectorAll(".mtable .vlist > span").length >= 2, "two real rows, not repaired-away slashes"); + } + } + }); + } + it("preserves escaped currency and code on both dock surfaces", () => { + const raw = "Costs \\$5 and \\$10. `" + clean + "`\n\n```text\n" + doubled + "\n```"; + for (const element of [dock({ statement: raw }), {}} onSubmit={() => {}} />]) { + const html = renderToStaticMarkup(element); + assert.deepEqual(math(html).annotations, []); + assert.ok(parseHTML(html).document.firstElementChild?.textContent?.includes("$5 and $10")); + assert.ok(parseHTML(html).document.querySelector("code")?.textContent?.includes(clean)); + } + }); + it("renders direct LatexGlyph connectors through the upgraded root", () => { + for (const [command, glyph] of [[String.raw`\Rightarrow`, "⇒"], [String.raw`\land`, "∧"], [String.raw`\neg`, "¬"]]) { + const html = renderToStaticMarkup(); + const { document } = parseHTML(html); + assert.equal(document.querySelectorAll(".katex-error").length, 0); + assert.ok(document.querySelector(".katex-base .katex-strut")); + assert.ok(document.querySelector(".katex-html")?.textContent?.includes(glyph!)); + } + }); +}); + describe("practice display surfaces", () => { for (const [name, raw, expected, glyphs] of [ ["two command slashes", doubled, tex, ["∇", "∈", "×"]],