Skip to content

Commit da6eee1

Browse files
authored
feat(chart): rename formatter to dangerousHtmlFormatter for XSS awareness (#414)
BREAKING CHANGE: The formatter property in KumoChartOption['tooltip'] has been renamed to dangerousHtmlFormatter. This change makes the security implications of using HTML formatters more explicit to developers. - Update EChart.tsx with new property name and warnings - Update TimeseriesChart.tsx to use dangerousHtmlFormatter - Add migration examples in documentation - Include changeset for minor version bump
1 parent 58b5777 commit da6eee1

File tree

7 files changed

+170
-28
lines changed

7 files changed

+170
-28
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@cloudflare/kumo": minor
3+
---
4+
5+
feat(chart): rename `formatter` to `dangerousHtmlFormatter` for XSS awareness
6+
7+
BREAKING CHANGE: The `formatter` property in `KumoChartOption['tooltip']` has been renamed to `dangerousHtmlFormatter`. This change makes the security implications of using HTML formatters more explicit to developers. The API remains identical—only the name has changed.
8+
9+
Migration: Replace `formatter` with `dangerousHtmlFormatter` in your chart tooltip configurations.

packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
LayerCard,
77
} from "@cloudflare/kumo";
88
import * as echarts from "echarts/core";
9-
import type { EChartsOption } from "echarts";
9+
import type { KumoChartOption } from "@cloudflare/kumo";
1010
import { BarChart, LineChart, PieChart } from "echarts/charts";
1111
import { useEffect, useMemo, useState } from "react";
1212
import {
@@ -41,8 +41,8 @@ export function PieChartDemo() {
4141
({
4242
animation: true,
4343
animationDuration: 2000,
44-
toolbox: {
45-
show: false,
44+
tooltip: {
45+
show: true,
4646
},
4747
series: [
4848
{
@@ -56,7 +56,7 @@ export function PieChartDemo() {
5656
],
5757
},
5858
],
59-
}) as EChartsOption,
59+
}) satisfies KumoChartOption,
6060
[],
6161
);
6262

@@ -287,7 +287,7 @@ export function PieChartPreviewDemo() {
287287
],
288288
},
289289
],
290-
}) as EChartsOption,
290+
}) satisfies KumoChartOption,
291291
[],
292292
);
293293

