close
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions apps/sim/lib/charts/spec.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
/**
* @vitest-environment node
*/
import * as echarts from 'echarts'
import { describe, expect, it } from 'vitest'
import { buildChartRenderOption } from '@/lib/charts/option'
import { parseChartSpec, shapeTableRows } from '@/lib/charts/spec'

/** Parses a chart document written as a plain object, asserting it was accepted. */
function parse(doc: Record<string, unknown>): Record<string, unknown> {
const { spec, error } = parseChartSpec(JSON.stringify(doc))
expect(error).toBeUndefined()
return spec!.option
}

const rows = [
{ month: '2024-01', region: 'NA', revenue: 100, conversion: 4 },
{ month: '2024-01', region: 'EMEA', revenue: 50, conversion: 2 },
Expand Down Expand Up @@ -85,6 +94,182 @@ describe('shapeTableRows', () => {
})
})

const XSS_FORMATTER = '<img src=x onerror="alert(1)">'

describe('parseChartSpec option confinement', () => {
it('forces the tooltip off the innerHTML path, keeping the formatter template', () => {
const option = parse({
schema_version: 1,
option: { tooltip: { trigger: 'item', formatter: XSS_FORMATTER }, series: [{ type: 'bar' }] },
})
expect(option.tooltip).toEqual({
trigger: 'item',
formatter: XSS_FORMATTER,
renderMode: 'richText',
})
})

it('overrides a spec-declared html render mode', () => {
const option = parse({
schema_version: 1,
option: { tooltip: { renderMode: 'html', formatter: XSS_FORMATTER } },
})
expect((option.tooltip as Record<string, unknown>).renderMode).toBe('richText')
})

it('reaches tooltips nested under media, baseOption, timeline options, and series', () => {
const option = parse({
schema_version: 1,
option: {
baseOption: { tooltip: { formatter: XSS_FORMATTER } },
options: [{ tooltip: { formatter: XSS_FORMATTER } }],
media: [{ query: { minWidth: 100 }, option: { tooltip: { formatter: XSS_FORMATTER } } }],
series: [
{
type: 'bar',
tooltip: { formatter: XSS_FORMATTER },
data: [{ value: 1, tooltip: { formatter: XSS_FORMATTER } }],
},
],
},
})
const renderModes: unknown[] = []
function collect(node: unknown): void {
if (node === null || typeof node !== 'object') return
if (Array.isArray(node)) {
for (const entry of node) collect(entry)
return
}
const record = node as Record<string, unknown>
if (record.formatter === XSS_FORMATTER) renderModes.push(record.renderMode)
for (const value of Object.values(record)) collect(value)
}
collect(option)
expect(renderModes).toHaveLength(5)
expect(renderModes.every((mode) => mode === 'richText')).toBe(true)
})

it('confines a tooltip declared as an array', () => {
const option = parse({
schema_version: 1,
option: { tooltip: [{ formatter: XSS_FORMATTER }, { formatter: 'plain' }] },
})
expect(option.tooltip).toEqual([
{ formatter: XSS_FORMATTER, renderMode: 'richText' },
{ formatter: 'plain', renderMode: 'richText' },
])
})

it('drops the toolbox at every level', () => {
const option = parse({
schema_version: 1,
option: {
toolbox: { feature: { dataView: { lang: [XSS_FORMATTER] } } },
baseOption: { toolbox: { feature: { saveAsImage: {} } } },
media: [{ query: { minWidth: 100 }, option: { toolbox: { show: true } } }],
},
})
expect(option.toolbox).toBeUndefined()
expect((option.baseOption as Record<string, unknown>).toolbox).toBeUndefined()
const media = option.media as Array<{ option: Record<string, unknown> }>
expect(media[0].option.toolbox).toBeUndefined()
})

it('adds no tooltip to a document that declares none', () => {
const option = parse({ schema_version: 1, option: { series: [{ type: 'bar', data: [1] }] } })
expect('tooltip' in option).toBe(false)
})

it('rejects a document too deep to walk instead of throwing', () => {
const nest = (depth: number) => {
let series = '1'
for (let i = 0; i < depth; i++) series = `[${series}]`
return `{"schema_version":1,"option":{"series":${series}}}`
}
expect(parseChartSpec(nest(500)).error).toBeUndefined()
expect(parseChartSpec(nest(50_000)).error).toMatch(/deeply/)
})

it('leaves dataset rows alone — they hold data, not components', () => {
const rows = [{ tooltip: 'ok', toolbox: 'ok' }]
const option = parse({ schema_version: 1, option: { dataset: { source: rows } } })
expect((option.dataset as Record<string, unknown>).source).toEqual(rows)
})
})

