Skip to content

Commit 0c1eae7

Browse files
pullfrog[bot]Trigger.dev RepoOps
authored andcommitted
fix(webapp): validate chart colors and isolate style identifiers
Mono-RevId: 613a15b5658e00dd4d0bd7a4fa018dfe14158545
1 parent 8fdf109 commit 0c1eae7

9 files changed

Lines changed: 331 additions & 66 deletions

File tree

‎apps/webapp/app/components/alerts/AlertThresholdChart.render.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe("AlertMultiSeriesChart hostile queue names", () => {
4747
expect(html).not.toContain("<script>");
4848
expect(styles).not.toContain("alert(1)");
4949
expect(styles).not.toContain('q"}{x:y');
50-
expect(styles).toContain("--color-ok-queue");
50+
expect(html).toContain("ok-queue");
5151
expect(html).toContain("a&lt;/style&gt;&lt;script&gt;alert(1)&lt;/script&gt;");
5252
});
5353
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// @vitest-environment jsdom
2+
import { createElement } from "react";
3+
import { renderToStaticMarkup } from "react-dom/server";
4+
import { describe, expect, it } from "vitest";
5+
import type { ChartConfiguration } from "../metrics/QueryWidget";
6+
import { QueryResultsChart } from "./QueryResultsChart";
7+
8+
const hostileNames = [
9+
"</style><script>alert(1)</script>",
10+
'series" onload="alert(1)',
11+
"series; } body { color: red; } /*",
12+
"series], body [data-chart",
13+
"url(https://example.com)",
14+
"__proto__",
15+
"constructor",
16+
];
17+
18+
describe("query chart series rendering", () => {
19+
it.each(["bar", "line"] as const)(
20+
"preserves grouped %s labels without using them in CSS",
21+
(chartType) => {
22+
const config: ChartConfiguration = {
23+
chartType,
24+
xAxisColumn: "bucket",
25+
yAxisColumns: ["count"],
26+
groupByColumn: "group",
27+
stacked: false,
28+
sortByColumn: null,
29+
sortDirection: "asc",
30+
aggregation: "sum",
31+
seriesColors: Object.fromEntries(hostileNames.map((name) => [name, name])),
32+
};
33+
const container = document.createElement("div");
34+
container.innerHTML = renderToStaticMarkup(
35+
createElement(QueryResultsChart, {
36+
rows: hostileNames.map((group) => ({ bucket: "category", group, count: 1 })),
37+
columns: [],
38+
config,
39+
fullLegend: true,
40+
})
41+
);
42+
const style = container.querySelector("style")!;
43+
44+
for (const name of hostileNames) {
45+
expect(container.textContent).toContain(name);
46+
expect(style.textContent).not.toContain(name);
47+
}
48+
expect(container.querySelectorAll("style")).toHaveLength(1);
49+
expect(container.querySelector("script, [onload]")).toBeNull();
50+
}
51+
);
52+
53+
it.each([false, true])(
54+
"aggregates hostile names with numeric values (grouped: %s)",
55+
(grouped) => {
56+
const name = 'series.[0]</style>"{}';
57+
const config: ChartConfiguration = {
58+
chartType: "bar",
59+
xAxisColumn: "bucket",
60+
yAxisColumns: grouped ? ["count"] : [name],
61+
groupByColumn: grouped ? "group" : null,
62+
stacked: false,
63+
sortByColumn: null,
64+
sortDirection: "asc",
65+
aggregation: "sum",
66+
seriesColors: { [name]: "url(https://example.com)" },
67+
};
68+
const container = document.createElement("div");
69+
container.innerHTML = renderToStaticMarkup(
70+
createElement(QueryResultsChart, {
71+
rows: grouped
72+
? [{ bucket: "category", group: name, count: 7 }]
73+
: [{ bucket: "category", [name]: 7 }],
74+
columns: [],
75+
config,
76+
fullLegend: true,
77+
})
78+
);
79+
80+
expect(container.querySelector("style")?.textContent).not.toContain(name);
81+
expect(container.querySelector("script, [onload]")).toBeNull();
82+
container.querySelector("style")?.remove();
83+
expect(container.textContent).toContain(name);
84+
expect(container.textContent).toContain("7");
85+
}
86+
);
87+
});

‎apps/webapp/app/components/code/QueryResultsChart.tsx‎

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { aggregateValues } from "../primitives/charts/aggregation";
1515
import { MAX_SERIES } from "~/components/primitives/charts/seriesFromRows";
1616
import { getRunStatusChartColor } from "~/components/runs/v3/TaskRunStatus";
1717
import { getSeriesColor } from "./chartColors";
18+
import { safeChartColor } from "../primitives/charts/chartColors";
1819

