CVE-2024-28243: Insufficient Information (NVD-CWE-noinfo)
value: KaTeX is a JavaScript library for TeX math rendering on the web. KaTeX users who render untrusted mathematical expressions could encounter malicious input using `\edef` that causes a near-infinite loop, despite setting `maxExpand` to avoid such loops. This can be used as an availability attack, where e.g. a client rendering another user's KaTeX input will be unable to use the site due to memory overflow, tying up the main thread, or stack overflow. Upgrade to KaTeX v0.16.10 to remove this vulnerability.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-25T20:15Z
Repository: https://github.com/KaTeX/KaTeX
Language: JavaScript
Files changed: docs/options.md, src/MacroExpander.js, test/katex-spec.js
Commit date: 2024-03-24 17:57:37 -0400
Commit message: fix: \edef bypassing maxExpand via exponential blowup

Modify `\edef`, and `expandTokens` more generally, to count the number
of tokens in the entire expansion instead of just 1.  This prevents
exponential blowup in the expanded tokens via a linear number of `\edef`
and apparent expansions.

Diff: diff --git a/docs/options.md b/docs/options.md
index ac35b940..3f90a013 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -24,11 +24,11 @@ You can provide an object of options as the last argument to [`katex.render` and
     - An expansion object matching [an internal `MacroExpansion` specification](https://github.com/KaTeX/KaTeX/blob/main/src/defineMacro.js), which is what results from global `\def` or `\let`. For example, you can simulate the effect of `\let\realint=\int` via `{"\\realint": {tokens: [{text: "\\int", noexpand: true}], numArgs: 0}}`.
   - *This object will be modified* if the LaTeX code defines its own macros via `\gdef` or `\global\let` (or via `\def` or `\newcommand` or `\let` when using `globalGroup`). This enables consecutive calls to KaTeX to share state (in particular, user macro definitions) if you pass in the same `macros` object each time.
 - `minRuleThickness`: `number`. Specifies a minimum thickness, in ems, for fraction lines, `\sqrt` top lines, `{array}` vertical lines, `\hline`, `\hdashline`, `\underline`, `\overline`, and the borders of `\fbox`, `\boxed`, and `\fcolorbox`. The usual value for these items is `0.04`, so for `minRuleThickness` to be effective it should probably take a value slightly above `0.04`, say `0.05` or `0.06`. Negative values will be ignored.
 - `colorIsTextColor`: `boolean`. In early versions of both KaTeX (<0.8.0) and MathJax, the `\color` function expected the content to be a function argument, as in `\color{blue}{hello}`. In current KaTeX, `\color` is a switch, as in `\color{blue} hello`. This matches LaTeX behavior. If you want the old `\color` behavior, set option `colorIsTextColor` to true.
 - `maxSize`: `number`. All user-specified sizes, e.g. in `\rule{500em}{500em}`, will be capped to `maxSize` ems. If set to `Infinity` (the default), users can make elements and spaces arbitrarily large.
-- `maxExpand`: `number`. Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to `Infinity`, the macro expander will try to fully expand as in LaTeX. (default: 1000)
+- `maxExpand`: `number`. Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. `\edef` expansion counts all expanded tokens. If set to `Infinity`, the macro expander will try to fully expand as in LaTeX. (default: 1000)
 - `strict`: `boolean` or `string` or `function` (default: `"warn"`). If `false` or `"ignore`", allow features that make writing LaTeX convenient but are not actually supported by (Xe)LaTeX (similar to MathJax). If `true` or `"error"` (LaTeX faithfulness mode), throw an error for any such transgressions. If `"warn"` (the default), warn about such behavior via `console.warn`. Provide a custom function `handler(errorCode, errorMsg, token)` to customize behavior depending on the type of transgression (summarized by the string code `errorCode` and detailed in `errorMsg`); this function can also return `"ignore"`, `"error"`, or `"warn"` to use a built-in behavior.  A list of such features and their `errorCode`s:
 
   - `"unknownSymbol"`: Use of unknown Unicode symbol, which will likely also
     lead to warnings about missing character metrics, and layouts may be
     incorrect (especially in terms of vertical heights).
diff --git a/src/MacroExpander.js b/src/MacroExpander.js
index 08532fd6..18dadc3d 100644
--- a/src/MacroExpander.js
+++ b/src/MacroExpander.js
@@ -243,10 +243,22 @@ export default class MacroExpander implements MacroContextInterface {
             args.push(this.consumeArg(delimiters && delimiters[i + 1]).tokens);
         }
         return args;
     }
 
+    /**
+     * Increment `expansionCount` by the specified amount.
+     * Throw an error if it exceeds `maxExpand`.
+     */
+    countExpansion(amount: number): void {
+        this.expansionCount += amount;
+        if (this.expansionCount > this.settings.maxExpand) {
+            throw new ParseError("Too many expansions: infinite loop or " +
+                "need to increase maxExpand setting");
+        }
+    }
+
     /**
      * Expand the next token only once if possible.
      *
      * If the token is expanded, the resulting tokens will be pushed onto
      * the stack in reverse order, and the number of such tokens will be
@@ -274,15 +286,11 @@ export default class MacroExpander implements MacroContextInterface {
                 throw new ParseError("Undefined control sequence: " + name);
             }
             this.pushToken(topToken);
             return false;
         }
-        this.expansionCount++;
-        if (this.expansionCount > this.settings.maxExpand) {
-            throw new ParseError("Too many expansions: infinite loop or " +
-                "need to increase maxExpand setting");
-        }
+        this.countExpansion(1);
         let tokens = expansion.tokens;
         const args = this.consumeArgs(expansion.numArgs, expansion.delimiters);
         if (expansion.numArgs) {
             // paste arguments in place of the placeholders
             tokens = tokens.slice(); // make a shallow copy
@@ -373,10 +381,13 @@ export default class MacroExpander implements MacroContextInterface {
                     token.treatAsRelax = false;
                 }
                 output.push(token);
             }
         }
+        // Count all of these tokens as additional expansions, to prevent
+        // exponential blowup from linearly many \edef's.
+        this.countExpansion(output.length);
         return output;
     }
 
     /**
      * Fully expand the given macro name and return the result as a string,
diff --git a/test/katex-spec.js b/test/katex-spec.js
index a51b0fed..bd1380f1 100644
--- a/test/katex-spec.js
+++ b/test/katex-spec.js
@@ -3995,10 +3995,15 @@ describe("The maxExpand setting", () => {
 
     it("should prevent infinite loops", () => {
         expect`\gdef\foo{\foo}\foo`.not.toParse(
             new Settings({maxExpand: 10}));
     });
+
+    it("should prevent exponential blowup via \\edef", () => {
+        expect`\edef0{x}\edef0{00}\edef0{00}\edef0{00}\edef0{00}`.not.toParse(
+            new Settings({maxExpand: 10}));
+    });
 });
 
 describe("The \\mathchoice function", function() {
     const cmd = r`\sum_{k = 0}^{\infty} x^k`;
 

Vulnerable code: - `maxExpand`: `number`. Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to `Infinity`, the macro expander will try to fully expand as in LaTeX. (default: 1000)
        this.expansionCount++;
        if (this.expansionCount > this.settings.maxExpand) {
            throw new ParseError("Too many expansions: infinite loop or " +
                "need to increase maxExpand setting");
        }
Fixed code: - `maxExpand`: `number`. Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. `\edef` expansion counts all expanded tokens. If set to `Infinity`, the macro expander will try to fully expand as in LaTeX. (default: 1000)
    /**
     * Increment `expansionCount` by the specified amount.
     * Throw an error if it exceeds `maxExpand`.
     */
    countExpansion(amount: number): void {
        this.expansionCount += amount;
        if (this.expansionCount > this.settings.maxExpand) {
            throw new ParseError("Too many expansions: infinite loop or " +
                "need to increase maxExpand setting");
        }
    }

        this.countExpansion(1);
        // Count all of these tokens as additional expansions, to prevent
        // exponential blowup from linearly many \edef's.
        this.countExpansion(output.length);

    it("should prevent exponential blowup via \\edef", () => {
        expect`\edef0{x}\edef0{00}\edef0{00}\edef0{00}\edef0{00}`.not.toParse(
            new Settings({maxExpand: 10}));
    });