fix(charts): escape untrusted strings before rendering into chart tooltips and popups (#43397)

Co-authored-by: Superset Dev <dev@superset.apache.org>
This commit is contained in:
Evan Rusackas
2026-08-21 12:31:31 -07:00
committed by GitHub
co-authored by Superset Dev
parent edda9d1bbb
commit aee98b527d
16 changed files with 454 additions and 24 deletions
@@ -523,3 +523,73 @@ test('EChartOptionsParseError contains validation error details', () => {
);
}
});
// =============================================================================
// Creator-authored options must not reach the tooltip's innerHTML/
// navigation sinks unsanitized.
// =============================================================================
test('sanitizes tooltip string formatters instead of rejecting all markup', () => {
const input = `{ tooltip: { formatter: '<img src=x onerror=alert(1)>' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.tooltip).toEqual({ formatter: '<img src>' });
});
test('keeps presentational tags in tooltip string formatters', () => {
const input = `{ tooltip: { formatter: '{b}<br/>{c}' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.tooltip).toEqual({ formatter: '{b}<br />{c}' });
});
test('strips per-series tooltip config so its formatter never reaches the merge', () => {
const result = parseEChartOptions(
`{ series: [{ type: 'line', tooltip: { formatter: '<b onpointerover=alert(1)>x</b>' } }] }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({ series: [{ type: 'line' }] });
});
test('accepts markup-free tooltip placeholder formatters', () => {
const input = `{ tooltip: { formatter: '{b}: {c}' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({ tooltip: { formatter: '{b}: {c}' } });
});
test('rejects javascript: URLs in title link and sublink', () => {
expect(() =>
parseEChartOptions(`{ title: { link: 'javascript:alert(1)' } }`),
).toThrow(EChartOptionsParseError);
expect(() =>
parseEChartOptions(`{ title: { sublink: 'javascript:alert(1)' } }`),
).toThrow(EChartOptionsParseError);
expect(() =>
parseEChartOptions(`{ title: { link: '//evil.example/x' } }`),
).toThrow(EChartOptionsParseError);
});
test('accepts http(s) and same-origin relative title links', () => {
const result = parseEChartOptions(
`{ title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' } }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' },
});
});
test('strips tooltip extraCssText instead of passing raw CSS through', () => {
const result = parseEChartOptions(
`{ tooltip: { show: true, extraCssText: 'background:url(//evil.example/x)' } }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({ tooltip: { show: true } });
});