Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/appkit-ui/src/react/charts/__tests__/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
interface EChartsOption {
title?: { text?: string };
legend?: unknown;
tooltip?: {
formatter?: (params: { data: [number, number, number] }) => string;
};
xAxis: { type: string; data?: unknown[] };
yAxis: { type: string; data?: unknown[] };
series: Array<{
Expand Down Expand Up @@ -646,4 +649,28 @@ describe("buildHeatmapOption", () => {

expect(opt.series[0].data).toEqual(ctx.heatmapData);
});

test("tooltip formatter renders labels and values", () => {
const ctx = createHeatmapContext();
const opt = asOption(buildHeatmapOption(ctx));

const output = opt.tooltip?.formatter?.({ data: [1, 2, 25] });
expect(output).toBe("10AM, Wed: 25");
});

test("tooltip formatter escapes HTML in category values (XSS)", () => {
const ctx = {
...createHeatmapContext(),
xData: ["<img src=x onerror=alert(1)>", "10AM"],
yAxisData: ["<script>alert(2)</script>", "Tue"],
};
const opt = asOption(buildHeatmapOption(ctx));

const output = opt.tooltip?.formatter?.({ data: [0, 0, 10] });
expect(output).not.toContain("<");
expect(output).not.toContain(">");
expect(output).toBe(
"&lt;img src=x onerror=alert(1)&gt;, &lt;script&gt;alert(2)&lt;/script&gt;: 10",
);
});
});
34 changes: 34 additions & 0 deletions packages/appkit-ui/src/react/charts/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import {
createTimeSeriesData,
escapeHtml,
formatLabel,
sortTimeSeriesAscending,
toChartArray,
Expand Down Expand Up @@ -129,6 +130,39 @@ describe("formatLabel", () => {
});
});

describe("escapeHtml", () => {
test("escapes all five HTML special characters", () => {
expect(escapeHtml("&")).toBe("&amp;");
expect(escapeHtml("<")).toBe("&lt;");
expect(escapeHtml(">")).toBe("&gt;");
expect(escapeHtml('"')).toBe("&quot;");
expect(escapeHtml("'")).toBe("&#39;");
});

test("escapes all special characters in a combined string", () => {
expect(escapeHtml(`<img src="x" onerror='alert(1)'>&`)).toBe(
"&lt;img src=&quot;x&quot; onerror=&#39;alert(1)&#39;&gt;&amp;",
);
});

test("escapes ampersand first so generated entities are not double-encoded", () => {
// "&" is replaced before "<" and ">", so the entities produced by
// escaping "<" and ">" keep their single "&" prefix.
expect(escapeHtml("<&>")).toBe("&lt;&amp;&gt;");
});

test("double-escapes pre-escaped input (expected for raw data)", () => {
// escapeHtml assumes raw, unescaped input; already-escaped entities
// are escaped again. This is intentional for untrusted data.
expect(escapeHtml("&amp;")).toBe("&amp;amp;");
});

test("leaves safe strings unchanged", () => {
expect(escapeHtml("hello world 123")).toBe("hello world 123");
expect(escapeHtml("")).toBe("");
});
});

describe("truncateLabel", () => {
test("truncates long strings with ellipsis", () => {
// Implementation: value.slice(0, maxLength) + "..."
Expand Down
15 changes: 11 additions & 4 deletions packages/appkit-ui/src/react/charts/options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { ChartType } from "./types";
import { createTimeSeriesData, formatLabel, truncateLabel } from "./utils";
import {
createTimeSeriesData,
escapeHtml,
formatLabel,
truncateLabel,
} from "./utils";

// ============================================================================
// Option Builder Types
Expand Down Expand Up @@ -189,9 +194,11 @@ export function buildHeatmapOption(
trigger: "item",
formatter: (params: { data: [number, number, number] }) => {
const [xIdx, yIdx, value] = params.data;
const xLabel = ctx.xData[xIdx] ?? xIdx;
const yLabel = ctx.yAxisData[yIdx] ?? yIdx;
return `${xLabel}, ${yLabel}: ${value}`;
// Function formatter output is injected as raw HTML into the
// tooltip DOM, so data-derived labels must be escaped.
const xLabel = escapeHtml(String(ctx.xData[xIdx] ?? xIdx));
const yLabel = escapeHtml(String(ctx.yAxisData[yIdx] ?? yIdx));
return `${xLabel}, ${yLabel}: ${escapeHtml(String(value))}`;
},
},
grid: {
Expand Down
19 changes: 19 additions & 0 deletions packages/appkit-ui/src/react/charts/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@ export function formatLabel(field: string): string {
);
}

/**
* Escapes HTML special characters to prevent XSS.
* Required for ECharts function formatters: unlike string-template
* formatters, their return values are injected as raw HTML into the
* tooltip DOM.
*
* Only for HTML tooltip contexts; do NOT use on canvas-rendered
* axis/series label formatters — canvas text is not HTML and would
* display literal entities (e.g. "&amp;").
*/
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

/**
* Truncates a label to a maximum length with ellipsis.
*/
Expand Down
Loading