You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/21 16:12:17 UTC
(superset) 04/06: fix(plugin-chart-calendar): escape d3-tip tooltip HTML built from creator-controlled format strings
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch fix/chart-tooltip-html-escaping
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 79f987b04c40aa91c0845eedd68fcf899a208b7e
Author: Superset Dev <de...@superset.apache.org>
AuthorDate: Fri Aug 21 01:33:01 2026 -0700
fix(plugin-chart-calendar): escape d3-tip tooltip HTML built from creator-controlled format strings
Both the cell tip and legend tip in the vendored cal-heatmap.ts assign
their .html() callback's return value to the tooltip DOM node via
innerHTML. timeFormatter/valueFormatter are built from creator-controlled
freeForm format strings (Time format / Value format chart controls), so
their output must be treated as untrusted. Add an escapeHtml helper to
utils.ts (mirroring plugin-chart-country-map's CountryMap.ts pattern) and
wrap all three formatter interpolations in the vendored file.
Rebased onto the CALENDAR_TOOLTIP_CLASS tooltip-class change from #41589,
which touched the same lines for an unrelated reason.
---
.../plugins/plugin-chart-calendar/src/utils.ts | 12 +++++++++
.../src/vendor/cal-heatmap.ts | 10 ++++---
.../plugin-chart-calendar/test/cal-heatmap.test.ts | 31 ++++++++++++++++++++++
.../plugin-chart-calendar/test/utils.test.ts | 26 +++++++++++++++++-
4 files changed, 75 insertions(+), 4 deletions(-)
diff --git a/superset-frontend/plugins/plugin-chart-calendar/src/utils.ts b/superset-frontend/plugins/plugin-chart-calendar/src/utils.ts
index ff271cc73f7..eade32691cf 100644
--- a/superset-frontend/plugins/plugin-chart-calendar/src/utils.ts
+++ b/superset-frontend/plugins/plugin-chart-calendar/src/utils.ts
@@ -38,3 +38,15 @@ export const convertUTCTimestampToLocal = (utcTimestamp: number): number => {
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
return utcTimestamp + offsetMs;
};
+
+// SECURITY: escape HTML special characters before formatter output reaches
+// an innerHTML sink. The tooltip time/value formatters are built from
+// creator-controlled format strings (d3-time-format passes non-% characters
+// through verbatim, and an invalid d3 number format echoes the raw format
+// string back), so their output must never be treated as markup. Mirrors
+// plugin-chart-country-map's escapeHtml.
+export const escapeHtml = (text: unknown): string => {
+ const div = document.createElement('div');
+ div.textContent = String(text);
+ return div.innerHTML;
+};
diff --git a/superset-frontend/plugins/plugin-chart-calendar/src/vendor/cal-heatmap.ts b/superset-frontend/plugins/plugin-chart-calendar/src/vendor/cal-heatmap.ts
index a040ab19756..e66a62d318e 100644
--- a/superset-frontend/plugins/plugin-chart-calendar/src/vendor/cal-heatmap.ts
+++ b/superset-frontend/plugins/plugin-chart-calendar/src/vendor/cal-heatmap.ts
@@ -13,6 +13,7 @@ import d3tip from 'd3-tip';
import { t } from '@apache-superset/core/translation';
import { getContrastingColor } from '@superset-ui/core';
import { CALENDAR_TOOLTIP_CLASS } from '../tooltip';
+import { escapeHtml } from '../utils';
var d3 = typeof require === 'function' ? require('d3') : window.d3;
@@ -22,14 +23,17 @@ var CalHeatMap = function () {
'use strict';
var self = this;
+ // SECURITY: d3-tip assigns the .html() return value to the tip node via
+ // innerHTML. timeFormatter/valueFormatter are built from creator-controlled
+ // format strings (freeForm controls), so their output must be HTML-escaped.
self.tip = d3tip()
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
.direction('n')
.offset([-5, 0])
.html(
d => `
- ${self.options.timeFormatter(d.t)}: <strong>${self.options.valueFormatter(
- d.v,
+ ${escapeHtml(self.options.timeFormatter(d.t))}: <strong>${escapeHtml(
+ self.options.valueFormatter(d.v),
)}</strong>
`,
);
@@ -37,7 +41,7 @@ var CalHeatMap = function () {
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
.direction('n')
.offset([-5, 0])
- .html(d => self.options.valueFormatter(d));
+ .html(d => escapeHtml(self.options.valueFormatter(d)));
this.allowedDataType = ['json', 'csv', 'tsv', 'txt'];
diff --git a/superset-frontend/plugins/plugin-chart-calendar/test/cal-heatmap.test.ts b/superset-frontend/plugins/plugin-chart-calendar/test/cal-heatmap.test.ts
index 78f6efc6b72..b7c91217042 100644
--- a/superset-frontend/plugins/plugin-chart-calendar/test/cal-heatmap.test.ts
+++ b/superset-frontend/plugins/plugin-chart-calendar/test/cal-heatmap.test.ts
@@ -25,8 +25,12 @@ type FunctionalDateFormat = (date: Date) => string;
interface CalHeatMapInstance {
options: {
dateFormatter: DateFormatter | null;
+ timeFormatter: (t: number) => string;
+ valueFormatter: (v: number) => string;
};
formatDate(date: Date, format: string | FunctionalDateFormat): string;
+ tip: { html(): (d: { t: number; v: number }) => string };
+ legendTip: { html(): (d: number) => string };
}
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
@@ -59,3 +63,30 @@ test('CalHeatMap keeps the D3 formatter fallback', () => {
expect(calendar.formatDate(date, '%B')).toBe('January');
});
+
+test('cell tooltip HTML escapes creator-controlled formatter output', () => {
+ // SECURITY regression test: the tip's .html() callback is assigned to the
+ // tooltip node via innerHTML (d3-tip). timeFormatter/valueFormatter are
+ // built from creator-controlled freeForm format strings, so markup smuggled
+ // through either formatter must never survive into the returned HTML.
+ const calendar = new CalHeatMap();
+ calendar.options.timeFormatter = () => '<img src=x onerror=alert(1)>';
+ calendar.options.valueFormatter = () => '<svg onload=alert(2)>';
+
+ const html = calendar.tip.html()({ t: 0, v: 1 });
+
+ expect(html).not.toContain('<img');
+ expect(html).not.toContain('<svg');
+ expect(html).toContain('<img');
+ expect(html).toContain('<svg');
+});
+
+test('legend tooltip HTML escapes creator-controlled formatter output', () => {
+ const calendar = new CalHeatMap();
+ calendar.options.valueFormatter = () => '<img src=x onerror=alert(1)>';
+
+ const html = calendar.legendTip.html()(1);
+
+ expect(html).not.toContain('<img');
+ expect(html).toContain('<img');
+});
diff --git a/superset-frontend/plugins/plugin-chart-calendar/test/utils.test.ts b/superset-frontend/plugins/plugin-chart-calendar/test/utils.test.ts
index 3b9c65d84c7..359ee4018d5 100644
--- a/superset-frontend/plugins/plugin-chart-calendar/test/utils.test.ts
+++ b/superset-frontend/plugins/plugin-chart-calendar/test/utils.test.ts
@@ -17,7 +17,11 @@
* under the License.
*/
-import { getFormattedUTCTime, convertUTCTimestampToLocal } from '../src/utils';
+import {
+ getFormattedUTCTime,
+ convertUTCTimestampToLocal,
+ escapeHtml,
+} from '../src/utils';
test('getFormattedUTCTime formats local timestamp for display as UTC date', () => {
const utcTimestamp = 1420070400000; // 2015-01-01 00:00:00 UTC
@@ -87,3 +91,23 @@ test('convertUTCTimestampToLocal and getFormattedUTCTime work together to displa
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
expect(formattedTime).toContain('2024-01-01');
});
+
+test('escapeHtml neutralizes markup smuggled through a time format string', () => {
+ // SECURITY regression test: d3-time-format passes non-% characters
+ // through verbatim, so a creator-controlled format string can carry
+ // markup into the tooltip's innerHTML sink unless escaped.
+ const formatted = getFormattedUTCTime(
+ 1704067200000,
+ '%Y <img src=x onerror=alert(1)>',
+ );
+ const escaped = escapeHtml(formatted);
+
+ expect(formatted).toContain('<img');
+ expect(escaped).not.toContain('<img');
+ expect(escaped).toContain('<img');
+});
+
+test('escapeHtml stringifies non-string formatter output safely', () => {
+ expect(escapeHtml(1234)).toEqual('1234');
+ expect(escapeHtml('a & b < c')).toEqual('a & b < c');
+});