1920
const MAX_SVG_ELEMENT_BUDGET = 6_000;
2021
const MIN_DATA_POINTS = 100;
@@ -287,10 +288,10 @@ function fillTimeGaps(
287288
__rawDate: bucket.rawDate,
288289
__granularity: granularity,
289290
__originalX: bucket.originalX,
291+
...Object.fromEntries(
292+
series.map((s) => [s, aggregateValues(bucket.values[s], aggregation)])
293+
),
290294
};
291-
for (const s of series) {
292-
point[s] = aggregateValues(bucket.values[s], aggregation);
293-
}
294295
filledData.push(point);
295296
} else {
296297
// Create a null-filled data point so gaps appear in line/bar charts
@@ -300,10 +301,8 @@ function fillTimeGaps(
300301
__rawDate: new Date(t),
301302
__granularity: granularity,
302303
__originalX: new Date(t).toISOString(),
304+
...Object.fromEntries(series.map((s) => [s, null])),
303305
};
304-
for (const s of series) {
305-
gapPoint[s] = null;
306-
}
307306
filledData.push(gapPoint);
308307
}
309308
}
@@ -591,10 +590,10 @@ function transformDataForChart(
591590
__rawDate: rawDate,
592591
__granularity: granularity,
593592
__originalX: originalX,
593+
...Object.fromEntries(
594+
yAxisColumns.map((col) => [col, aggregateValues(yValues[col], aggregation)])
595+
),
594596
};
595-
for (const yCol of yAxisColumns) {
596-
point[yCol] = aggregateValues(yValues[yCol], aggregation);
597-
}
598597
return point;
599598
});
600599

@@ -652,7 +651,7 @@ function transformDataForChart(
652651
const groupTotals = new Map<string, number>();
653652
const groupedByX = new Map<
654653
string | number,
655-
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
654+
{ values: Map<string, number[]>; rawDate: Date | null; originalX: unknown }
656655
>();
657656

658657
for (const row of rows) {
@@ -667,14 +666,14 @@ function transformDataForChart(
667666
groupTotals.set(groupValue, (groupTotals.get(groupValue) ?? 0) + Math.abs(yValue));
668667

669668
if (!groupedByX.has(xKey)) {
670-
groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] });
669+
groupedByX.set(xKey, { values: new Map(), rawDate, originalX: row[xAxisColumn] });
671670
}
672671

673672
const existing = groupedByX.get(xKey)!;
674-
if (!existing.values[groupValue]) {
675-
existing.values[groupValue] = [];
673+
if (!existing.values.has(groupValue)) {
674+
existing.values.set(groupValue, []);
676675
}
677-
existing.values[groupValue].push(yValue);
676+
existing.values.get(groupValue)!.push(yValue);
678677
}
679678

680679
// Keep only the top MAX_SERIES groups by absolute total to avoid O(n) processing
@@ -697,10 +696,13 @@ function transformDataForChart(
697696
__rawDate: rawDate,
698697
__granularity: granularity,
699698
__originalX: originalX,
699+
...Object.fromEntries(
700+
series.map((group) => {
701+
const groupValues = values.get(group);
702+
return [group, groupValues ? aggregateValues(groupValues, aggregation) : 0];
703+
})
704+
),
700705
};
701-
for (const group of series) {
702-
point[group] = values[group] ? aggregateValues(values[group], aggregation) : 0;
703-
}
704706
return point;
705707
});
706708

