Merge KaTeX security repair

This commit is contained in:
amosblomqvist
2026-10-23 17:11:57 +03:00
4 changed files with 139 additions and 5 deletions
+4 -4
View File
@@ -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"
+4 -1
View File
@@ -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",
+83
View File
@@ -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<string, unknown>).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/);
});
});
+48
View File
@@ -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 [
<MessageResponse mode="static">{raw}</MessageResponse>,
<QuestionDock toolName="ask_user_question" toolCallId="security_question" input={{ question: raw }} expanded onExpand={() => {}} 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 }), <QuestionDock toolName="ask_user_question" toolCallId="security_code" input={{ question: raw }} expanded onExpand={() => {}} 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(<LatexGlyph cmd={command!} />);
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, ["∇", "∈", "×"]],