CVE-2022-2589: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Cross-site Scripting (XSS) - Reflected in GitHub repository beancount/fava prior to 1.22.3.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2022-08-01T15:15Z
Repository: https://github.com/beancount/fava
Language: TypeScript
Files changed: frontend/src/charts/ScatterPlot.svelte, frontend/src/charts/Treemap.svelte, frontend/src/charts/bar.ts, frontend/src/charts/context.ts, frontend/src/charts/line.ts, frontend/src/charts/tooltip.ts, frontend/src/keyboard-shortcuts.ts, frontend/src/sidebar/index.ts
Commit date: 2022-07-30 10:02:45 +0200
Commit message: create tooltip contents in a xss-safe way

Diff: diff --git a/frontend/src/charts/ScatterPlot.svelte b/frontend/src/charts/ScatterPlot.svelte
index 6f28723fd..a0e1009c8 100644
--- a/frontend/src/charts/ScatterPlot.svelte
+++ b/frontend/src/charts/ScatterPlot.svelte
@@ -8,11 +8,11 @@
 
   import Axis from "./Axis.svelte";
   import { scatterplotScale } from "./helpers";
   import type { ScatterPlotDatum } from "./scatterplot";
   import type { TooltipFindNode } from "./tooltip";
-  import { positionedTooltip } from "./tooltip";
+  import { domHelpers, positionedTooltip } from "./tooltip";
 
   export let data: ScatterPlotDatum[];
   export let width: number;
 
   const today = new Date();
