CVE-2024-28246: Insufficient Information (NVD-CWE-noinfo)
value: KaTeX is a JavaScript library for TeX math rendering on the web. Code that uses KaTeX's `trust` option, specifically that provides a function to blacklist certain URL protocols, can be fooled by URLs in malicious inputs that use uppercase characters in the protocol. In particular, this can allow for malicious input to generate `javascript:` links in the output, even if the `trust` function tries to forbid this protocol via `trust: (context) => context.protocol !== 'javascript'`. 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/Settings.js, src/utils.js, test/__snapshots__/katex-spec.js.snap, test/katex-spec.js
Commit date: 2024-03-24 18:08:34 -0400
Commit message: fix: force protocol to be lowercase for better protocol filtering

* fix: force protocol to be lowercase for better protocol filtering

See https://datatracker.ietf.org/doc/html/rfc3986#section-3.1
"Although schemes are case-insensitive, the canonical form is lowercase"

* Update snapshots

* Rewrite to use new URL if available, and reject weird/invalid protocols

* Fix prefix pattern, ? handling, add comments and tests

* Remove new URL to simplify code

* Lint fixes

Diff: diff --git a/docs/options.md b/docs/options.md
index 3f90a013..de00c9f9 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -48,10 +48,12 @@ You can provide an object of options as the last argument to [`katex.render` and
     results, as in LaTeX.
 
 - `trust`: `boolean` or `function` (default: `false`). If `false` (do not trust input), prevent any commands like `\includegraphics` that could enable adverse behavior, rendering them instead in `errorColor`. If `true` (trust input), allow all such commands. Provide a custom function `handler(context)` to customize behavior depending on the context (command, arguments e.g. a URL, etc.).  A list of possible contexts:
 
   - `{command: "\\url", url, protocol}`
+    where `protocol` is a lowercased string like `"http"` or `"https"`
+    that appears before a colon, or `"_relative"` for relative URLs.
   - `{command: "\\href", url, protocol}`
   - `{command: "\\includegraphics", url, protocol}`
   - `{command: "\\htmlClass", class}`
   - `{command: "\\htmlId", id}`
   - `{command: "\\htmlStyle", style}`
diff --git a/src/Settings.js b/src/Settings.js
index 9c5336a3..304a7d1e 100644
--- a/src/Settings.js
+++ b/src/Settings.js
@@ -344,11 +344,15 @@ export default class Settings {
      * If `context` has a `url` field, a `protocol` field will automatically
      * get added by this function (changing the specified object).
      */
     isTrusted(context: AnyTrustContext): boolean {
         if (context.url && !context.protocol) {
-            context.protocol = utils.protocolFromUrl(context.url);
+            const protocol = utils.protocolFromUrl(context.url);
+            if (protocol == null) {
+                return false;
+            }
+            context.protocol = protocol;
         }
         const trust = typeof this.trust === "function"
             ? this.trust(context)
             : this.trust;
         return Boolean(trust);
diff --git a/src/utils.js b/src/utils.js
index 534c0f58..dfb197cf 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -91,15 +91,34 @@ export const assert = function<T>(value: ?T): T {
     return value;
 };
 
 /**
  * Return the protocol of a URL, or "_relative" if the URL does not specify a
- * protocol (and thus is relative).
+ * protocol (and thus is relative), or `null` if URL has invalid protocol
+ * (so should be outright rejected).
  */
-export const protocolFromUrl = function(url: string): string {
-    const protocol = /^\s*([^\\/#]*?)(?::|&#0*58|&#x0*3a)/i.exec(url);
-    return (protocol != null ? protocol[1] : "_relative");
+export const protocolFromUrl = function(url: string): string | null {
+    // Check for possible leading protocol.
+    // https://url.spec.whatwg.org/#url-parsing strips leading whitespace
+    // (U+20) or C0 control (U+00-U+1F) characters.
+    // eslint-disable-next-line no-control-regex
+    const protocol = /^[\x00-\x20]*([^\\/#?]*?)(:|&#0*58|&#x0*3a|&colon)/i
+    .exec(url);
+    if (!protocol) {
+        return "_relative";
+    }
+    // Reject weird colons
+    if (protocol[2] !== ":") {
+        return null;
+    }
+    // Reject invalid characters in scheme according to
+    // https://datatracker.ietf.org/doc/html/rfc3986#section-3.1
+    if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(protocol[1])) {
+        return null;
+    }
+    // Lowercase the protocol
+    return protocol[1].toLowerCase();
 };
 
 export default {
     contains,
     deflt,
diff --git a/test/__snapshots__/katex-spec.js.snap b/test/__snapshots__/katex-spec.js.snap
index 3546ca85..0795a111 100755
--- a/test/__snapshots__/katex-spec.js.snap
+++ b/test/__snapshots__/katex-spec.js.snap
@@ -1466,11 +1466,54 @@ exports[`href and url commands should not affect spacing around 1`] = `
     }
   }
 ]
 `;
 
-exports[`href and url commands should not allow explicitly disallow protocols 1`] = `
+exports[`href and url commands should not allow explicitly disallowed protocols 1`] = `
+[
+  {
+    "type": "color",
+    "body": [
+      {
+        "type": "text",
+        "body": [
+          {
+            "type": "textord",
+            "mode": "text",
+            "text": "\\\\"
+          },
+          {
+            "type": "textord",
+            "mode": "text",
+            "text": "h"
+          },
+          {
+            "type": "textord",
+            "mode": "text",
+            "text": "r"
+          },
+          {
+            "type": "textord",
+            "mode": "text",
+            "text": "e"
+          },
+          {
+            "type": "textord",
+            "mode": "text",
+            "text": "f"
+          }
+        ],
+        "mode": "math"
+      }
+    ],
+    "color": "#cc0000",
+    "mode": "math"
+  }
+]
+`;
+
+exports[`href and url commands should not allow explicitly uppercased disallowed protocols 1`] = `
 [
   {
     "type": "color",
     "body": [
       {
diff --git a/test/katex-spec.js b/test/katex-spec.js
index 89cb389a..438be76c 100644
--- a/test/katex-spec.js
+++ b/test/katex-spec.js
@@ -3003,17 +3003,50 @@ describe("href and url commands", function() {
     it("should allow all protocols when trust option is true", () => {
         const parsed = getParsed("\\href{ftp://x}{foo}", trustSettings);
         expect(parsed).toMatchSnapshot();
     });
 
-    it("should not allow explicitly disallow protocols", () => {
+    it("should not allow explicitly disallowed protocols", () => {
         const parsed = getParsed(
             "\\href{javascript:alert('x')}{foo}",
             new Settings({trust: context => context.protocol !== "javascript"}),
         );
         expect(parsed).toMatchSnapshot();
     });
+
+    it("should not allow explicitly uppercased disallowed protocols", () => {
+        const parsed = getParsed(
+            "\\href{JavaScript:alert('x')}{foo}",
+            new Settings({trust: context => context.protocol !== "javascript"}),
+        );
+        expect(parsed).toMatchSnapshot();
+    });
+
+    function getProtocolViaTrust(url) {
+        let protocol;
+        getParsed(`\\url{${url}}`, new Settings({
+            trust: context => protocol = context.protocol,
+        }));
+        return protocol;
+    }
+
+    it("should get protocols correctly", () => {
+        expect(getProtocolViaTrust("foo")).toBe("_relative");
+        expect(getProtocolViaTrust("Foo:")).toBe("foo");
+        expect(getProtocolViaTrust("Foo:bar")).toBe("foo");
+        expect(getProtocolViaTrust("JavaScript:")).toBe("javascript");
+        expect(getProtocolViaTrust("JavaScript:code")).toBe("javascript");
+        expect(getProtocolViaTrust("!:")).toBeUndefined();
+        expect(getProtocolViaTrust("foo&colon;")).toBeUndefined();
+        expect(getProtocolViaTrust("?query=string&colon=")).toBe("_relative");
+        expect(getProtocolViaTrust("#query=string&colon=")).toBe("_relative");
+        expect(getProtocolViaTrust("dir/file&colon")).toBe("_relative");
+        expect(getProtocolViaTrust("//foo")).toBe("_relative");
+        expect(getProtocolViaTrust("://foo")).toBeUndefined();
+        expect(getProtocolViaTrust("  \t http://")).toBe("http");
+        expect(getProtocolViaTrust("  \t http://foo")).toBe("http");
+    });
 });
 
 describe("A raw text parser", function() {
     it("should return null for a omitted optional string", function() {
         expect("\\includegraphics{https://cdn.kastatic.org/images/apple-touch-icon-57x57-precomposed.new.png}").toParse();

Vulnerable code:             context.protocol = utils.protocolFromUrl(context.url);
 * protocol (and thus is relative).
export const protocolFromUrl = function(url: string): string {
    const protocol = /^\s*([^\\/#]*?)(?::|&#0*58|&#x0*3a)/i.exec(url);
    return (protocol != null ? protocol[1] : "_relative");
exports[`href and url commands should not allow explicitly disallow protocols 1`] = `
    it("should not allow explicitly disallow protocols", () => {
Fixed code:     where `protocol` is a lowercased string like `"http"` or `"https"`
    that appears before a colon, or `"_relative"` for relative URLs.
            const protocol = utils.protocolFromUrl(context.url);
            if (protocol == null) {
                return false;
            }
            context.protocol = protocol;
 * protocol (and thus is relative), or `null` if URL has invalid protocol
 * (so should be outright rejected).
export const protocolFromUrl = function(url: string): string | null {
    // Check for possible leading protocol.
    // https://url.spec.whatwg.org/#url-parsing strips leading whitespace
    // (U+20) or C0 control (U+00-U+1F) characters.
    // eslint-disable-next-line no-control-regex
    const protocol = /^[\x00-\x20]*([^\\/#?]*?)(:|&#0*58|&#x0*3a|&colon)/i
    .exec(url);
    if (!protocol) {
        return "_relative";
    }
    // Reject weird colons
    if (protocol[2] !== ":") {
        return null;
    }
    // Reject invalid characters in scheme according to
    // https://datatracker.ietf.org/doc/html/rfc3986#section-3.1
    if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(protocol[1])) {
        return null;
    }
    // Lowercase the protocol
    return protocol[1].toLowerCase();
exports[`href and url commands should not allow explicitly disallowed protocols 1`] = `
[
  {
    "type": "color",
    "body": [
      {
        "type": "text",
        "body": [
          {
            "type": "textord",
            "mode": "text",
            "text": "\\\\"
          },
          {
            "type": "textord",
            "mode": "text",
            "text": "h"
          },
          {
            "type": "textord",
            "mode": "text",
            "text": "r"
          },
          {
            "type": "textord",
            "mode": "text",
            "text": "e"
          },
          {
            "type": "textord",
            "mode": "text",
            "text": "f"
          }
        ],
        "mode": "math"
      }
    ],
    "color": "#cc0000",
    "mode": "math"
  }
]
`;

exports[`href and url commands should not allow explicitly uppercased disallowed protocols 1`] = `
    it("should not allow explicitly disallowed protocols", () => {

    it("should not allow explicitly uppercased disallowed protocols", () => {
        const parsed = getParsed(
            "\\href{JavaScript:alert('x')}{foo}",
            new Settings({trust: context => context.protocol !== "javascript"}),
        );
        expect(parsed).toMatchSnapshot();
    });

    function getProtocolViaTrust(url) {
        let protocol;
        getParsed(`\\url{${url}}`, new Settings({
            trust: context => protocol = context.protocol,
        }));
        return protocol;
    }

    it("should get protocols correctly", () => {
        expect(getProtocolViaTrust("foo")).toBe("_relative");
        expect(getProtocolViaTrust("Foo:")).toBe("foo");
        expect(getProtocolViaTrust("Foo:bar")).toBe("foo");
        expect(getProtocolViaTrust("JavaScript:")).toBe("javascript");
        expect(getProtocolViaTrust("JavaScript:code")).toBe("javascript");
        expect(getProtocolViaTrust("!:")).toBeUndefined();
        expect(getProtocolViaTrust("foo&colon;")).toBeUndefined();
        expect(getProtocolViaTrust("?query=string&colon=")).toBe("_relative");
        expect(getProtocolViaTrust("#query=string&colon=")).toBe("_relative");
        expect(getProtocolViaTrust("dir/file&colon")).toBe("_relative");
        expect(getProtocolViaTrust("//foo")).toBe("_relative");
        expect(getProtocolViaTrust("://foo")).toBeUndefined();
        expect(getProtocolViaTrust("  \t http://")).toBe("http");
        expect(getProtocolViaTrust("  \t http://foo")).toBe("http");
    });