diff --git a/__tests__/integration/canvas.ts b/__tests__/integration/canvas.ts index 2807c4b71..f1714b5e4 100644 --- a/__tests__/integration/canvas.ts +++ b/__tests__/integration/canvas.ts @@ -2,7 +2,7 @@ import type { DisplayObject } from '@antv/g'; import { Canvas, CanvasEvent, resetEntityCounter } from '@antv/g'; import { Renderer } from '@antv/g-svg'; import { OffscreenCanvasContext, measureText } from './utils/offscreen-canvas-context'; -import { setMockMeasureTextWidth } from '../../src'; +import { Layout, setMockMeasureTextWidth } from '../../src'; export function createGCanvas(width: number, height: number) { resetEntityCounter(); @@ -38,6 +38,12 @@ export function sleep(n: number) { }); } +function nextFrame(canvas: Canvas) { + return new Promise((resolve) => { + canvas.requestAnimationFrame(() => resolve()); + }); +} + export async function renderCanvas(gshape: DisplayObject, wait = 300) { const bbox = gshape.getBBox(); const width = gshape.attributes.width || bbox.x + bbox.width || 400; @@ -48,8 +54,34 @@ export async function renderCanvas(gshape: DisplayObject, wait = 300) { canvas.addEventListener(CanvasEvent.READY, async () => { canvas.appendChild(gshape); - // Wait for the next tick. + // Wait for components, animations and G's mutation queue to settle. await sleep(wait); + + // Layout nodes are populated before being connected to the canvas, so + // their insertion events can run before layout has measurable children. + // Trigger a final layout after mounting, as G2 does during rendering. + let layoutTriggered = false; + canvas.getRoot().forEach((node) => { + if ( + node instanceof Layout && + node.children.length > 0 && + node.children.every((child) => child.attributes.transform === undefined) + ) { + node.layout(); + layoutTriggered = true; + } + }); + + // Flush the layout mutations before serializing the SVG. The explicit + // render keeps this deterministic in Jest, where RAF scheduling differs + // from a browser's render loop. + if (layoutTriggered) { + canvas.render(); + + // Match G2's render completion: wait two frames before taking a snapshot. + await nextFrame(canvas); + await nextFrame(canvas); + } resolve(canvas); }); }); diff --git a/__tests__/integration/snapshots/AxisLinearLabelRender1.svg b/__tests__/integration/snapshots/AxisLinearLabelRender1.svg index f47f4fd78..09a7e90bf 100644 --- a/__tests__/integration/snapshots/AxisLinearLabelRender1.svg +++ b/__tests__/integration/snapshots/AxisLinearLabelRender1.svg @@ -56,7 +56,7 @@ - +
Tick 0 @@ -67,7 +67,7 @@ - +
Tick 1 @@ -78,7 +78,7 @@ - +
Tick 2 @@ -89,7 +89,7 @@ - +
Tick 3 diff --git a/__tests__/integration/snapshots/AxisLinearLabelRender2.svg b/__tests__/integration/snapshots/AxisLinearLabelRender2.svg index 4a328d73f..267c7fb5f 100644 --- a/__tests__/integration/snapshots/AxisLinearLabelRender2.svg +++ b/__tests__/integration/snapshots/AxisLinearLabelRender2.svg @@ -56,7 +56,7 @@ - +
Tick 0 @@ -67,7 +67,7 @@ - +
Tick 1 @@ -78,7 +78,7 @@ - +
Tick 2 @@ -89,7 +89,7 @@ - +
Tick 3 diff --git a/__tests__/integration/snapshots/AxisLinearLabelRender3.svg b/__tests__/integration/snapshots/AxisLinearLabelRender3.svg index d07e6ac45..1241f8763 100644 --- a/__tests__/integration/snapshots/AxisLinearLabelRender3.svg +++ b/__tests__/integration/snapshots/AxisLinearLabelRender3.svg @@ -56,7 +56,7 @@ - +
Tick text is very long 0 @@ -67,7 +67,7 @@ - +
Tick text is very long 1 @@ -78,7 +78,7 @@ - +
Tick text is very long 2 @@ -89,7 +89,7 @@ - +
Tick text is very long 3 diff --git a/__tests__/integration/snapshots/LayoutLegend2.svg b/__tests__/integration/snapshots/LayoutLegend2.svg index 1796bad82..c2f7be105 100644 --- a/__tests__/integration/snapshots/LayoutLegend2.svg +++ b/__tests__/integration/snapshots/LayoutLegend2.svg @@ -1,7 +1,7 @@ - - + + @@ -11,7 +11,7 @@ - + diff --git a/__tests__/integration/snapshots/LayoutLegend3.svg b/__tests__/integration/snapshots/LayoutLegend3.svg index 0e817915c..271360b0e 100644 --- a/__tests__/integration/snapshots/LayoutLegend3.svg +++ b/__tests__/integration/snapshots/LayoutLegend3.svg @@ -1,7 +1,7 @@ - - + + @@ -11,7 +11,7 @@ - + diff --git a/__tests__/integration/snapshots/LayoutLegend5.svg b/__tests__/integration/snapshots/LayoutLegend5.svg index e81bae165..342022c6c 100644 --- a/__tests__/integration/snapshots/LayoutLegend5.svg +++ b/__tests__/integration/snapshots/LayoutLegend5.svg @@ -1,7 +1,7 @@ - - + + @@ -11,7 +11,7 @@ - + diff --git a/__tests__/integration/snapshots/LayoutLegend6.svg b/__tests__/integration/snapshots/LayoutLegend6.svg index e53e30d82..0ebb0ccb2 100644 --- a/__tests__/integration/snapshots/LayoutLegend6.svg +++ b/__tests__/integration/snapshots/LayoutLegend6.svg @@ -1,7 +1,7 @@ - - + + @@ -11,7 +11,7 @@ - + diff --git a/__tests__/integration/snapshots/LayoutLegend8.svg b/__tests__/integration/snapshots/LayoutLegend8.svg index ac81e2768..4394f5c2d 100644 --- a/__tests__/integration/snapshots/LayoutLegend8.svg +++ b/__tests__/integration/snapshots/LayoutLegend8.svg @@ -1,7 +1,7 @@ - - + + @@ -11,7 +11,7 @@ - + diff --git a/__tests__/integration/snapshots/LayoutLegend9.svg b/__tests__/integration/snapshots/LayoutLegend9.svg index f114659dd..0642ba192 100644 --- a/__tests__/integration/snapshots/LayoutLegend9.svg +++ b/__tests__/integration/snapshots/LayoutLegend9.svg @@ -1,7 +1,7 @@ - - + + @@ -11,7 +11,7 @@ - + diff --git a/__tests__/integration/snapshots/LayoutLegendMultiple.svg b/__tests__/integration/snapshots/LayoutLegendMultiple.svg index 4b45b6d3c..317000bb5 100644 --- a/__tests__/integration/snapshots/LayoutLegendMultiple.svg +++ b/__tests__/integration/snapshots/LayoutLegendMultiple.svg @@ -1,10 +1,10 @@ - - + + - - + + @@ -14,7 +14,7 @@ - + @@ -165,7 +165,7 @@ - + diff --git a/__tests__/unit/ui/tooltip/security.spec.ts b/__tests__/unit/ui/tooltip/security.spec.ts new file mode 100644 index 000000000..3f80a5148 --- /dev/null +++ b/__tests__/unit/ui/tooltip/security.spec.ts @@ -0,0 +1,146 @@ +import { Tooltip } from '../../../../src/ui/tooltip'; + +describe('Tooltip security', () => { + it('escapes default content when update renders title and items', () => { + const tooltip = new Tooltip({ + style: { + container: { x: 0, y: 0 }, + bounding: null, + }, + }); + const title = ''; + const name = ''; + const value = '">\''; + const index = '0" onmouseover="alert(4)'; + const data = [ + { + name, + value, + index, + color: 'red" onmouseover="alert(5)', + }, + ]; + + tooltip.update({ + title, + data, + }); + + const element = tooltip.HTMLTooltipElement; + const nameElement = element.querySelector('.tooltip-list-item-name-label'); + const valueElement = element.querySelector('.tooltip-list-item-value'); + const markerElement = element.querySelector('.tooltip-list-item-marker') as HTMLElement; + const itemElement = element.querySelector('.tooltip-list-item'); + + expect(element.querySelector('.tooltip-title')?.textContent).toBe(title); + expect(nameElement?.textContent).toBe(name); + expect(valueElement?.textContent).toBe(value); + expect(nameElement?.getAttribute('title')).toBe(name); + expect(valueElement?.getAttribute('title')).toBe(value); + expect(itemElement?.getAttribute('data-index')).toBe(index); + expect(markerElement.getAttribute('onmouseover')).toBeNull(); + expect(element.querySelector('img, svg, script')).toBeNull(); + expect(element.querySelector('[onerror], [onload], [onmouseover]')).toBeNull(); + expect(tooltip.attributes.title).toBe(title); + expect(tooltip.attributes.data[0]).toMatchObject({ name, value, index }); + + tooltip.update({ title, data }); + expect(element.querySelector('.tooltip-title')?.textContent).toBe(title); + expect(element.querySelector('.tooltip-title')?.innerHTML).not.toContain('<'); + expect(element.querySelector('.tooltip-list-item-name-label')?.textContent).toBe(name); + expect(element.querySelector('.tooltip-list-item-value')?.textContent).toBe(value); + expect(element.querySelector('.tooltip-list-item')?.getAttribute('data-index')).toBe(index); + }); + + it('keeps default structure and normal content unchanged', () => { + const tooltip = new Tooltip({ + style: { + container: { x: 0, y: 0 }, + bounding: null, + title: 'Category A', + data: [{ name: 'Sales', value: 275, color: '#5b8ff9' }], + }, + }); + const element = tooltip.HTMLTooltipElement; + + expect(element.querySelector('.tooltip-title')?.textContent).toBe('Category A'); + expect(element.querySelector('.tooltip-list-item-name-label')?.textContent).toBe('Sales'); + expect(element.querySelector('.tooltip-list-item-value')?.textContent).toBe('275'); + expect((element.querySelector('.tooltip-list-item-marker') as HTMLElement).style.background).not.toBe('black'); + expect(element.querySelectorAll('ul > li')).toHaveLength(1); + }); + + it('prevents attribute injection through colors', () => { + const tooltip = new Tooltip({ + style: { + container: { x: 0, y: 0 }, + bounding: null, + data: [ + { name: 'Attribute injection', value: 1, color: 'red" onmouseover="alert(1)' }, + { name: 'Element injection', value: 2, color: 'red">', + item: `
  • + {name} + {value} +
  • `, + }, + }, + }); + const element = tooltip.HTMLTooltipElement; + + expect(element.querySelector('.tooltip-title')).toBeInstanceOf(HTMLElement); + expect(element.querySelector('.custom-item')).toBeInstanceOf(HTMLElement); + expect(element.querySelector('.custom-name')?.textContent).toBe(''); + expect(element.querySelector('.custom-value')?.textContent).toBe(''); + expect(element.querySelector('.custom-item')?.getAttribute('data-index')).toBe('0" onmouseover="alert(1)'); + expect(element.querySelector('[onmouseover]')).toBeNull(); + }); + + it('preserves explicit string custom content', () => { + const tooltip = new Tooltip({ + style: { + container: { x: 0, y: 0 }, + bounding: null, + }, + }); + + tooltip.update({ content: 'Custom HTML' }); + + expect(tooltip.HTMLTooltipElement.querySelector('[data-custom-content="string"]')?.tagName).toBe('STRONG'); + }); + + it('preserves explicit HTMLElement custom content', () => { + const tooltip = new Tooltip({ + style: { + container: { x: 0, y: 0 }, + bounding: null, + }, + }); + const content = document.createElement('strong'); + content.textContent = ''; + + tooltip.update({ content }); + + expect(tooltip.HTMLTooltipElement.firstElementChild).toBe(content); + }); +}); diff --git a/__tests__/unit/util/string.spec.ts b/__tests__/unit/util/string.spec.ts index b47282731..0b0409503 100644 --- a/__tests__/unit/util/string.spec.ts +++ b/__tests__/unit/util/string.spec.ts @@ -1,6 +1,16 @@ -import { toUppercaseFirstLetter, toLowercaseFirstLetter, addPrefix, removePrefix } from '../../../src/util/string'; +import { + escapeHtml, + toUppercaseFirstLetter, + toLowercaseFirstLetter, + addPrefix, + removePrefix, +} from '../../../src/util/string'; describe('string', () => { + it('escapeHtml', () => { + expect(escapeHtml(``)).toBe('<img title="'&">'); + }); + it('toUppercaseFirstLetter', () => { expect(toUppercaseFirstLetter('hello')).toBe('Hello'); expect(toUppercaseFirstLetter('')).toBe(''); diff --git a/src/ui/axis/guides/labels.ts b/src/ui/axis/guides/labels.ts index 2597caef2..dab70cf9a 100644 --- a/src/ui/axis/guides/labels.ts +++ b/src/ui/axis/guides/labels.ts @@ -241,13 +241,6 @@ function renderLabel( ...labelStyle, }); - // For HTML labels, adjust x position to center align. - if (label.nodeName === 'html') { - const bbox = label.getBBox(); - const currentX = label.style.x || 0; - label.attr('x', currentX - bbox.width / 2); - } - container.attr(groupStyle); return label; } diff --git a/src/ui/legend/types.ts b/src/ui/legend/types.ts index 0cc69d85e..fe58a8e18 100644 --- a/src/ui/legend/types.ts +++ b/src/ui/legend/types.ts @@ -67,5 +67,9 @@ export type ContinuousStyleProps = LegendBaseStyleProps & export type ContinuousOptions = ComponentOptions; -export type CategoryStyleProps = LegendBaseStyleProps & CategoryItemsStyleProps; +export type CategoryStyleProps = LegendBaseStyleProps & + CategoryItemsStyleProps & { + dx?: number; + dy?: number; + }; export type CategoryOptions = ComponentOptions; diff --git a/src/ui/scrollbar/index.ts b/src/ui/scrollbar/index.ts index 799814c56..d0d54242d 100644 --- a/src/ui/scrollbar/index.ts +++ b/src/ui/scrollbar/index.ts @@ -148,8 +148,8 @@ export class Scrollbar extends Component { value: newValue, }, }; - this.dispatchEvent(new CustomEvent('scroll', evtVal)); - this.dispatchEvent(new CustomEvent('valuechange', evtVal)); + this.dispatchEvent(new CustomEvent('scroll', evtVal) as any); + this.dispatchEvent(new CustomEvent('valuechange', evtVal) as any); }; public bindEvents() { @@ -194,18 +194,18 @@ export class Scrollbar extends Component { } private onThumbMouseenter = (e: CustomEvent) => { - this.dispatchEvent(new CustomEvent('thumbMouseenter', { detail: e.detail })); + this.dispatchEvent(new CustomEvent('thumbMouseenter', { detail: e.detail }) as any); }; private onTrackMouseenter = (e: CustomEvent) => { - this.dispatchEvent(new CustomEvent('trackMouseenter', { detail: e.detail })); + this.dispatchEvent(new CustomEvent('trackMouseenter', { detail: e.detail }) as any); }; private onThumbMouseleave = (e: CustomEvent) => { - this.dispatchEvent(new CustomEvent('thumbMouseleave', { detail: e.detail })); + this.dispatchEvent(new CustomEvent('thumbMouseleave', { detail: e.detail }) as any); }; private onTrackMouseleave = (e: CustomEvent) => { - this.dispatchEvent(new CustomEvent('trackMouseleave', { detail: e.detail })); + this.dispatchEvent(new CustomEvent('trackMouseleave', { detail: e.detail }) as any); }; } diff --git a/src/ui/select/select.ts b/src/ui/select/select.ts index 57316875f..7af748ae1 100644 --- a/src/ui/select/select.ts +++ b/src/ui/select/select.ts @@ -165,7 +165,7 @@ export class Select extends Component { onClick: (value, option, item) => { this.setValue(value); onSelect?.(value, option, item); - this.dispatchEvent(new CustomEvent('change', { detail: { value, option, item } })); + this.dispatchEvent(new CustomEvent('change', { detail: { value, option, item } }) as any); hide(this.dropdown); }, }, diff --git a/src/ui/slider/index.ts b/src/ui/slider/index.ts index 8f7a3339c..37f893713 100644 --- a/src/ui/slider/index.ts +++ b/src/ui/slider/index.ts @@ -535,7 +535,7 @@ export class Slider extends Component { private dispatchCustomEvent(target: Selection, event: string, name: string) { target.on(event, (e: MouseEvent) => { e.stopPropagation(); - this.dispatchEvent(new CustomEvent(name, { detail: e })); + this.dispatchEvent(new CustomEvent(name, { detail: e }) as any); }); } @@ -627,7 +627,7 @@ export class Slider extends Component { const evt = new CustomEvent('valuechange', { detail: { oldValue: internalOldValue, value }, }); - this.dispatchEvent(evt); + this.dispatchEvent(evt as any); onChange?.(value); }; } diff --git a/src/ui/tooltip/index.ts b/src/ui/tooltip/index.ts index d6ab1d634..299459255 100644 --- a/src/ui/tooltip/index.ts +++ b/src/ui/tooltip/index.ts @@ -1,12 +1,20 @@ import { substitute, createDOM } from '@antv/util'; import { Component } from '../../core'; import { Group } from '../../shapes'; -import { BBox, applyStyleSheet, replaceChildren } from '../../util'; +import { BBox, applyStyleSheet, escapeHtml, replaceChildren } from '../../util'; import { getClassNames, getDefaultTooltipStyle } from './constant'; import type { TooltipOptions, TooltipPosition, TooltipStyleProps } from './types'; export type { TooltipStyleProps, TooltipOptions }; +function sanitizeDatum(datum: Record) { + const sanitized: Record = {}; + Object.keys(datum).forEach((key) => { + sanitized[key] = escapeHtml(datum[key]); + }); + return sanitized; +} + export class Tooltip extends Component { public static tag = 'tooltip'; @@ -30,7 +38,7 @@ export class Tooltip extends Component { const { data, template } = this.attributes; return data.map(({ name = '', color = 'black', index, ...rest }, idx) => { const datum = { name, color, index: index ?? idx, ...rest }; - return createDOM(substitute(template.item!, datum)) as HTMLElement; + return createDOM(substitute(template.item!, sanitizeDatum(datum))) as HTMLElement; }); } @@ -57,7 +65,7 @@ export class Tooltip extends Component { prefixCls: '', container: `
    `, title: `
    `, - item: `
  • + item: `
  • {name} @@ -146,7 +154,7 @@ export class Tooltip extends Component { else { if (title) { container.innerHTML = template.title!; - container.getElementsByClassName(CLASS_NAME.TITLE)[0].innerHTML = title; + container.getElementsByClassName(CLASS_NAME.TITLE)[0].innerHTML = escapeHtml(title); } else container.getElementsByClassName(CLASS_NAME.TITLE)?.[0]?.remove(); const itemsElements = this.HTMLTooltipItemsElements; const ul = document.createElement('ul'); diff --git a/src/util/string.ts b/src/util/string.ts index 0f4456e2b..bbfc4e3db 100644 --- a/src/util/string.ts +++ b/src/util/string.ts @@ -1,3 +1,15 @@ +const HTML_ESCAPE_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +export function escapeHtml(value: unknown): string { + return String(value).replace(/[&<>"']/g, (character) => HTML_ESCAPE_MAP[character]); +} + export function toUppercaseFirstLetter(string: string) { return string.toString().charAt(0).toUpperCase() + string.toString().slice(1); }