describe('chart option confinement against echarts', () => {
/**
* Pins the library behavior the confinement relies on: the tooltip's render
* mode is resolved from the single tooltip component, and a tooltip reaching
* that component only through `media` would otherwise default to HTML. Renders
* server-side so the assertion runs without a DOM.
*/
function renderModel(option: Record<string, unknown>) {
const chart = echarts.init(null, null, {
renderer: 'svg',
ssr: true,
width: 400,
height: 300,
})
try {
chart.setOption(buildChartRenderOption({ option, rows: null }))
return chart.getModel()
} finally {
chart.dispose()
}
}

it('resolves a media-only tooltip to the canvas render mode', () => {
const model = renderModel(
parse({
schema_version: 1,
option: {
xAxis: {},
yAxis: {},
series: [{ type: 'bar', data: [1, 2] }],
media: [{ query: { minWidth: 100 }, option: { tooltip: { formatter: XSS_FORMATTER } } }],
},
})
)
expect(model.getComponent('tooltip')?.get('renderMode')).toBe('richText')
})

it.each([
['top-level', { tooltip: { formatter: XSS_FORMATTER } }],
[
'series-level',
{ series: [{ type: 'bar', data: [1], tooltip: { formatter: XSS_FORMATTER } }] },
],
['non-object top-level', { tooltip: 'x', series: [{ type: 'bar', data: [1], tooltip: {} }] }],
['array', { tooltip: [{ formatter: XSS_FORMATTER }] }],
['baseOption', { baseOption: { tooltip: { formatter: XSS_FORMATTER } } }],
])('leaves no tooltip component on the innerHTML path (%s)', (_label, declaration) => {
const model = renderModel(
parse({
schema_version: 1,
option: { xAxis: {}, yAxis: {}, series: [{ type: 'bar', data: [1] }], ...declaration },
})
)
const renderMode = model.getComponent('tooltip')?.get('renderMode')
expect(renderMode === undefined || renderMode === 'richText').toBe(true)
})

it('never instantiates a toolbox component', () => {
const model = renderModel(
parse({
schema_version: 1,
option: {
xAxis: {},
yAxis: {},
series: [{ type: 'bar', data: [1] }],
toolbox: { feature: { dataView: {} } },
},
})
)
expect(model.getComponent('toolbox')).toBeUndefined()
})
})