@@ -424,7 +424,7 @@ export function BarChartDemo() {
424424
const data = useMemo(
425425
() => [
426426
{
427-
name: "Requests",
427+
name: "Requests where age > 10",
428428
data: buildSeriesData(0, 20, 3_600_000, 1),
429429
color: ChartPalette.semantic("Neutral", isDarkMode),
430430
},
@@ -445,6 +445,7 @@ export function BarChartDemo() {
445445
data={data}
446446
xAxisName="Time (UTC)"
447447
yAxisName="Count"
448+
tooltipValueFormat={(r) => r.toFixed(2)}
448449
/>
449450
);
450451
}
@@ -540,6 +541,72 @@ export function ChartExampleDemo() {
540541
);
541542
}
542543

544+
/**
545+
* Custom chart with HTML tooltip using dangerousHtmlFormatter.
546+
* USE WITH CAUTION: Only use dangerousHtmlFormatter for trusted HTML content.
547+
* Always sanitize any user-provided data using echarts.format.encodeHTML
548+
* or similar utilities to prevent XSS vulnerabilities.
549+
*/
550+
export function CustomTooltipChartDemo() {
551+
const isDarkMode = useIsDarkMode();
552+
553+
const options = useMemo<KumoChartOption>(
554+
() => ({
555+
tooltip: {
556+
trigger: "item",
557+
// Use dangerousHtmlFormatter instead of formatter to make the
558+
// security implications explicit. Only use with trusted content.
559+
dangerousHtmlFormatter: (params: any) => {
560+
// IMPORTANT: Always escape ALL dynamic values using encodeHTML
561+
// from echarts/format before including in HTML. This prevents
562+
// XSS attacks from malicious data like:
563+
// { name: "<img src=x onerror=alert('xss')>", value: "..." }
564+
const safeName = echarts.format.encodeHTML(params.name);
565+
const safeValue = echarts.format.encodeHTML(String(params.value));
566+
const safePercent = echarts.format.encodeHTML(
567+
String(Math.round(params.percent)),
568+
);
569+
570+
return `
571+
<div style="padding: 8px;">
572+
<div style="font-weight: 600; margin-bottom: 4px;">${safeName}</div>
573+
<div>Value: <strong>${safeValue}</strong></div>
574+
<div style="font-size: 12px; opacity: 0.7; margin-top: 4px;">
575+
${safePercent}% of total
576+
</div>
577+
</div>
578+
`;
579+
},
580+
},
581+
series: [
582+
{
583+
type: "pie",
584+
data: [
585+
{ value: 101, name: "Series A" },
586+
{ value: 202, name: "Series B" },
587+
// Malicious series name to demonstrate XSS protection via encodeHTML.
588+
// Without encoding, this would render an alert popup. With encodeHTML,
589+
// it safely displays as plain text.
590+
{ value: 150, name: "<img src=x onerror=alert('XSS')>" },
591+
{ value: 303, name: "Series C" },
592+
{ value: 404, name: "Series D" },
593+
],
594+
},
595+
],
596+
}),
597+
[],
598+
);
599+
600+
return (
601+
<Chart
602+
echarts={echarts}
603+
options={options}
604+
height={400}
605+
isDarkMode={isDarkMode}
606+
/>
607+
);
608+
}
609+
543610
function buildSeriesData(
544611
seed = 0,
545612
points = 50,

packages/kumo-docs-astro/src/pages/charts/custom.mdx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ sourceFile: "components/chart"
66
---
77

88
import ComponentSection from "~/components/docs/ComponentSection.astro";
9-
import { PieChartDemo } from "~/components/demos/Chart/ChartDemo";
9+
import {
10+
PieChartDemo,
11+
CustomTooltipChartDemo,
12+
} from "~/components/demos/Chart/ChartDemo";
1013
import ComponentExample from "~/components/docs/ComponentExample.astro";
1114
import Heading from "~/components/docs/Heading.astro";
1215

@@ -17,3 +20,15 @@ import Heading from "~/components/docs/Heading.astro";
1720
<PieChartDemo client:visible />
1821
</ComponentExample>
1922
</ComponentSection>
23+
24+
<ComponentSection>
25+
<Heading level={2}>Custom Tooltip with HTML</Heading>
26+
27+
For tooltips that require custom HTML formatting, use the `dangerousHtmlFormatter` property instead of the standard `formatter`. This makes the security implications more explicit.
28+
29+
When using `dangerousHtmlFormatter`, it is **strongly recommended** to sanitize any user-provided content using `echarts.format.encodeHTML` to prevent XSS vulnerabilities.
30+
31+
<ComponentExample demo="CustomTooltipChartDemo">
32+
<CustomTooltipChartDemo client:visible />
33+
</ComponentExample>
34+
</ComponentSection>

packages/kumo/src/components/chart/EChart.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type * as echarts from "echarts/core";
2-
import type { EChartsOption, SetOptionOpts } from "echarts";
2+
import type {
3+
EChartsOption,
4+
SetOptionOpts,
5+
TooltipComponentOption,
6+
} from "echarts";
37
import { forwardRef, useEffect, useRef } from "react";
48
import { cn } from "../../utils";
59
import { CHART_DARK_COLORS, CHART_LIGHT_COLORS } from "./Color";
@@ -28,6 +32,26 @@ type EChartsMouseEventParams = {
2832
color?: string;
2933
};
3034

35+
/**
36+
* Tooltip options with the `formatter` property removed and replaced with
37+
* `dangerousHtmlFormatter` to make the security implications more explicit.
38+
*/
39+
export type SafeTooltipOption = Omit<TooltipComponentOption, "formatter"> & {
40+
/**
41+
* USE WITH CAUTION: Use this only for trusted HTML content.
42+
* When building tooltip HTML with user-provided data, always sanitize
43+
* the input to prevent XSS vulnerabilities. Recommended: use
44+
* `encodeHTML` from `echarts/format` to escape HTML special characters.
45+
*/
46+
dangerousHtmlFormatter?: TooltipComponentOption["formatter"];
47+
};
48+
49+
export type KumoChartOption = {
50+
[K in keyof EChartsOption]: K extends "tooltip"
51+
? SafeTooltipOption | SafeTooltipOption[] | undefined
52+
: EChartsOption[K];
53+
};
54+
3155
/**
3256
* ECharts event handlers that can be attached to a `Chart`.
3357
* Pass a subset via the `onEvents` prop; handlers are registered lazily and
@@ -107,7 +131,7 @@ export interface ChartProps {
107131
*/
108132
echarts: typeof echarts;
109133
/** ECharts option object — passed through to `chart.setOption()` */
110-
options: EChartsOption;
134+
options: KumoChartOption;
111135
/**
112136
* Additional options passed as the second argument to `chart.setOption()`.
113137
* Defaults to `{ notMerge: false, lazyUpdate: true }`.
@@ -126,6 +150,25 @@ export interface ChartProps {
126150
onEvents?: Partial<ChartEvents>;
127151
}
128152

153+
const transformTooltip = (tooltipObj: SafeTooltipOption) => {
154+
const { dangerousHtmlFormatter, ...restOfTooltip } = tooltipObj;
155+
return {
156+
...restOfTooltip,
157+
formatter: dangerousHtmlFormatter,
158+
};
159+
};
160+
161+
const prepareChartOptions = (options: KumoChartOption): EChartsOption => {
162+
if (!options.tooltip) return options;
163+
164+
return {
165+
...options,
166+
tooltip: Array.isArray(options.tooltip)
167+
? options.tooltip.map(transformTooltip)
168+
: transformTooltip(options.tooltip),
169+
};
170+
};
171+
129172
/**
130173
* Chart — a low-level wrapper around [Apache ECharts](https://echarts.apache.org).
131174
*
@@ -209,7 +252,7 @@ export const Chart = forwardRef<echarts.ECharts, ChartProps>(function Chart(
209252
const chart = chartRef.current;
210253
if (!chart) return;
211254

212-
chart.setOption(options, {
255+
chart.setOption(prepareChartOptions(options), {
213256
notMerge: false,
214257
lazyUpdate: true,
215258
...optionUpdateBehavior,

packages/kumo/src/components/chart/TimeseriesChart.tsx

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type * as echarts from "echarts/core";
22
import type { LineSeriesOption, BarSeriesOption } from "echarts/charts";
3-
import type { EChartsOption } from "echarts";
3+
import type { EChartsOption, SeriesOption } from "echarts";
44
import { useEffect, useMemo, useRef } from "react";
5-
import { Chart, ChartEvents } from "./EChart";
5+
import { Chart, ChartEvents, KumoChartOption } from "./EChart";
66

77
/** A single data series rendered on a `TimeseriesChart` */
88
export interface TimeseriesData {
@@ -221,7 +221,6 @@ export function TimeseriesChart({
221221
...(ariaDescription && { label: { description: ariaDescription } }),
222222
},
223223
brush: {
224-
snapToData: true,
225224
xAxisIndex: "all" as const,
226225
brushType: "lineX" as const,
227226
brushMode: "single" as const,
@@ -236,8 +235,9 @@ export function TimeseriesChart({
236235
},
237236
tooltip: {
238237
trigger: "axis" as const,
238+
appendTo: "body",
239239
axisPointer: { type: "shadow" as const },
240-
formatter: (params: any) => {
240+
dangerousHtmlFormatter: (params) => {
241241
const items = Array.isArray(params) ? params : [params];
242242

243243
// Track seen series names to avoid duplicates in tooltip
@@ -250,21 +250,27 @@ export function TimeseriesChart({
250250
return true;
251251
});
252252

253-
const first = filteredParams[0];
253+
const first = filteredParams[0] as {
254+
value?: [number, number];
255+
axisValue?: number;
256+
};
257+
254258
const ts = first?.value?.[0] ?? first?.axisValue;
259+
255260
const header =
256261
ts != null
257-
? `<div style="font-weight:600;margin-bottom:4px;">${formatTimestamp(ts)}</div>`
262+
? `<div style="font-weight:600;margin-bottom:4px;">${echarts.format.encodeHTML(formatTimestamp(ts))}</div>`
258263
: "";
259264

260265
const rows = filteredParams
261266
.map((param: any) => {
262267
const value = param?.value?.[1];
263268
const formatFn = tooltipValueFormat ?? yAxisTickLabelFormat;
264269
const formattedValue = formatFn
265-
? escapeHtml(String(formatFn(value)))
266-
: escapeHtml(String(value));
267-
return `${param.marker} ${escapeHtml(String(param.seriesName))}: <strong>${formattedValue}</strong>`;
270+
? echarts.format.encodeHTML(String(formatFn(value)))
271+
: echarts.format.encodeHTML(String(value));
272+
273+
return `${param.marker} ${echarts.format.encodeHTML(param.seriesName)}: <strong>${formattedValue}</strong>`;
268274
})
269275
.join("<br/>");
270276

@@ -313,8 +319,8 @@ export function TimeseriesChart({
313319
top: 24,
314320
bottom: xAxisName ? 30 : 24,
315321
},
316-
series: transformSeries,
317-
};
322+
series: transformSeries as SeriesOption[],
323+
} satisfies KumoChartOption;
318324
}, [
319325
data,
320326
xAxisName,
@@ -494,12 +500,6 @@ function pad(n: number) {
494500
return n.toString().padStart(2, "0");
495501
}
496502

497-
function escapeHtml(str: string): string {
498-
const div = document.createElement("div");
499-
div.textContent = str;
500-
return div.innerHTML;
501-
}
502-
503503
/**
504504
* Formats a timestamp as `"YYYY-MM-DD HH:mm:ss"` for use in chart tooltips.
505505
* Accepts a Unix timestamp in milliseconds, an ISO date string, or a `Date` object.

packages/kumo/src/components/chart/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ export {
33
type TimeseriesChartProps,
44
type TimeseriesData,
55
} from "./TimeseriesChart";
6-
export { Chart, type ChartEvents, type ChartProps } from "./EChart";
6+
7+
export {
8+
Chart,
9+
type ChartEvents,
10+
type ChartProps,
11+
type KumoChartOption,
12+
} from "./EChart";
13+
714
export { ChartLegend } from "./Legend";
815
// Re-export color utilities for consumers who need to match chart colors outside of a chart instance
916
export { ChartPalette } from "./Color";

packages/kumo/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export {
215215
ChartPalette,
216216
TimeseriesChart,
217217
ChartLegend,
218+
type KumoChartOption,
218219
} from "./components/chart";
219220

220221
// Sidebar

0 commit comments

Comments
 (0)