Merge KaTeX security repair
This commit is contained in:
Generated
+4
-4
@@ -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
@@ -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",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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, ["∇", "∈", "×"]],
|
||||
|
||||
Reference in New Issue
Block a user