describe('parseChartSpec table-shaping validation', () => {
it('rejects groupBy without aggregate and bad ops', () => {
const base = { schema_version: 1, option: {} }
Expand Down
66 changes: 62 additions & 4 deletions apps/sim/lib/charts/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ export interface ChartStaticSource {

/**
* A `.chart` file (`text/x-sim-chart`): a declarative ECharts document. The
* `option` is a plain ECharts option object; `source` optionally supplies the
* data — inline rows, or a live read of a Sim table injected as
* `option.dataset.source` so the chart stays current with the table.
* `option` is a plain ECharts option object, confined to ECharts' canvas render
* paths by {@link confineOptionToCanvas}; `source` optionally supplies the data —
* inline rows, or a live read of a Sim table injected as `option.dataset.source`
* so the chart stays current with the table.
*/
export interface ChartSpec {
schema_version: number
Expand All @@ -48,6 +49,52 @@ export interface ChartSpec {
option: Record<string, unknown>
}

/** ECharts' tooltip render mode that draws into the chart canvas instead of the DOM. */
const CANVAS_TOOLTIP_RENDER_MODE = 'richText'

/**
* Closes the paths by which an ECharts option reaches the DOM, so a `.chart`
* document cannot inject markup into the page that renders it. A document is
* untrusted input: any workspace member authors one, and `/f/<token>` renders it
* to anonymous visitors on the app origin.
*
* ECharts draws through canvas with two exceptions. A `tooltip` left in its
* default `renderMode: 'html'` assigns its content to `el.innerHTML`, and a
* string `formatter` is used as that content's template verbatim — only the
* values substituted into it are escaped. A `toolbox` assigns `dataView.lang`
* entries to `innerHTML` and fills a `saveAsImage` popup with `document.write`.
* Forcing the render mode and dropping the toolbox leaves the document no DOM
* sink at all, which holds whatever any individual option value contains.
*
* The walk is deep because `tooltip` is not only a top-level component:
* `baseOption`, `media[].option`, and timeline `options[]` each carry their own,
* a tooltip declared *only* under `media` still instantiates the component in
* HTML mode once its query matches, and a `media` entry can override a top-level
* `renderMode`. `dataset` is skipped — it holds rows, not components.
*/
function confineOptionToCanvas(node: unknown): void {
if (Array.isArray(node)) {
for (const entry of node) confineOptionToCanvas(entry)
return
}
if (node === null || typeof node !== 'object') return
const record = node as Record<string, unknown>
// biome-ignore lint/performance/noDelete: the key must be absent, not undefined-valued
if ('toolbox' in record) delete record.toolbox
for (const key of Object.keys(record)) {
if (key === 'dataset') continue
const value = record[key]
if (key === 'tooltip') {
for (const tooltip of Array.isArray(value) ? value : [value]) {
if (tooltip !== null && typeof tooltip === 'object') {
;(tooltip as Record<string, unknown>).renderMode = CANVAS_TOOLTIP_RENDER_MODE
}
}
}
confineOptionToCanvas(value)
}
}

export function parseChartSpec(content: string): { spec?: ChartSpec; error?: string } {
let raw: unknown
try {
Expand Down Expand Up @@ -98,14 +145,25 @@ export function parseChartSpec(content: string): { spec?: ChartSpec; error?: str
return { error: '"source.type" must be "static" or "table"' }
}
}
const option = doc.option as Record<string, unknown>
try {
confineOptionToCanvas(option)
} catch {
// Exhausting the stack is the only way the walk fails, and an option that
// deep never reaches the renderer anyway — `structuredClone` in
// `buildChartRenderOption` throws on it too. Rejecting it here shows the
// document's error card instead of failing inside the render.
return { error: 'chart document is nested too deeply' }
}

// Built explicitly from the validated fields — no blanket cast, and no
// unvalidated extra keys riding along on the parsed spec.
return {
spec: {
schema_version: 1,
title: typeof doc.title === 'string' ? doc.title : undefined,
source,
option: doc.option as Record<string, unknown>,
option,
},
}
}
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/lib/pptx-renderer/renderer/chart-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2061,9 +2061,12 @@ function buildBubbleChartOption(
: undefined,
tooltip: {
trigger: 'item',
// The name comes from the uploaded document and lands in the tooltip's innerHTML.
// ECharts escapes the markup it builds itself; a hand-built one escapes its own.
formatter: (params: unknown) => {
const p = params as { seriesName: string; value: number[] }
return `${p.seriesName}<br/>x: ${p.value[0]}, y: ${p.value[1]}, size: ${p.value[2]}`
const name = echarts.format.encodeHTML(p.seriesName)
return `${name}<br/>x: ${p.value[0]}, y: ${p.value[1]}, size: ${p.value[2]}`
},
},
legend: buildLegendOption(
Expand Down
Loading