Cross-site Scripting (XSS) - Reflected in beancount/fava
Description
Cross-site Scripting (XSS) - Reflected in GitHub repository beancount/fava prior to 1.22.3.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CVE-2022-2589 is a reflected XSS vulnerability in Fava before 1.22.3 that allows an attacker to inject malicious scripts via unsanitized tooltip content.
Vulnerability
Overview CVE-2022-2589 is a reflected Cross-Site Scripting (XSS) vulnerability affecting Fava, a web-based frontend for the Beancount double-entry accounting tool. The issue exists in versions prior to 1.22.3, where tooltip content in chart components is constructed using string interpolation without proper sanitization, allowing an attacker to inject arbitrary JavaScript [1][2].
Exploitation
Scenario An attacker can exploit this vulnerability by crafting a malicious URL or supplying user-controlled data that ends up in a tooltip displayed on a Fava chart page. The vulnerable code in scatterplot, hierarchy, and bar chart components directly concatenated user-provided strings (e.g., d.description) into the tooltip innerHTML, which is then rendered by the browser [1]. No special user permissions are required other than inducing a logged-in victim to click a crafted link or visit a page with attacker-controlled content.
Impact
Successful exploitation enables an attacker to execute arbitrary JavaScript in the context of the victim's Fava session. This could lead to theft of session cookies, exfiltration of financial data displayed in the tool, or performing actions on behalf of the victim within the Fava interface [2][3].
Mitigation
The vulnerability was fixed in commit68bbb6e39319deb35ab9f18d0b6aa9fa70472539, which replaced unsafe string concatenation with DOM helper functions (domHelpers.t and domHelpers.em) that properly escape text content [1]. Users should upgrade to Fava version 1.22.3 or later. The issue is also documented in the GitHub Advisory Database under GHSA-6hcj-qrw3-m66q [2].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
favaPyPI | < 1.22.3 | 1.22.3 |
Affected products
3- beancount/beancount/favav5Range: unspecified
Patches
168bbb6e39319create tooltip contents in a xss-safe way
8 files changed · +73 −43
frontend/src/charts/bar.ts+24 −11 modified@@ -7,6 +7,8 @@ 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; @@ -38,7 +40,11 @@ export interface BarChart { /** 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( @@ -91,24 +97,31 @@ export function bar( 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; }, }); }
frontend/src/charts/context.ts+7 −12 modified@@ -2,7 +2,7 @@ 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[]; @@ -13,17 +13,12 @@ export type ChartContext = { * 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(
frontend/src/charts/line.ts+12 −6 modified@@ -12,6 +12,9 @@ import { tuple, } from "../lib/validation"; +import type { TooltipContent } from "./tooltip"; +import { domHelpers } from "./tooltip"; + export interface LineChartDatum { name: string; date: Date; @@ -26,7 +29,7 @@ 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) })); @@ -57,8 +60,10 @@ 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)), + ], }); } @@ -82,8 +87,9 @@ export function commodities( 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)), + ], }); }
frontend/src/charts/ScatterPlot.svelte+2 −2 modified@@ -10,7 +10,7 @@ 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; @@ -51,7 +51,7 @@ ); 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) => {
frontend/src/charts/tooltip.ts+19 −6 modified@@ -19,6 +19,19 @@ const hide = (): void => { 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. * @@ -27,8 +40,8 @@ const hide = (): void => { */ 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 { @@ -39,14 +52,14 @@ export function followingTooltip( } 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; }, }; @@ -56,7 +69,7 @@ export function followingTooltip( 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. @@ -78,7 +91,7 @@ export function positionedTooltip( 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 {
frontend/src/charts/Treemap.svelte+7 −4 modified@@ -11,7 +11,7 @@ AccountHierarchyDatum, AccountHierarchyNode, } from "./hierarchy"; - import { followingTooltip } from "./tooltip"; + import { domHelpers, followingTooltip } from "./tooltip"; export let data: AccountHierarchyNode; export let width: number; @@ -35,9 +35,12 @@ 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(
frontend/src/keyboard-shortcuts.ts+1 −1 modified@@ -10,7 +10,7 @@ function showTooltip(target: HTMLElement): () => void { 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
frontend/src/sidebar/index.ts+1 −1 modified@@ -26,7 +26,7 @@ export function initSidebar(): void { errorCountEl.classList.toggle("hidden", errorCount_val === 0); const span = errorCountEl.querySelector("span"); if (span) { - span.innerHTML = `${errorCount_val}`; + span.textContent = `${errorCount_val}`; } }); }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-6hcj-qrw3-m66qghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-2589ghsaADVISORY
- github.com/beancount/fava/commit/68bbb6e39319deb35ab9f18d0b6aa9fa70472539ghsax_refsource_MISCWEB
- github.com/pypa/advisory-database/tree/main/vulns/fava/PYSEC-2022-246.yamlghsaWEB
- huntr.dev/bounties/8705800d-cf2f-433d-9c3e-dbef6a3f7e08ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.