@@ -49,11 +49,11 @@
     (d) => x(d.date),
     (d) => y(d.type) ?? 0
   );
 
   function tooltipText(d: ScatterPlotDatum) {
-    return `${d.description}<em>${day(d.date)}</em>`;
+    return [domHelpers.t(d.description), domHelpers.em(day(d.date))];
   }
 
   const tooltipFindNode: TooltipFindNode = (xPos, yPos) => {
     const d = quad.find(xPos, yPos);
     return d && [x(d.date), y(d.type) ?? 0, tooltipText(d)];
diff --git a/frontend/src/charts/Treemap.svelte b/frontend/src/charts/Treemap.svelte
index c75b79f71..1efddd558 100644
--- a/frontend/src/charts/Treemap.svelte
+++ b/frontend/src/charts/Treemap.svelte
@@ -9,11 +9,11 @@
   import { treemapScale } from "./helpers";
   import type {
     AccountHierarchyDatum,
     AccountHierarchyNode,
   } from "./hierarchy";
-  import { followingTooltip } from "./tooltip";
+  import { domHelpers, followingTooltip } from "./tooltip";
 
   export let data: AccountHierarchyNode;
   export let width: number;
   export let currency: string;
 
@@ -33,13 +33,16 @@
 
   function tooltipText(d: AccountHierarchyNode) {
     const val = d.value ?? 0;
     const rootValue = root.value || 1;
 
-    return `${$ctx.amount(val, currency)} (${formatPercentage(
-      val / rootValue
-    )})<em>${d.data.account}</em>`;
+    return [
+      domHelpers.t(
+        `${$ctx.amount(val, currency)} (${formatPercentage(val / rootValue)})`
+      ),
+      domHelpers.em(d.data.account),
+    ];
   }
 
   function setVisibility(
     node: SVGTextElement,
     param: HierarchyRectangularNode<AccountHierarchyDatum>
diff --git a/frontend/src/charts/bar.ts b/frontend/src/charts/bar.ts
index 4180b47c8..5256ce018 100644
--- a/frontend/src/charts/bar.ts
+++ b/frontend/src/charts/bar.ts
@@ -5,10 +5,12 @@ import type { FormatterContext } from "../format";
 import { ok } from "../lib/result";
 import type { Result } from "../lib/result";
 import { array, date, number, object, record } from "../lib/validation";
 
 import type { ChartContext } from "./context";
+import type { TooltipContent } from "./tooltip";
+import { domHelpers } from "./tooltip";
 
 export interface BarChartDatumValue {
   currency: string;
   value: number;
   budget: number;
@@ -36,11 +38,15 @@ export interface BarChart {
     /** For each currency, the stacks (one series per account) */
     stacks: [currency: string, stacks: Series<BarChartDatum, string>[]][];
     /** Whether this chart contains any stacks (or is just a single account). */
     hasStackedData: boolean;
   };
-  tooltipText: (c: FormatterContext, d: BarChartDatum, e: string) => string;
+  tooltipText: (
+    c: FormatterContext,
+    d: BarChartDatum,
+    e: string
+  ) => TooltipContent;
 }
 
 const bar_validator = array(
   object({
     date,
@@ -89,26 +95,33 @@ export function bar(
 
   return ok({
     type: "barchart" as const,
     data: { accounts, bar_groups, stacks, hasStackedData },
     tooltipText: (c, d, e) => {
-      let text = "";
+      const content: TooltipContent = [];
       if (e === "") {
         d.values.forEach((a) => {
-          text += c.amount(a.value, a.currency);
-          if (a.budget) {
-            text += ` / ${c.amount(a.budget, a.currency)}`;
-          }
-          text += "<br>";
+          content.push(
+            domHelpers.t(
+              a.budget
+                ? `${c.amount(a.value, a.currency)} / ${c.amount(
+                    a.budget,
+                    a.currency
+                  )}`
+                : c.amount(a.value, a.currency)
+            )
+          );
+          content.push(domHelpers.br());
         });
       } else {
-        text += `<em>${e}</em>`;
+        content.push(domHelpers.em(e));
         d.values.forEach((a) => {
           const value = d.account_balances[e]?.[a.currency] ?? 0;
-          text += `${c.amount(value, a.currency)}<br>`;
+          content.push(domHelpers.t(`${c.amount(value, a.currency)}`));
+          content.push(domHelpers.br());
         });
       }
-      text += `<em>${d.label}</em>`;
-      return text;
+      content.push(domHelpers.em(d.label));
+      return content;
     },
   });
 }
diff --git a/frontend/src/charts/context.ts b/frontend/src/charts/context.ts
index 9adcdfae7..f493a0dd3 100644
--- a/frontend/src/charts/context.ts
+++ b/frontend/src/charts/context.ts
@@ -1,31 +1,26 @@
 import type { Readable } from "svelte/store";
 import { derived } from "svelte/store";
 
 import { currentDateFormat } from "../format";
-import { conversion, operating_currency } from "../stores";
+import { conversion, currencies, operating_currency } from "../stores";
 
 export type ChartContext = {
   currencies: string[];
   dateFormat: (date: Date) => string;
 };
 
 /**
  * The list of operating currencies, adding in the current conversion currency.
  */
 const operatingCurrenciesWithConversion = derived(
-  [operating_currency, conversion],
-  ([operating_currency_val, conversion_val]) => {
-    if (
-      !conversion_val ||
-      ["at_cost", "at_value", "units"].includes(conversion_val) ||
-      operating_currency_val.includes(conversion_val)
-    ) {
-      return operating_currency_val;
-    }
-    return [...operating_currency_val, conversion_val];
-  }
+  [operating_currency, currencies, conversion],
+  ([operating_currency_val, currencies_val, conversion_val]) =>
+    currencies_val.includes(conversion_val) &&
+    !operating_currency_val.includes(conversion_val)
+      ? [...operating_currency_val, conversion_val]
+      : operating_currency_val
 );
 
 export const chartContext: Readable<ChartContext> = derived(
   [operatingCurrenciesWithConversion, currentDateFormat],
   ([currencies_val, dateFormat]) => ({ currencies: currencies_val, dateFormat })
diff --git a/frontend/src/charts/line.ts b/frontend/src/charts/line.ts
index 222e11dcc..086505b59 100644
--- a/frontend/src/charts/line.ts
+++ b/frontend/src/charts/line.ts
@@ -10,10 +10,13 @@ import {
   record,
   string,
   tuple,
 } from "../lib/validation";
 
+import type { TooltipContent } from "./tooltip";
+import { domHelpers } from "./tooltip";
+
 export interface LineChartDatum {
   name: string;
   date: Date;
   value: number;
 }
@@ -24,11 +27,11 @@ export type LineChartData = {
 };
 
 export interface LineChart {
   type: "linechart";
   data: LineChartData[];
-  tooltipText: (c: FormatterContext, d: LineChartDatum) => string;
+  tooltipText: (c: FormatterContext, d: LineChartDatum) => TooltipContent;
 }
 
 const balances_validator = array(object({ date, balance: record(number) }));
 
 export function balances(json: unknown): Result<LineChart, string> {
@@ -55,12 +58,14 @@ export function balances(json: unknown): Result<LineChart, string> {
   }));
 
   return ok({
     type: "linechart" as const,
     data,
-    tooltipText: (c, d) =>
-      `${c.amount(d.value, d.name)}<em>${day(d.date)}</em>`,
+    tooltipText: (c, d) => [
+      domHelpers.t(c.amount(d.value, d.name)),
+      domHelpers.em(day(d.date)),
+    ],
   });
 }
 
 const commodities_validator = object({
   quote: string,
@@ -80,10 +85,11 @@ export function commodities(
   const { base, quote, prices } = res.value;
   const values = prices.map((d) => ({ name: label, date: d[0], value: d[1] }));
   return ok({
     type: "linechart" as const,
     data: [{ name: label, values }],
-    tooltipText(c, d) {
-      return `1 ${base} = ${c.amount(d.value, quote)}<em>${day(d.date)}</em>`;
-    },
+    tooltipText: (c, d) => [
+      domHelpers.t(`1 ${base} = ${c.amount(d.value, quote)}`),
+      domHelpers.em(day(d.date)),
+    ],
   });
 }
diff --git a/frontend/src/charts/tooltip.ts b/frontend/src/charts/tooltip.ts
index ac8dcf5d8..b9f792e26 100644
--- a/frontend/src/charts/tooltip.ts
+++ b/frontend/src/charts/tooltip.ts
@@ -17,48 +17,61 @@ const tooltip = (() => {
 const hide = (): void => {
   const t = tooltip();
   t.style.opacity = "0";
 };
 
+/** Some small utilities to create tooltip contents. */
+export const domHelpers = {
+  br: () => document.createElement("br"),
+  em: (content: string) => {
+    const em = document.createElement("em");
+    em.textContent = content;
+    return em;
+  },
+  t: (text: string) => document.createTextNode(text),
+};
+
+export type TooltipContent = (HTMLElement | Text)[];
+
 /**
  * Svelte action to have the given element act on mouse to show a tooltip.
  *
  * The tooltip will be positioned at the cursor and is given a tooltip getter
  * per <g> element.
  */
 export function followingTooltip(
   node: SVGElement,
-  text: () => string
-): { destroy: () => void; update: (t: () => string) => void } {
+  text: () => TooltipContent
+): { destroy: () => void; update: (t: () => TooltipContent) => void } {
   let getter = text;
   /** Event listener to have the tooltip follow the mouse. */
   function followMouse(event: MouseEvent): void {
     const t = tooltip();
     t.style.opacity = "1";
     t.style.left = `${event.pageX}px`;
     t.style.top = `${event.pageY - 15}px`;
   }
   node.addEventListener("mouseenter", () => {
     const t = tooltip();
-    t.innerHTML = getter();
+    t.replaceChildren(...getter());
   });
   node.addEventListener("mousemove", followMouse);
   node.addEventListener("mouseleave", hide);
 
   return {
     destroy: hide,
-    update(t: () => string): void {
+    update(t: () => TooltipContent): void {
       getter = t;
     },
   };
 }
 
 /** A function to find the closest node and the content to show in the tooltip. */
 export type TooltipFindNode = (
   x: number,
   y: number
-) => [number, number, string] | undefined;
+) => [number, number, TooltipContent] | undefined;
 
 /**
  * Svelte action to have the given <g> element act on mouse to show a tooltip.
  *
  * The parameter to the tooltip is a function that takes a position (relative
@@ -76,11 +89,11 @@ export function positionedTooltip(
     const matrix = node.getScreenCTM();
     if (res && matrix) {
       const [x, y, content] = res;
       const t = tooltip();
       t.style.opacity = "1";
-      t.innerHTML = content;
+      t.replaceChildren(...content);
       t.style.left = `${window.scrollX + x + matrix.e}px`;
       t.style.top = `${window.scrollY + y + matrix.f - 15}px`;
     } else {
       hide();
     }
diff --git a/frontend/src/keyboard-shortcuts.ts b/frontend/src/keyboard-shortcuts.ts
index d8d5e4f2a..005f46e95 100644
--- a/frontend/src/keyboard-shortcuts.ts
+++ b/frontend/src/keyboard-shortcuts.ts
@@ -8,11 +8,11 @@ function showTooltip(target: HTMLElement): () => void {
   const isHidden = target.classList.contains("hidden");
   if (isHidden) {
     target.classList.remove("hidden");
   }
   tooltip.className = "keyboard-tooltip";
-  tooltip.innerHTML = target.getAttribute("data-key") || "";
+  tooltip.textContent = target.getAttribute("data-key") ?? "";
   document.body.appendChild(tooltip);
   const parentCoords = target.getBoundingClientRect();
   // Padded 10px to the left if there is space or centered otherwise
   const left =
     parentCoords.left +
diff --git a/frontend/src/sidebar/index.ts b/frontend/src/sidebar/index.ts
index b83c0fe67..199a981c0 100644
--- a/frontend/src/sidebar/index.ts
+++ b/frontend/src/sidebar/index.ts
@@ -24,11 +24,11 @@ export function initSidebar(): void {
   if (errorCountEl instanceof HTMLLIElement) {
     errorCount.subscribe((errorCount_val) => {
       errorCountEl.classList.toggle("hidden", errorCount_val === 0);
       const span = errorCountEl.querySelector("span");
       if (span) {
-        span.innerHTML = `${errorCount_val}`;
+        span.textContent = `${errorCount_val}`;
       }
     });
   }
 
   const asideButton = document.getElementById("aside-button");

Vulnerable code:   import { positionedTooltip } from "./tooltip";
    return `${d.description}<em>${day(d.date)}</em>`;
  import { followingTooltip } from "./tooltip";
    return `${$ctx.amount(val, currency)} (${formatPercentage(
      val / rootValue
    )})<em>${d.data.account}</em>`;
  tooltipText: (c: FormatterContext, d: BarChartDatum, e: string) => string;
      let text = "";
          text += c.amount(a.value, a.currency);
          if (a.budget) {
            text += ` / ${c.amount(a.budget, a.currency)}`;
          }
          text += "<br>";
        text += `<em>${e}</em>`;
          text += `${c.amount(value, a.currency)}<br>`;
      text += `<em>${d.label}</em>`;
      return text;
import { conversion, operating_currency } from "../stores";
  [operating_currency, conversion],
  ([operating_currency_val, conversion_val]) => {
    if (
      !conversion_val ||
      ["at_cost", "at_value", "units"].includes(conversion_val) ||
      operating_currency_val.includes(conversion_val)
    ) {
      return operating_currency_val;
    }
    return [...operating_currency_val, conversion_val];
  }
  tooltipText: (c: FormatterContext, d: LineChartDatum) => string;
    tooltipText: (c, d) =>
      `${c.amount(d.value, d.name)}<em>${day(d.date)}</em>`,
    tooltipText(c, d) {
      return `1 ${base} = ${c.amount(d.value, quote)}<em>${day(d.date)}</em>`;
    },
  text: () => string
): { destroy: () => void; update: (t: () => string) => void } {
    t.innerHTML = getter();
    update(t: () => string): void {
) => [number, number, string] | undefined;
      t.innerHTML = content;
  tooltip.innerHTML = target.getAttribute("data-key") || "";
        span.innerHTML = `${errorCount_val}`;
Fixed code:   import { domHelpers, positionedTooltip } from "./tooltip";
    return [domHelpers.t(d.description), domHelpers.em(day(d.date))];
  import { domHelpers, followingTooltip } from "./tooltip";
    return [
      domHelpers.t(
        `${$ctx.amount(val, currency)} (${formatPercentage(val / rootValue)})`
      ),
      domHelpers.em(d.data.account),
    ];
import type { TooltipContent } from "./tooltip";
import { domHelpers } from "./tooltip";
  tooltipText: (
    c: FormatterContext,
    d: BarChartDatum,
    e: string
  ) => TooltipContent;
      const content: TooltipContent = [];
          content.push(
            domHelpers.t(
              a.budget
                ? `${c.amount(a.value, a.currency)} / ${c.amount(
                    a.budget,
                    a.currency
                  )}`
                : c.amount(a.value, a.currency)
            )
          );
          content.push(domHelpers.br());
        content.push(domHelpers.em(e));
          content.push(domHelpers.t(`${c.amount(value, a.currency)}`));
          content.push(domHelpers.br());
      content.push(domHelpers.em(d.label));
      return content;
import { conversion, currencies, operating_currency } from "../stores";
  [operating_currency, currencies, conversion],
  ([operating_currency_val, currencies_val, conversion_val]) =>
    currencies_val.includes(conversion_val) &&
    !operating_currency_val.includes(conversion_val)
      ? [...operating_currency_val, conversion_val]
      : operating_currency_val
import type { TooltipContent } from "./tooltip";
import { domHelpers } from "./tooltip";

  tooltipText: (c: FormatterContext, d: LineChartDatum) => TooltipContent;
    tooltipText: (c, d) => [
      domHelpers.t(c.amount(d.value, d.name)),
      domHelpers.em(day(d.date)),
    ],
    tooltipText: (c, d) => [
      domHelpers.t(`1 ${base} = ${c.amount(d.value, quote)}`),
      domHelpers.em(day(d.date)),
    ],
/** Some small utilities to create tooltip contents. */
export const domHelpers = {
  br: () => document.createElement("br"),
  em: (content: string) => {
    const em = document.createElement("em");
    em.textContent = content;
    return em;
  },
  t: (text: string) => document.createTextNode(text),
};

export type TooltipContent = (HTMLElement | Text)[];

  text: () => TooltipContent
): { destroy: () => void; update: (t: () => TooltipContent) => void } {
    t.replaceChildren(...getter());
    update(t: () => TooltipContent): void {
) => [number, number, TooltipContent] | undefined;
      t.replaceChildren(...content);
  tooltip.textContent = target.getAttribute("data-key") ?? "";
        span.textContent = `${errorCount_val}`;