@@ -936,20 +938,34 @@ export const QueryResultsChart = memo(function QueryResultsChart({
936938
return col?.customRenderType === "runStatus";
937939
}, [groupByColumn, columns]);
938940

939-
// Build chart config for colors/labels
940-
const chartConfig = useMemo(() => {
941-
const cfg: ChartConfig = {};
942-
sortedSeries.forEach((s, i) => {
943-
const statusColor = groupByIsRunStatus ? getRunStatusChartColor(s) : undefined;
944-
const originalIndex = config.yAxisColumns.indexOf(s);
945-
const colorIndex = originalIndex >= 0 ? originalIndex : i;
946-
cfg[s] = {
947-
label: s,
948-
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(colorIndex),
949-
};
950-
});
951-
return cfg;
952-
}, [sortedSeries, groupByIsRunStatus, config.seriesColors, config.yAxisColumns]);
941+
// Keep data-derived names in labels, never in chart identifiers or CSS variable names.
942+
const { chartConfig, chartData, chartSeries } = useMemo(() => {
943+
const chartSeries = sortedSeries.map((_, i) => `series-${i}`);
944+
const chartConfig: ChartConfig = Object.fromEntries(
945+
sortedSeries.map((s, i) => {
946+
const statusColor = groupByIsRunStatus ? getRunStatusChartColor(s) : undefined;
947+
const originalIndex = config.yAxisColumns.indexOf(s);
948+
const colorIndex = originalIndex >= 0 ? originalIndex : i;
949+
return [
950+
chartSeries[i],
951+
{
952+
label: s,
953+
color:
954+
statusColor ?? safeChartColor(config.seriesColors?.[s]) ?? getSeriesColor(colorIndex),
955+
},
956+
];
957+
})
958+
);
959+
const chartData = data.map((point) => ({
960+
...Object.fromEntries(sortedSeries.map((s, i) => [chartSeries[i], point[s]])),
961+
__chartX: point[xDataKey],
962+
__rawDate: point.__rawDate,
963+
__granularity: point.__granularity,
964+
__originalX: point.__originalX,
965+
}));
966+
return { chartConfig, chartData, chartSeries };
967+
}, [sortedSeries, data, xDataKey, groupByIsRunStatus, config.seriesColors, config.yAxisColumns]);
968+
const chartVisibleSeries = chartSeries.slice(0, visibleSeries.length);
953969

954970
// Custom tooltip label formatter for better date display
955971
const tooltipLabelFormatter = useMemo(() => {
@@ -1182,10 +1198,10 @@ export const QueryResultsChart = memo(function QueryResultsChart({
11821198
<div ref={chartMeasureRef} className="h-full w-full">
11831199
<Chart.Root
11841200
config={chartConfig}
1185-
data={data}
1186-
dataKey={xDataKey}
1187-
series={sortedSeries}
1188-
visibleSeries={visibleSeries}
1201+
data={chartData}
1202+
dataKey="__chartX"
1203+
series={chartSeries}
1204+
visibleSeries={chartVisibleSeries}
11891205
labelFormatter={legendLabelFormatter}
11901206
showLegend={showLegend}
11911207
maxLegendItems={fullLegend ? Infinity : 5}
@@ -1215,10 +1231,10 @@ export const QueryResultsChart = memo(function QueryResultsChart({
12151231
<div ref={chartMeasureRef} className="h-full w-full">
12161232
<Chart.Root
12171233
config={chartConfig}
1218-
data={data}
1219-
dataKey={xDataKey}
1220-
series={sortedSeries}
1221-
visibleSeries={visibleSeries}
1234+
data={chartData}
1235+
dataKey="__chartX"
1236+
series={chartSeries}
1237+
visibleSeries={chartVisibleSeries}
12221238
labelFormatter={legendLabelFormatter}
12231239
showLegend={showLegend}
12241240
maxLegendItems={fullLegend ? Infinity : 5}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// @vitest-environment jsdom
2+
import { createElement } from "react";
3+
import { renderToStaticMarkup } from "react-dom/server";
4+
import { describe, expect, it } from "vitest";
5+
import { ChartContainer, type ChartConfig } from "./Chart";
6+
7+
describe("chart stylesheet rendering", () => {
8+
it.each([
9+
"</style><script>alert(1)</script>",
10+
'series" onload="alert(1)',
11+
"series; } body { color: red; } /*",
12+
"series], body [data-chart",
13+
"url(https://example.com)",
14+
])("keeps configuration key %s out of CSS and selector attributes", (key) => {
15+
const config: ChartConfig = {
16+
[key]: { label: key, color: "#123456" },
17+
invalidColor: { color: key },
18+
themed: { theme: { light: "#abc", dark: key } },
19+
};
20+
const html = renderToStaticMarkup(
21+
createElement(ChartContainer, {
22+
id: key,
23+
config,
24+
// oxlint-disable-next-line react/no-children-prop -- Required by ChartContainer's createElement props type.
25+
children: createElement("div"),
26+
})
27+
);
28+
const container = document.createElement("div");
29+
container.innerHTML = html;
30+
const chart = container.querySelector("[data-chart]")!;
31+
const chartId = chart.getAttribute("data-chart")!;
32+
const style = chart.querySelector("style")!;
33+
34+
expect(chartId).toMatch(/^chart-[a-f0-9-]+$/);
35+
expect(chart.id).toBe(key);
36+
expect(style.childNodes).toHaveLength(1);
37+
expect(style.firstChild?.nodeType).toBe(Node.TEXT_NODE);
38+
expect(style.textContent).toContain(`[data-chart=${chartId}]`);
39+
expect(style.textContent).toContain("--color-series-0: #123456;");
40+
expect(style.textContent).toContain("--color-series-1: var(--color-text-dimmed);");
41+
expect(style.textContent).toContain(":is([data-theme=dark], [data-theme=black])");
42+
expect(style.textContent).not.toContain(key);
43+
expect(style.textContent).not.toContain("&quot;");
44+
expect(container.querySelectorAll("style")).toHaveLength(1);
45+
expect(container.querySelector("script, [onload]")).toBeNull();
46+
});
47+
});

0 commit comments

Comments
 (0)