@svgrid/grid 2.6.22 → 2.6.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/GridMenus.svelte +184 -6
- package/dist/SvAutoComplete.svelte +4 -0
- package/dist/SvComboBox.svelte +5 -1
- package/dist/SvDateRangeInput.svelte +25 -3
- package/dist/SvDropDownList.svelte +4 -0
- package/dist/SvForm.svelte +8 -8
- package/dist/SvGrid.controller.svelte.d.ts +48 -1
- package/dist/SvGrid.controller.svelte.js +214 -52
- package/dist/SvGrid.css +221 -0
- package/dist/SvGrid.helpers.d.ts +55 -0
- package/dist/SvGrid.helpers.js +83 -0
- package/dist/SvGrid.svelte +145 -0
- package/dist/SvGrid.types.d.ts +105 -0
- package/dist/SvGridCellEditor.svelte +99 -17
- package/dist/SvGridSelect.svelte +6 -2
- package/dist/SvGridSelect.svelte.d.ts +1 -1
- package/dist/SvListBox.svelte +8 -0
- package/dist/SvMultiSelect.svelte +6 -2
- package/dist/SvMultiSelect.svelte.d.ts +1 -1
- package/dist/SvNumberInput.svelte +4 -0
- package/dist/SvRichCell.svelte +125 -0
- package/dist/SvRichCell.svelte.d.ts +15 -0
- package/dist/SvTextArea.svelte +5 -1
- package/dist/SvTreeSelect.svelte +6 -2
- package/dist/SvTreeSelect.svelte.d.ts +1 -1
- package/dist/builtin-editors.d.ts +2 -2
- package/dist/builtin-editors.js +19 -3
- package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
- package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
- package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
- package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
- package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
- package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
- package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
- package/dist/cdn/date-format-BNii4zeD.js +1393 -0
- package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
- package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
- package/dist/cdn/{src-D1lXwq1l.js → src-DGo7IHug.js} +5730 -5198
- package/dist/cdn/{src-C9Hihx1W.js → src-skQN5eqh.js} +7216 -6684
- package/dist/cdn/svgrid.js +14 -12
- package/dist/cdn/svgrid.svelte-external.js +14 -12
- package/dist/cell-values.d.ts +16 -0
- package/dist/cell-values.js +28 -0
- package/dist/clipboard.d.ts +22 -0
- package/dist/clipboard.js +405 -27
- package/dist/editing.js +12 -7
- package/dist/editors/cell-editors.d.ts +11 -0
- package/dist/editors/cell-editors.js +18 -1
- package/dist/grid-messages.d.ts +15 -0
- package/dist/grid-messages.js +15 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/list-option.d.ts +6 -0
- package/dist/menus.js +40 -7
- package/dist/sanitize-html.d.ts +37 -0
- package/dist/sanitize-html.js +234 -0
- package/dist/selection-bar-view.svelte.d.ts +25 -0
- package/dist/selection-bar-view.svelte.js +13 -0
- package/dist/selection.d.ts +2 -1
- package/dist/selection.js +129 -13
- package/package.json +1 -1
- package/src/GridMenus.svelte +184 -6
- package/src/SvAutoComplete.svelte +4 -0
- package/src/SvComboBox.svelte +5 -1
- package/src/SvDateRangeInput.svelte +25 -3
- package/src/SvDropDownList.svelte +4 -0
- package/src/SvForm.svelte +8 -8
- package/src/SvGrid.controller.svelte.ts +251 -61
- package/src/SvGrid.css +221 -0
- package/src/SvGrid.helpers.ts +87 -0
- package/src/SvGrid.svelte +145 -0
- package/src/SvGrid.types.ts +112 -0
- package/src/SvGridCellEditor.svelte +99 -17
- package/src/SvGridSelect.svelte +6 -2
- package/src/SvListBox.svelte +8 -0
- package/src/SvMultiSelect.svelte +6 -2
- package/src/SvNumberInput.svelte +4 -0
- package/src/SvRichCell.svelte +125 -0
- package/src/SvTextArea.svelte +5 -1
- package/src/SvTreeSelect.svelte +6 -2
- package/src/builtin-editors.test.ts +18 -1
- package/src/builtin-editors.ts +19 -3
- package/src/cell-values.ts +30 -0
- package/src/clipboard.test.ts +93 -20
- package/src/clipboard.ts +433 -33
- package/src/date-string-column.test.ts +87 -0
- package/src/editing.test.ts +25 -0
- package/src/editing.ts +12 -9
- package/src/editor-block-prop.test.ts +132 -0
- package/src/editors/cell-editors.ts +27 -1
- package/src/grid-messages.ts +34 -0
- package/src/index.ts +13 -0
- package/src/list-option.ts +6 -0
- package/src/menus.test.ts +43 -0
- package/src/menus.ts +37 -7
- package/src/move-cells.test.ts +850 -0
- package/src/number-editor-literal.test.ts +84 -0
- package/src/number-editor.grid.test.ts +160 -0
- package/src/sanitize-html.test.ts +182 -0
- package/src/sanitize-html.ts +235 -0
- package/src/selection-bar-view.svelte.ts +36 -0
- package/src/selection.test.ts +157 -0
- package/src/selection.ts +131 -13
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
- package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
- package/src/svgrid.menu-scroll-close.test.ts +204 -0
- package/src/svgrid.selection-bar-seam.test.ts +185 -0
- package/src/svgrid.upsell-license.test.ts +117 -0
- package/dist/cdn/GridMenus-BuoBPqxx.js +0 -493
- package/dist/cdn/GridMenus-n4llxoOI.js +0 -494
- package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
- package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
- package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
- package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typing a decimal into an `editorType: 'number'` cell.
|
|
3
|
+
*
|
|
4
|
+
* `<input type="number">` applies the HTML value-sanitization algorithm: the
|
|
5
|
+
* element only reports a value it can parse as a valid floating-point number.
|
|
6
|
+
* Every intermediate state on the way to "12.5" - "12." - is not one, so the
|
|
7
|
+
* element reports "" and the keystroke vanishes. The same applies to a lone
|
|
8
|
+
* "-" on the way to a negative, and to "1e" on the way to "1e3".
|
|
9
|
+
*
|
|
10
|
+
* jsdom implements that sanitization, so this is reproducible here rather than
|
|
11
|
+
* only in a browser.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
getCellEditorInputType,
|
|
16
|
+
getEditorInputType,
|
|
17
|
+
isNumericEditorInput,
|
|
18
|
+
} from './SvGrid.helpers'
|
|
19
|
+
|
|
20
|
+
describe('what a bare number input does to intermediate values', () => {
|
|
21
|
+
// This is the browser behaviour the editor has to work around, pinned so the
|
|
22
|
+
// reasoning below cannot quietly stop being true.
|
|
23
|
+
const sanitized = (raw: string) => {
|
|
24
|
+
const el = document.createElement('input')
|
|
25
|
+
el.type = 'number'
|
|
26
|
+
el.value = raw
|
|
27
|
+
return el.value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it('throws away a trailing decimal point', () => {
|
|
31
|
+
expect(sanitized('12.')).toBe('')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('throws away a lone minus sign', () => {
|
|
35
|
+
expect(sanitized('-')).toBe('')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('throws away a half-typed exponent', () => {
|
|
39
|
+
expect(sanitized('1e')).toBe('')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('keeps values that are already valid', () => {
|
|
43
|
+
expect(sanitized('12.5')).toBe('12.5')
|
|
44
|
+
expect(sanitized('-3')).toBe('-3')
|
|
45
|
+
expect(sanitized('1e3')).toBe('1e3')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('getCellEditorInputType', () => {
|
|
50
|
+
it('does NOT hand the number editor a type=number input', () => {
|
|
51
|
+
// The whole point: a text input keeps the raw keystrokes, and the value is
|
|
52
|
+
// coerced at commit by parseEditorValue - which the editor already did.
|
|
53
|
+
expect(getCellEditorInputType('number')).toBe('text')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('leaves every other editor on its native input type', () => {
|
|
57
|
+
for (const t of ['text', 'date', 'datetime', 'time', 'password', 'color'] as const) {
|
|
58
|
+
expect(getCellEditorInputType(t), t).toBe(getEditorInputType(t))
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not change the FILTER inputs, which share the other helper', () => {
|
|
63
|
+
// The filter row and filter menu still ask getEditorInputType. They have
|
|
64
|
+
// the same defect, but changing them is a separate decision - this pins
|
|
65
|
+
// that they were not swept along by accident.
|
|
66
|
+
expect(getEditorInputType('number')).toBe('number')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('isNumericEditorInput', () => {
|
|
71
|
+
it('accepts every state on the way to a number', () => {
|
|
72
|
+
// Rejecting these is the exact bug this replaces.
|
|
73
|
+
for (const v of ['', '-', '12', '12.', '12.5', '-3', '.5', '1e', '1e3', '1e-3']) {
|
|
74
|
+
expect(isNumericEditorInput(v), JSON.stringify(v)).toBe(true)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('rejects what can never become a number', () => {
|
|
79
|
+
// The guarantee type="number" used to provide, now that it is gone.
|
|
80
|
+
for (const v of ['abc', '12x', '12..5', '--3', '1 2', '12,5']) {
|
|
81
|
+
expect(isNumericEditorInput(v), JSON.stringify(v)).toBe(false)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end: typing a decimal into an `editorType: 'number'` cell keeps every
|
|
3
|
+
* keystroke, and the value still commits as a NUMBER.
|
|
4
|
+
*
|
|
5
|
+
* The unit half of this lives in `number-editor-literal.test.ts` - the browser
|
|
6
|
+
* sanitization that caused it, and the helpers that route around it. This
|
|
7
|
+
* drives the real editor, which is what actually regressed for users: typing
|
|
8
|
+
* "12.5" produced 125, because the "." was dropped and the digits ran together.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { mount, unmount } from 'svelte'
|
|
12
|
+
import SvGrid from './SvGrid.svelte'
|
|
13
|
+
import { createCoreRowModel, tableFeatures } from './index'
|
|
14
|
+
import type { ColumnDef, SvGridApi } from './index'
|
|
15
|
+
|
|
16
|
+
type Row = { id: number; label: string; amount: number }
|
|
17
|
+
const features = tableFeatures({})
|
|
18
|
+
const cols: ColumnDef<typeof features, Row>[] = [
|
|
19
|
+
{ field: 'label', header: 'Label', width: 160 },
|
|
20
|
+
{ field: 'amount', header: 'Amount', width: 140, editorType: 'number' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
function mountGrid() {
|
|
24
|
+
return new Promise<{
|
|
25
|
+
api: SvGridApi<typeof features, Row>
|
|
26
|
+
target: HTMLElement
|
|
27
|
+
changes: Array<{ newValue: unknown }>
|
|
28
|
+
destroy: () => void
|
|
29
|
+
}>((res, rej) => {
|
|
30
|
+
const target = document.createElement('div')
|
|
31
|
+
document.body.appendChild(target)
|
|
32
|
+
const changes: Array<{ newValue: unknown }> = []
|
|
33
|
+
const app = mount(SvGrid, {
|
|
34
|
+
target,
|
|
35
|
+
props: {
|
|
36
|
+
data: [{ id: 1, label: 'One', amount: 5 }],
|
|
37
|
+
columns: cols,
|
|
38
|
+
features,
|
|
39
|
+
_rowModels: { coreRowModel: createCoreRowModel() },
|
|
40
|
+
getRowId: (r: Row) => String(r.id),
|
|
41
|
+
containerHeight: 200,
|
|
42
|
+
virtualization: false,
|
|
43
|
+
editable: true,
|
|
44
|
+
onCellValueChange: (e: { newValue: unknown }) => changes.push(e),
|
|
45
|
+
onApiReady(api: SvGridApi<typeof features, Row>) {
|
|
46
|
+
res({ api, target, changes, destroy: () => { unmount(app); target.remove() } })
|
|
47
|
+
},
|
|
48
|
+
} as any,
|
|
49
|
+
})
|
|
50
|
+
queueMicrotask(() => { if (!target.querySelector('[role="grid"]')) rej(new Error('no grid')) })
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Start editing the amount cell and return its input, once the lazy chunk lands. */
|
|
55
|
+
async function editAmount(api: SvGridApi<typeof features, Row>, target: HTMLElement) {
|
|
56
|
+
api.startEditing(0, 'amount')
|
|
57
|
+
await vi.waitFor(() => {
|
|
58
|
+
expect(target.querySelector('.sv-grid-cell-editing input')).not.toBeNull()
|
|
59
|
+
})
|
|
60
|
+
return target.querySelector('.sv-grid-cell-editing input') as HTMLInputElement
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Type `text` one character at a time, as a user does. */
|
|
64
|
+
function typeInto(input: HTMLInputElement, text: string) {
|
|
65
|
+
let sofar = ''
|
|
66
|
+
for (const ch of text) {
|
|
67
|
+
sofar += ch
|
|
68
|
+
input.value = sofar
|
|
69
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
70
|
+
// Read back what survived - the element may have rewritten it.
|
|
71
|
+
sofar = input.value
|
|
72
|
+
}
|
|
73
|
+
return input.value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('number editor - decimals survive being typed', () => {
|
|
77
|
+
it('keeps the trailing decimal point mid-entry', async () => {
|
|
78
|
+
const { api, target, destroy } = await mountGrid()
|
|
79
|
+
try {
|
|
80
|
+
const input = await editAmount(api, target)
|
|
81
|
+
// The bug: with type="number" this read back "" and the dot was gone.
|
|
82
|
+
expect(typeInto(input, '12.')).toBe('12.')
|
|
83
|
+
} finally {
|
|
84
|
+
destroy()
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('commits 12.5 as the number 12.5, not 125', async () => {
|
|
89
|
+
const { api, target, changes, destroy } = await mountGrid()
|
|
90
|
+
try {
|
|
91
|
+
const input = await editAmount(api, target)
|
|
92
|
+
typeInto(input, '12.5')
|
|
93
|
+
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
|
94
|
+
|
|
95
|
+
await vi.waitFor(() => expect(changes.length).toBeGreaterThan(0))
|
|
96
|
+
expect(changes.at(-1)!.newValue).toBe(12.5)
|
|
97
|
+
expect(typeof changes.at(-1)!.newValue).toBe('number')
|
|
98
|
+
} finally {
|
|
99
|
+
destroy()
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('keeps a lone minus sign on the way to a negative', async () => {
|
|
104
|
+
const { api, target, changes, destroy } = await mountGrid()
|
|
105
|
+
try {
|
|
106
|
+
const input = await editAmount(api, target)
|
|
107
|
+
expect(typeInto(input, '-')).toBe('-')
|
|
108
|
+
typeInto(input, '-4.25')
|
|
109
|
+
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
|
110
|
+
|
|
111
|
+
await vi.waitFor(() => expect(changes.length).toBeGreaterThan(0))
|
|
112
|
+
expect(changes.at(-1)!.newValue).toBe(-4.25)
|
|
113
|
+
} finally {
|
|
114
|
+
destroy()
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('carries the mobile decimal keypad hint', async () => {
|
|
119
|
+
// Losing type="number" also loses the numeric soft keyboard, which is what
|
|
120
|
+
// inputmode restores.
|
|
121
|
+
const { api, target, destroy } = await mountGrid()
|
|
122
|
+
try {
|
|
123
|
+
const input = await editAmount(api, target)
|
|
124
|
+
expect(input.getAttribute('type')).toBe('text')
|
|
125
|
+
expect(input.getAttribute('inputmode')).toBe('decimal')
|
|
126
|
+
} finally {
|
|
127
|
+
destroy()
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('refuses characters that can never be part of a number', async () => {
|
|
132
|
+
// type="number" used to block these; a text input does not, so the editor
|
|
133
|
+
// has to. Without the guard "12x" would reach the commit and coerce to null.
|
|
134
|
+
const { api, target, destroy } = await mountGrid()
|
|
135
|
+
try {
|
|
136
|
+
const input = await editAmount(api, target)
|
|
137
|
+
typeInto(input, '12')
|
|
138
|
+
input.value = '12x'
|
|
139
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
140
|
+
expect(input.value).toBe('12')
|
|
141
|
+
} finally {
|
|
142
|
+
destroy()
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('leaves a text column alone', async () => {
|
|
147
|
+
const { api, target, destroy } = await mountGrid()
|
|
148
|
+
try {
|
|
149
|
+
api.startEditing(0, 'label')
|
|
150
|
+
await vi.waitFor(() => {
|
|
151
|
+
expect(target.querySelector('.sv-grid-cell-editing input')).not.toBeNull()
|
|
152
|
+
})
|
|
153
|
+
const input = target.querySelector('.sv-grid-cell-editing input') as HTMLInputElement
|
|
154
|
+
expect(input.getAttribute('inputmode')).toBeNull()
|
|
155
|
+
expect(typeInto(input, 'hello.')).toBe('hello.')
|
|
156
|
+
} finally {
|
|
157
|
+
destroy()
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { sanitizeHtml, renderMarkdown, htmlToText } from './sanitize-html'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The sanitizer is the one part of the rich-text cell that has to be right:
|
|
6
|
+
* everything else costs a rendering glitch, this costs script execution in the
|
|
7
|
+
* host page. The XSS cases below are the standard vectors a cell renderer gets
|
|
8
|
+
* hit with, so a regression here fails loudly.
|
|
9
|
+
*/
|
|
10
|
+
describe('sanitizeHtml - script execution vectors', () => {
|
|
11
|
+
it('drops script tags and their contents', () => {
|
|
12
|
+
const out = sanitizeHtml('<p>hi</p><script>alert(1)</script>')
|
|
13
|
+
expect(out).toContain('hi')
|
|
14
|
+
expect(out).not.toContain('script')
|
|
15
|
+
expect(out).not.toContain('alert')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('strips every inline event handler', () => {
|
|
19
|
+
for (const attr of ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus']) {
|
|
20
|
+
const out = sanitizeHtml(`<p ${attr}="alert(1)">x</p>`)
|
|
21
|
+
expect(out, attr).not.toContain(attr)
|
|
22
|
+
expect(out, attr).not.toContain('alert')
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('strips a javascript: href, including obfuscated forms', () => {
|
|
27
|
+
const vectors = [
|
|
28
|
+
'javascript:alert(1)',
|
|
29
|
+
'JaVaScRiPt:alert(1)',
|
|
30
|
+
' javascript:alert(1)',
|
|
31
|
+
'java\tscript:alert(1)',
|
|
32
|
+
'java\nscript:alert(1)',
|
|
33
|
+
'vbscript:msgbox(1)',
|
|
34
|
+
]
|
|
35
|
+
for (const href of vectors) {
|
|
36
|
+
const out = sanitizeHtml(`<a href="${href}">x</a>`)
|
|
37
|
+
expect(out.toLowerCase(), href).not.toContain('javascript:')
|
|
38
|
+
expect(out.toLowerCase(), href).not.toContain('vbscript:')
|
|
39
|
+
expect(out, href).not.toContain('href')
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('drops an img with an onerror payload but keeps a safe one', () => {
|
|
44
|
+
expect(sanitizeHtml('<img src=x onerror="alert(1)">')).not.toContain('onerror')
|
|
45
|
+
const safe = sanitizeHtml('<img src="/logo.png" alt="Logo">')
|
|
46
|
+
expect(safe).toContain('src="/logo.png"')
|
|
47
|
+
expect(safe).toContain('alt="Logo"')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('allows a base64 image data URL but not data:text/html', () => {
|
|
51
|
+
const png = 'data:image/png;base64,iVBORw0KGgo='
|
|
52
|
+
expect(sanitizeHtml(`<img src="${png}">`)).toContain('src=')
|
|
53
|
+
expect(sanitizeHtml('<img src="data:text/html;base64,PHNjcmlwdD4=">')).not.toContain('src=')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('removes style attributes and style/iframe subtrees', () => {
|
|
57
|
+
expect(sanitizeHtml('<p style="position:fixed;top:0">x</p>')).not.toContain('style')
|
|
58
|
+
expect(sanitizeHtml('<style>body{display:none}</style><p>x</p>')).not.toContain('display')
|
|
59
|
+
expect(sanitizeHtml('<iframe src="https://evil.test"></iframe>')).not.toContain('iframe')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('unwraps unknown tags but keeps their text', () => {
|
|
63
|
+
const out = sanitizeHtml('<marquee>keep me</marquee>')
|
|
64
|
+
expect(out).toContain('keep me')
|
|
65
|
+
expect(out).not.toContain('marquee')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('adds rel=noopener to links that open a new tab', () => {
|
|
69
|
+
const out = sanitizeHtml('<a href="https://x.test" target="_blank">x</a>')
|
|
70
|
+
expect(out).toContain('rel="noopener noreferrer"')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('sanitizeHtml - preserving legitimate content', () => {
|
|
75
|
+
it('keeps the formatting tags SvRichText produces', () => {
|
|
76
|
+
const html = '<p><strong>b</strong> <em>i</em> <u>u</u> <s>s</s> <code>c</code></p><ul><li>one</li></ul>'
|
|
77
|
+
expect(sanitizeHtml(html)).toBe(html)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('is idempotent', () => {
|
|
81
|
+
const once = sanitizeHtml('<p onclick="x()">hi <b>there</b></p>')
|
|
82
|
+
expect(sanitizeHtml(once)).toBe(once)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('passes plain text through untouched and handles empty input', () => {
|
|
86
|
+
expect(sanitizeHtml('just text')).toBe('just text')
|
|
87
|
+
expect(sanitizeHtml('')).toBe('')
|
|
88
|
+
expect(sanitizeHtml(null)).toBe('')
|
|
89
|
+
expect(sanitizeHtml(undefined)).toBe('')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('renderMarkdown', () => {
|
|
94
|
+
it('renders headings, emphasis, code and lists', () => {
|
|
95
|
+
expect(renderMarkdown('# Title')).toBe('<h1>Title</h1>')
|
|
96
|
+
expect(renderMarkdown('**bold**')).toContain('<strong>bold</strong>')
|
|
97
|
+
expect(renderMarkdown('*it*')).toContain('<em>it</em>')
|
|
98
|
+
expect(renderMarkdown('~~gone~~')).toContain('<del>gone</del>')
|
|
99
|
+
expect(renderMarkdown('`code`')).toContain('<code>code</code>')
|
|
100
|
+
expect(renderMarkdown('- a\n- b')).toBe('<ul><li>a</li><li>b</li></ul>')
|
|
101
|
+
expect(renderMarkdown('1. a\n2. b')).toBe('<ol><li>a</li><li>b</li></ol>')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('does not let emphasis leak into inline code', () => {
|
|
105
|
+
expect(renderMarkdown('`**not bold**`')).toContain('<code>**not bold**</code>')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('escapes raw HTML in the source instead of passing it through', () => {
|
|
109
|
+
const out = renderMarkdown('<script>alert(1)</script>')
|
|
110
|
+
expect(out).not.toContain('<script>')
|
|
111
|
+
expect(out).toContain('<script>')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('will not smuggle javascript: through a Markdown link', () => {
|
|
115
|
+
const out = renderMarkdown('[click](javascript:alert(1))')
|
|
116
|
+
expect(out).toContain('click')
|
|
117
|
+
expect(out).not.toContain('href')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('handles empty input', () => {
|
|
121
|
+
expect(renderMarkdown('')).toBe('')
|
|
122
|
+
expect(renderMarkdown(null)).toBe('')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('htmlToText', () => {
|
|
127
|
+
it('flattens markup to a single readable line', () => {
|
|
128
|
+
expect(htmlToText('<p>one</p><p>two</p>')).toBe('one two')
|
|
129
|
+
expect(htmlToText('<ul><li>a</li><li>b</li></ul>')).toBe('a b')
|
|
130
|
+
expect(htmlToText('a<br>b')).toBe('a b')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('decodes entities and drops script content', () => {
|
|
134
|
+
expect(htmlToText('<p>a & b</p>')).toBe('a & b')
|
|
135
|
+
expect(htmlToText('<script>alert(1)</script><p>safe</p>')).toBe('safe')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('handles empty input', () => {
|
|
139
|
+
expect(htmlToText('')).toBe('')
|
|
140
|
+
expect(htmlToText(null)).toBe('')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('sanitizeHtml - SSR path (no DOMParser)', () => {
|
|
145
|
+
// During SSR there is no DOM, so a second, regex-based implementation runs.
|
|
146
|
+
// It is stricter than the DOM path, and it must not be the weak link: the
|
|
147
|
+
// server-rendered HTML reaches the browser before hydration re-sanitizes.
|
|
148
|
+
const withoutDom = (html: string): string => {
|
|
149
|
+
const real = globalThis.DOMParser
|
|
150
|
+
// @ts-expect-error - deleting a global for the duration of the assertion
|
|
151
|
+
delete globalThis.DOMParser
|
|
152
|
+
try {
|
|
153
|
+
return sanitizeHtml(html)
|
|
154
|
+
} finally {
|
|
155
|
+
globalThis.DOMParser = real
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it('drops scripts, styles and iframes with their contents', () => {
|
|
160
|
+
expect(withoutDom('<p>hi</p><script>alert(1)</script>')).toBe('<p>hi</p>')
|
|
161
|
+
expect(withoutDom('<style>body{x:y}</style><p>hi</p>')).toBe('<p>hi</p>')
|
|
162
|
+
expect(withoutDom('<iframe src="https://evil.test"></iframe>ok')).toBe('ok')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('strips every attribute, so no handler or URL survives', () => {
|
|
166
|
+
expect(withoutDom('<p onclick="alert(1)">x</p>')).toBe('<p>x</p>')
|
|
167
|
+
expect(withoutDom('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>')
|
|
168
|
+
// The tag itself may stay, but with no src and no handler it is inert.
|
|
169
|
+
const img = withoutDom('<img src=x onerror="alert(1)">')
|
|
170
|
+
expect(img).toBe('<img>')
|
|
171
|
+
expect(img).not.toContain('onerror')
|
|
172
|
+
expect(img).not.toContain('src')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('keeps plain allowlisted formatting', () => {
|
|
176
|
+
expect(withoutDom('<p><strong>b</strong> <em>i</em></p>')).toBe('<p><strong>b</strong> <em>i</em></p>')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('removes unknown tags entirely', () => {
|
|
180
|
+
expect(withoutDom('<marquee>text</marquee>')).toBe('text')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sanitize-html - a dependency-free HTML sanitizer and a small Markdown
|
|
3
|
+
* renderer, sized for what a grid cell needs to display.
|
|
4
|
+
*
|
|
5
|
+
* A rich-text cell has to paint markup that came from a user, which is the one
|
|
6
|
+
* place a data grid can hand an attacker script execution. Everything rendered
|
|
7
|
+
* through `SvRichCell` goes through `sanitizeHtml` first, and the allowlist is
|
|
8
|
+
* deliberately narrower than a general-purpose sanitizer: it covers the tags
|
|
9
|
+
* `SvRichText` can produce, plus links and images.
|
|
10
|
+
*
|
|
11
|
+
* The parse uses the platform parser where there is one (`DOMParser`, which
|
|
12
|
+
* neither executes scripts nor runs loaders on a detached document) and a
|
|
13
|
+
* conservative regex stripper during SSR, where there is no DOM. The SSR path
|
|
14
|
+
* is stricter by design: when in doubt it drops markup rather than passing it
|
|
15
|
+
* through, and the hydrated client re-sanitizes with the real parser anyway.
|
|
16
|
+
*
|
|
17
|
+
* This is not a general-purpose sanitizer for arbitrary third-party HTML. It is
|
|
18
|
+
* a narrow allowlist for cell content. To render something outside the
|
|
19
|
+
* allowlist, extend it explicitly rather than widening the defaults.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Tags that survive sanitization. Everything else is unwrapped or dropped. */
|
|
23
|
+
const ALLOWED_TAGS = new Set([
|
|
24
|
+
'a', 'b', 'blockquote', 'br', 'code', 'del', 'div', 'em', 'h1', 'h2', 'h3',
|
|
25
|
+
'h4', 'h5', 'h6', 'i', 'img', 'li', 'mark', 'ol', 'p', 'pre', 's', 'small',
|
|
26
|
+
'span', 'strong', 'sub', 'sup', 'u', 'ul',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
/** Tags whose whole subtree is removed rather than unwrapped. What sits inside
|
|
30
|
+
* a <script> or <style> is code, so keeping its text would be worse than
|
|
31
|
+
* losing it. */
|
|
32
|
+
const DROP_SUBTREE = new Set([
|
|
33
|
+
'script', 'style', 'iframe', 'object', 'embed', 'noscript', 'template',
|
|
34
|
+
'svg', 'math', 'form', 'input', 'button', 'textarea', 'select', 'link',
|
|
35
|
+
'meta', 'base', 'title', 'head',
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
/** Attributes allowed per tag. `*` applies to every allowed tag. A `style`
|
|
39
|
+
* attribute is never allowed: it is the classic sanitizer hole (url(...)
|
|
40
|
+
* payloads, position tricks that let a cell cover the page chrome), and cell
|
|
41
|
+
* styling belongs to the column rather than to the data. */
|
|
42
|
+
const ALLOWED_ATTRS: Record<string, Set<string>> = {
|
|
43
|
+
'*': new Set(['class', 'dir', 'lang', 'title']),
|
|
44
|
+
a: new Set(['href', 'target', 'rel']),
|
|
45
|
+
img: new Set(['src', 'alt', 'width', 'height']),
|
|
46
|
+
ol: new Set(['start']),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** URL schemes a href/src may use. Anything else, `javascript:` and
|
|
50
|
+
* `vbscript:` included, is dropped. `data:` is allowed only on an image src
|
|
51
|
+
* and only for image media types, because `data:text/html` is a script
|
|
52
|
+
* vector. */
|
|
53
|
+
const SAFE_URL = /^(?:https?:|mailto:|tel:|\/|\.\/|\.\.\/|#)/i
|
|
54
|
+
const SAFE_IMG_DATA_URL = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,[a-z0-9+/=]*$/i
|
|
55
|
+
|
|
56
|
+
function isSafeUrl(value: string, tag: string, attr: string): boolean {
|
|
57
|
+
// Strip control characters and whitespace first: "java\tscript:" and
|
|
58
|
+
// "java\nscript:" are both read as javascript: by browsers.
|
|
59
|
+
// Matching control characters is the whole point here, hence the disable.
|
|
60
|
+
// eslint-disable-next-line no-control-regex
|
|
61
|
+
const url = value.replace(/[\u0000-\u0020\u007f]/g, '')
|
|
62
|
+
if (tag === 'img' && attr === 'src' && SAFE_IMG_DATA_URL.test(url)) return true
|
|
63
|
+
return SAFE_URL.test(url)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function attrAllowed(tag: string, attr: string): boolean {
|
|
67
|
+
if (attr.startsWith('on')) return false // every event handler, in one rule
|
|
68
|
+
if (ALLOWED_ATTRS['*']!.has(attr)) return true
|
|
69
|
+
return ALLOWED_ATTRS[tag]?.has(attr) ?? false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** DOM path: walk the parsed tree and strip anything off the allowlist. */
|
|
73
|
+
function sanitizeWithDom(html: string): string {
|
|
74
|
+
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
|
75
|
+
const walk = (node: Element): void => {
|
|
76
|
+
// Snapshot the child list: the loop mutates it as it unwraps and removes.
|
|
77
|
+
for (const child of [...node.children]) {
|
|
78
|
+
const tag = child.tagName.toLowerCase()
|
|
79
|
+
if (DROP_SUBTREE.has(tag)) {
|
|
80
|
+
child.remove()
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (!ALLOWED_TAGS.has(tag)) {
|
|
84
|
+
// Unwrap rather than drop, so an unknown wrapper does not cost the user
|
|
85
|
+
// the text inside it.
|
|
86
|
+
walk(child)
|
|
87
|
+
child.replaceWith(...child.childNodes)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
for (const attr of [...child.attributes]) {
|
|
91
|
+
const name = attr.name.toLowerCase()
|
|
92
|
+
if (!attrAllowed(tag, name)) {
|
|
93
|
+
child.removeAttribute(attr.name)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if ((name === 'href' || name === 'src') && !isSafeUrl(attr.value, tag, name)) {
|
|
97
|
+
child.removeAttribute(attr.name)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// A link that opens a new tab gets rel="noopener", so the opened page
|
|
101
|
+
// cannot reach back through window.opener.
|
|
102
|
+
if (tag === 'a' && child.getAttribute('target')) {
|
|
103
|
+
child.setAttribute('rel', 'noopener noreferrer')
|
|
104
|
+
}
|
|
105
|
+
walk(child)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
walk(doc.body)
|
|
109
|
+
return doc.body.innerHTML
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* SSR path: no DOM, so drop dangerous subtrees outright and keep only
|
|
114
|
+
* allowlisted tags in their attribute-free form. Stricter than the DOM path on
|
|
115
|
+
* purpose - the client re-sanitizes on hydration, so the cost is a moment of
|
|
116
|
+
* plainer markup rather than a hole.
|
|
117
|
+
*/
|
|
118
|
+
function sanitizeWithoutDom(html: string): string {
|
|
119
|
+
let out = html
|
|
120
|
+
for (const tag of DROP_SUBTREE) {
|
|
121
|
+
out = out.replace(new RegExp(`<${tag}\\b[\\s\\S]*?</${tag}\\s*>`, 'gi'), '')
|
|
122
|
+
out = out.replace(new RegExp(`<${tag}\\b[^>]*/?>`, 'gi'), '')
|
|
123
|
+
}
|
|
124
|
+
return out.replace(/<\/?([a-z0-9-]+)([^>]*)>/gi, (match, rawTag: string, attrs: string) => {
|
|
125
|
+
const tag = rawTag.toLowerCase()
|
|
126
|
+
if (!ALLOWED_TAGS.has(tag)) return ''
|
|
127
|
+
if (match.startsWith('</')) return `</${tag}>`
|
|
128
|
+
return attrs.trim() ? `<${tag}>` : match
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Return `html` with everything outside the allowlist removed. Idempotent, so
|
|
134
|
+
* it is safe to call on already-sanitized markup.
|
|
135
|
+
*/
|
|
136
|
+
export function sanitizeHtml(html: string | null | undefined): string {
|
|
137
|
+
const input = String(html ?? '')
|
|
138
|
+
if (!input) return ''
|
|
139
|
+
if (!input.includes('<')) return input // no angle bracket, no markup to strip
|
|
140
|
+
return typeof DOMParser === 'undefined' ? sanitizeWithoutDom(input) : sanitizeWithDom(input)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Escape text so it embeds in HTML as literal characters. */
|
|
144
|
+
function escapeHtml(text: string): string {
|
|
145
|
+
return text
|
|
146
|
+
.replace(/&/g, '&')
|
|
147
|
+
.replace(/</g, '<')
|
|
148
|
+
.replace(/>/g, '>')
|
|
149
|
+
.replace(/"/g, '"')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A sentinel that cannot occur in escaped input, used to park inline-code
|
|
153
|
+
* spans while the emphasis and link passes run over the rest. */
|
|
154
|
+
const MARK = '\u0000'
|
|
155
|
+
|
|
156
|
+
/** Inline Markdown. Code is extracted first so backticks protect their
|
|
157
|
+
* contents from the emphasis and link passes. */
|
|
158
|
+
function renderInline(text: string): string {
|
|
159
|
+
const code: string[] = []
|
|
160
|
+
let out = escapeHtml(text).replace(/`([^`]+)`/g, (_m, c: string) => {
|
|
161
|
+
code.push(c)
|
|
162
|
+
return `${MARK}${code.length - 1}${MARK}`
|
|
163
|
+
})
|
|
164
|
+
out = out
|
|
165
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<img src="$2" alt="$1">')
|
|
166
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>')
|
|
167
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
168
|
+
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
|
169
|
+
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
|
170
|
+
return out.replace(new RegExp(`${MARK}(\\d+)${MARK}`, 'g'), (_m, i: string) => `<code>${code[Number(i)]}</code>`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Render a common Markdown subset to HTML: headings, bold, italic,
|
|
175
|
+
* strikethrough, inline code, links, images, and bullet / ordered lists.
|
|
176
|
+
*
|
|
177
|
+
* Deliberately not a full CommonMark implementation - a grid cell shows a line
|
|
178
|
+
* or two, and a complete parser is a dependency this package does not take. The
|
|
179
|
+
* output is sanitized, so a Markdown link cannot smuggle `javascript:`.
|
|
180
|
+
*/
|
|
181
|
+
export function renderMarkdown(markdown: string | null | undefined): string {
|
|
182
|
+
const src = String(markdown ?? '')
|
|
183
|
+
if (!src) return ''
|
|
184
|
+
const out: string[] = []
|
|
185
|
+
let list: 'ul' | 'ol' | null = null
|
|
186
|
+
const closeList = () => {
|
|
187
|
+
if (list) out.push(`</${list}>`)
|
|
188
|
+
list = null
|
|
189
|
+
}
|
|
190
|
+
for (const line of src.split(/\r?\n/)) {
|
|
191
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/)
|
|
192
|
+
const bullet = line.match(/^\s*[-*+]\s+(.*)$/)
|
|
193
|
+
const ordered = line.match(/^\s*\d+[.)]\s+(.*)$/)
|
|
194
|
+
if (heading) {
|
|
195
|
+
closeList()
|
|
196
|
+
const level = heading[1]!.length
|
|
197
|
+
out.push(`<h${level}>${renderInline(heading[2]!)}</h${level}>`)
|
|
198
|
+
} else if (bullet || ordered) {
|
|
199
|
+
const want = bullet ? 'ul' : 'ol'
|
|
200
|
+
if (list !== want) {
|
|
201
|
+
closeList()
|
|
202
|
+
out.push(`<${want}>`)
|
|
203
|
+
list = want
|
|
204
|
+
}
|
|
205
|
+
out.push(`<li>${renderInline((bullet ?? ordered)![1]!)}</li>`)
|
|
206
|
+
} else if (!line.trim()) {
|
|
207
|
+
closeList()
|
|
208
|
+
} else {
|
|
209
|
+
closeList()
|
|
210
|
+
out.push(`<p>${renderInline(line)}</p>`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
closeList()
|
|
214
|
+
return sanitizeHtml(out.join(''))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Strip markup down to readable text, for exports, aria labels and tooltips
|
|
218
|
+
* where a cell's HTML has to collapse to one plain string. */
|
|
219
|
+
export function htmlToText(html: string | null | undefined): string {
|
|
220
|
+
const clean = sanitizeHtml(html)
|
|
221
|
+
if (!clean) return ''
|
|
222
|
+
const spaced = clean
|
|
223
|
+
.replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, ' ')
|
|
224
|
+
.replace(/<br\s*\/?>/gi, ' ')
|
|
225
|
+
return spaced
|
|
226
|
+
.replace(/<[^>]*>/g, '')
|
|
227
|
+
.replace(/ /gi, ' ')
|
|
228
|
+
.replace(/</gi, '<')
|
|
229
|
+
.replace(/>/gi, '>')
|
|
230
|
+
.replace(/"/gi, '"')
|
|
231
|
+
.replace(/'/gi, "'")
|
|
232
|
+
.replace(/&/gi, '&')
|
|
233
|
+
.replace(/\s+/g, ' ')
|
|
234
|
+
.trim()
|
|
235
|
+
}
|