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('&lt;img');
+  expect(html).toContain('&lt;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('&lt;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('&lt;img');
+});
+
+test('escapeHtml stringifies non-string formatter output safely', () => {
+  expect(escapeHtml(1234)).toEqual('1234');
+  expect(escapeHtml('a & b < c')).toEqual('a &amp; b &lt; c');
+});