From f5caf7a6e0a29a0919d15368f07c0fff57995bb5 Mon Sep 17 00:00:00 2001 From: f0r Date: Sat, 6 Jun 2026 14:46:08 +0800 Subject: [PATCH] [tfjs-vis] Escape HTML in heatmap tooltip labels When `xTickLabels`/`yTickLabels` are supplied, `render.heatmap` installs a custom vega-tooltip `sanitize` function to strip an internal index suffix. Supplying a custom `sanitize` bypasses vega-tooltip's default `escapeHTML`, so tick-label strings were inserted into the tooltip as raw HTML instead of text. Re-apply HTML escaping after stripping the suffix so labels render as text, matching vega-tooltip's default behavior. Extract the escaping into an `escapeHTML` helper and add unit tests. --- tfjs-vis/src/render/heatmap.ts | 20 ++++++++++++++++++-- tfjs-vis/src/render/heatmap_test.ts | 18 +++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/tfjs-vis/src/render/heatmap.ts b/tfjs-vis/src/render/heatmap.ts index 781e4a8ca1..668e23c62e 100644 --- a/tfjs-vis/src/render/heatmap.ts +++ b/tfjs-vis/src/render/heatmap.ts @@ -24,6 +24,19 @@ import {assert} from '../util/utils'; import {getDrawArea} from './render_utils'; +/** + * Escapes HTML-significant characters so a string is rendered as text rather + * than markup. Mirrors vega-tooltip's default `escapeHTML` sanitizer, which is + * bypassed whenever a custom `sanitize` function is supplied to the tooltip. + */ +export function escapeHTML(value: string): string { + return value.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + /** * Renders a heatmap. * @@ -209,8 +222,11 @@ export async function heatmap( //@ts-ignore embedOpts.tooltip = { sanitize: (value: string|number) => { - const valueString = String(value); - return valueString.replace(suffixRegex, ''); + // Strip the internal index suffix, then re-apply the HTML escaping + // that vega-tooltip performs by default. Supplying a custom `sanitize` + // bypasses that default, so without this, tick-label strings would be + // interpreted as HTML in the tooltip. + return escapeHTML(String(value).replace(suffixRegex, '')); } }; } diff --git a/tfjs-vis/src/render/heatmap_test.ts b/tfjs-vis/src/render/heatmap_test.ts index 1cec119398..d0be6b20bf 100644 --- a/tfjs-vis/src/render/heatmap_test.ts +++ b/tfjs-vis/src/render/heatmap_test.ts @@ -19,7 +19,7 @@ import * as tf from '@tensorflow/tfjs-core'; import {HeatmapData} from '../types'; -import {heatmap} from './heatmap'; +import {escapeHTML, heatmap} from './heatmap'; describe('renderHeatmap', () => { let pixelRatio: number; @@ -223,3 +223,19 @@ describe('renderHeatmap', () => { expect(threw).toBe(true); }); }); + +describe('escapeHTML', () => { + it('escapes HTML-significant characters', () => { + expect(escapeHTML('')) + .toEqual( + '<img src=x onerror="alert(1)">'); + }); + + it('escapes ampersands before other entities', () => { + expect(escapeHTML('a & b < c')).toEqual('a & b < c'); + }); + + it('leaves plain tick labels unchanged', () => { + expect(escapeHTML('alpha')).toEqual('alpha'); + }); +});