Files
learnharness/tests/katex-security.test.ts
T

84 lines
4.0 KiB
TypeScript

/** 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/);
});
});