@nextsparkjs/theme-blog 0.1.0-beta.188 → 0.1.0-beta.189
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/components/editor/WysiwygEditor.tsx +56 -7
- package/lib/sanitize-post-html.ts +52 -0
- package/package.json +11 -4
- package/templates/(public)/posts/[slug]/page.tsx +2 -1
- package/tests/jest/components/WysiwygEditor.test.tsx +117 -0
- package/tests/jest/jest.config.cjs +19 -2
- package/tests/jest/lib/sanitize-post-html.test.ts +47 -0
- package/tests/jest/tsconfig.jest.json +6 -0
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* A rich text editor built with native contentEditable.
|
|
7
7
|
* Provides formatting, markdown shortcuts, and a clean interface.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Formatting uses document.execCommand. Stored HTML, and markup pasted or
|
|
10
|
+
* dropped in, is sanitised before it reaches the DOM, in the editable area and
|
|
11
|
+
* in the preview.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import { useRef, useCallback, useEffect, useState } from 'react'
|
|
13
15
|
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
14
16
|
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
17
|
+
import { postHtmlHasUnsafeMarkup, sanitizePostHtml } from '@/themes/blog/lib/sanitize-post-html'
|
|
15
18
|
import {
|
|
16
19
|
Bold,
|
|
17
20
|
Italic,
|
|
@@ -78,6 +81,22 @@ const TOOLBAR_GROUPS: ToolbarButton[][] = [
|
|
|
78
81
|
],
|
|
79
82
|
]
|
|
80
83
|
|
|
84
|
+
/** The caret position under a point, so a drop lands where the pointer is. */
|
|
85
|
+
function rangeAtPoint(x: number, y: number): Range | null {
|
|
86
|
+
const doc = document as Document & {
|
|
87
|
+
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null
|
|
88
|
+
caretRangeFromPoint?: (x: number, y: number) => Range | null
|
|
89
|
+
}
|
|
90
|
+
const position = doc.caretPositionFromPoint?.(x, y)
|
|
91
|
+
if (position) {
|
|
92
|
+
const range = document.createRange()
|
|
93
|
+
range.setStart(position.offsetNode, position.offset)
|
|
94
|
+
range.collapse(true)
|
|
95
|
+
return range
|
|
96
|
+
}
|
|
97
|
+
return doc.caretRangeFromPoint?.(x, y) ?? null
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
export function WysiwygEditor({
|
|
82
101
|
value,
|
|
83
102
|
onChange,
|
|
@@ -91,15 +110,18 @@ export function WysiwygEditor({
|
|
|
91
110
|
const [isFocused, setIsFocused] = useState(false)
|
|
92
111
|
const isComposing = useRef(false)
|
|
93
112
|
|
|
94
|
-
//
|
|
113
|
+
// Fill the editable area. A value from outside (a loaded post) always goes in
|
|
114
|
+
// sanitised, and so does the value when leaving the preview mounts a new,
|
|
115
|
+
// empty area. One the area produced itself (typing, a paste, a command) is
|
|
116
|
+
// replaced only when sanitising would change it: rewriting it for a mere
|
|
117
|
+
// re-serialisation would move the caret to the start.
|
|
95
118
|
useEffect(() => {
|
|
96
119
|
if (editorRef.current && !isComposing.current) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
editorRef.current.innerHTML = value || ''
|
|
120
|
+
if (editorRef.current.innerHTML !== value || postHtmlHasUnsafeMarkup(value)) {
|
|
121
|
+
editorRef.current.innerHTML = sanitizePostHtml(value)
|
|
100
122
|
}
|
|
101
123
|
}
|
|
102
|
-
}, [value])
|
|
124
|
+
}, [value, isPreview])
|
|
103
125
|
|
|
104
126
|
// Auto focus
|
|
105
127
|
useEffect(() => {
|
|
@@ -115,6 +137,31 @@ export function WysiwygEditor({
|
|
|
115
137
|
}
|
|
116
138
|
}, [onChange])
|
|
117
139
|
|
|
140
|
+
// Markup pasted or dropped in that sanitising would change goes in sanitised,
|
|
141
|
+
// in place of the browser's own insertion. Replacing it once it is in the
|
|
142
|
+
// editable area is too late: an attribute such as onerror fires as soon as
|
|
143
|
+
// the element is in the document. Anything else keeps the browser's paste
|
|
144
|
+
// and drop, which is also how text moves within the editor.
|
|
145
|
+
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
146
|
+
const html = e.clipboardData.getData('text/html')
|
|
147
|
+
if (!postHtmlHasUnsafeMarkup(html)) return
|
|
148
|
+
e.preventDefault()
|
|
149
|
+
document.execCommand('insertHTML', false, sanitizePostHtml(html))
|
|
150
|
+
}, [])
|
|
151
|
+
|
|
152
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
153
|
+
const html = e.dataTransfer.getData('text/html')
|
|
154
|
+
if (!postHtmlHasUnsafeMarkup(html)) return
|
|
155
|
+
e.preventDefault()
|
|
156
|
+
const range = rangeAtPoint(e.clientX, e.clientY)
|
|
157
|
+
if (range) {
|
|
158
|
+
const selection = window.getSelection()
|
|
159
|
+
selection?.removeAllRanges()
|
|
160
|
+
selection?.addRange(range)
|
|
161
|
+
}
|
|
162
|
+
document.execCommand('insertHTML', false, sanitizePostHtml(html))
|
|
163
|
+
}, [])
|
|
164
|
+
|
|
118
165
|
const execCommand = useCallback((command: string, value?: string) => {
|
|
119
166
|
if (command === 'createLink') {
|
|
120
167
|
const url = prompt('Enter URL:')
|
|
@@ -275,7 +322,7 @@ export function WysiwygEditor({
|
|
|
275
322
|
<div
|
|
276
323
|
className="flex-1 min-h-0 prose prose-sm max-w-none p-4 overflow-auto"
|
|
277
324
|
style={{ minHeight }}
|
|
278
|
-
dangerouslySetInnerHTML={{ __html: value
|
|
325
|
+
dangerouslySetInnerHTML={{ __html: value ? sanitizePostHtml(value) : '<p class="text-muted-foreground">Nothing to preview...</p>' }}
|
|
279
326
|
data-cy="wysiwyg-preview"
|
|
280
327
|
/>
|
|
281
328
|
) : (
|
|
@@ -302,6 +349,8 @@ export function WysiwygEditor({
|
|
|
302
349
|
)}
|
|
303
350
|
style={{ minHeight }}
|
|
304
351
|
onInput={handleInput}
|
|
352
|
+
onPaste={handlePaste}
|
|
353
|
+
onDrop={handleDrop}
|
|
305
354
|
onKeyDown={handleKeyDown}
|
|
306
355
|
onFocus={() => setIsFocused(true)}
|
|
307
356
|
onBlur={() => setIsFocused(false)}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A post body is HTML written by anyone who can create a post, a team member
|
|
5
|
+
* and not only an owner, so every place that turns it back into DOM sanitises
|
|
6
|
+
* it first: the public page, the editor and the editor's preview.
|
|
7
|
+
*
|
|
8
|
+
* DOMPurify needs a DOM. Where there is none (a server render pass) this
|
|
9
|
+
* returns '' rather than the raw string, failing closed.
|
|
10
|
+
*/
|
|
11
|
+
export function sanitizePostHtml(html: string | null | undefined): string {
|
|
12
|
+
if (!html) return ''
|
|
13
|
+
if (!DOMPurify.isSupported) return ''
|
|
14
|
+
return DOMPurify.sanitize(html)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether sanitising changes the markup beyond what parsing it already does.
|
|
19
|
+
*
|
|
20
|
+
* Parsing alone rewrites markup that is not unsafe: the browser's editing
|
|
21
|
+
* commands leave a list inside a <p>, and the parser moves it out. So the
|
|
22
|
+
* original and the sanitised result are both parsed and compared as trees; any
|
|
23
|
+
* difference is something the sanitiser took out or altered. It does not read
|
|
24
|
+
* DOMPurify.removed, which DOMPurify documents as unfit for security decisions
|
|
25
|
+
* and which misses changes such as an attribute it rewrites.
|
|
26
|
+
*
|
|
27
|
+
* Both are parsed as a fragment, the way the editable area receives a paste. A
|
|
28
|
+
* document parse moves a leading <script> or <base> into a <head> that a
|
|
29
|
+
* comparison of bodies never sees. A template's content is inert, so nothing in
|
|
30
|
+
* the unsanitised markup runs or loads while it is inspected. Where there is no
|
|
31
|
+
* DOM the answer is true, sending callers down the sanitising path.
|
|
32
|
+
*/
|
|
33
|
+
export function postHtmlHasUnsafeMarkup(html: string | null | undefined): boolean {
|
|
34
|
+
if (!html) return false
|
|
35
|
+
if (!DOMPurify.isSupported || typeof document === 'undefined') return true
|
|
36
|
+
return !sameMarkup(parseFragment(html), parseFragment(DOMPurify.sanitize(html)))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseFragment(markup: string): DocumentFragment {
|
|
40
|
+
const template = document.createElement('template')
|
|
41
|
+
template.innerHTML = markup
|
|
42
|
+
return template.content
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Equal trees, including what each <template> holds: isEqualNode does not look inside one. */
|
|
46
|
+
function sameMarkup(a: DocumentFragment, b: DocumentFragment): boolean {
|
|
47
|
+
if (!a.isEqualNode(b)) return false
|
|
48
|
+
const templatesA = Array.from(a.querySelectorAll('template'))
|
|
49
|
+
const templatesB = Array.from(b.querySelectorAll('template'))
|
|
50
|
+
return templatesA.length === templatesB.length
|
|
51
|
+
&& templatesA.every((template, i) => sameMarkup(template.content, templatesB[i].content))
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextsparkjs/theme-blog",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.189",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./config/theme.config.ts",
|
|
7
7
|
"requiredPlugins": [],
|
|
8
|
-
"dependencies": {
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@tailwindcss/container-queries": "^0.1.1",
|
|
10
|
+
"dompurify": "^3.2.7",
|
|
11
|
+
"next-themes": "^0.4.6"
|
|
12
|
+
},
|
|
9
13
|
"peerDependencies": {
|
|
10
|
-
"@nextsparkjs/core": "
|
|
11
|
-
"@nextsparkjs/testing": "
|
|
14
|
+
"@nextsparkjs/core": ">=0.1.0-0",
|
|
15
|
+
"@nextsparkjs/testing": ">=0.1.0-0",
|
|
12
16
|
"lucide-react": "^0.539.0",
|
|
13
17
|
"next": "^15.0.0",
|
|
14
18
|
"next-intl": "^4.0.0",
|
|
@@ -19,5 +23,8 @@
|
|
|
19
23
|
"nextspark": {
|
|
20
24
|
"type": "theme",
|
|
21
25
|
"name": "blog"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/dompurify": "^3.2.0"
|
|
22
29
|
}
|
|
23
30
|
}
|
|
@@ -12,6 +12,7 @@ import Link from 'next/link'
|
|
|
12
12
|
import Image from 'next/image'
|
|
13
13
|
import { ArrowLeft, Calendar, Clock, Loader2, Share2, Bookmark } from 'lucide-react'
|
|
14
14
|
import { use, useEffect, useState } from 'react'
|
|
15
|
+
import { sanitizePostHtml } from '@/themes/blog/lib/sanitize-post-html'
|
|
15
16
|
import { ReadingProgress } from '@/themes/blog/components/public/ReadingProgress'
|
|
16
17
|
import { AuthorBio } from '@/themes/blog/components/public/AuthorBio'
|
|
17
18
|
import { RelatedPosts } from '@/themes/blog/components/public/RelatedPosts'
|
|
@@ -294,7 +295,7 @@ export default function PostPage({ params }: PageProps) {
|
|
|
294
295
|
|
|
295
296
|
{/* Article Content */}
|
|
296
297
|
<div className="article-content">
|
|
297
|
-
<div dangerouslySetInnerHTML={{ __html: renderContent(post.content) }} />
|
|
298
|
+
<div dangerouslySetInnerHTML={{ __html: sanitizePostHtml(renderContent(post.content)) }} />
|
|
298
299
|
</div>
|
|
299
300
|
|
|
300
301
|
{/* Divider */}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { render, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { WysiwygEditor } from '@/themes/blog/components/editor/WysiwygEditor'
|
|
3
|
+
|
|
4
|
+
// What a team member can store as a post body through the API.
|
|
5
|
+
const STORED = [
|
|
6
|
+
'<p>kept <strong>bold</strong></p>',
|
|
7
|
+
'<img src="x" onerror="globalThis.pwned = 1">',
|
|
8
|
+
'<script>globalThis.pwned = 1</script>',
|
|
9
|
+
'<a href="javascript:globalThis.pwned = 1">link</a>',
|
|
10
|
+
].join('')
|
|
11
|
+
|
|
12
|
+
function expectInert(root: Element) {
|
|
13
|
+
expect(root.querySelector('script')).toBeNull()
|
|
14
|
+
expect(root.querySelector('[onerror]')).toBeNull()
|
|
15
|
+
expect(root.querySelector('a[href^="javascript:"]')).toBeNull()
|
|
16
|
+
expect(root.querySelector('strong')?.textContent).toBe('bold')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('WysiwygEditor with stored HTML', () => {
|
|
20
|
+
it('renders formatting but no script in the editable area', () => {
|
|
21
|
+
const { container } = render(<WysiwygEditor value={STORED} onChange={jest.fn()} />)
|
|
22
|
+
expectInert(container.querySelector('[contenteditable]')!)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('drops script that reaches the editable area while editing', () => {
|
|
26
|
+
const onChange = jest.fn()
|
|
27
|
+
const { container, rerender } = render(<WysiwygEditor value="" onChange={onChange} />)
|
|
28
|
+
const editable = container.querySelector('[contenteditable]')!
|
|
29
|
+
editable.innerHTML = STORED
|
|
30
|
+
fireEvent.input(editable)
|
|
31
|
+
const emitted = onChange.mock.calls[onChange.mock.calls.length - 1][0]
|
|
32
|
+
rerender(<WysiwygEditor value={emitted} onChange={onChange} />)
|
|
33
|
+
expectInert(editable)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('drops a handler the sanitiser only reports as a rewritten attribute', () => {
|
|
37
|
+
const onChange = jest.fn()
|
|
38
|
+
const { container, rerender } = render(<WysiwygEditor value="" onChange={onChange} />)
|
|
39
|
+
const editable = container.querySelector('[contenteditable]')!
|
|
40
|
+
editable.innerHTML = '<p><strong>bold</strong></p><img title=" x " onerror="globalThis.pwned = 1" src="bad">'
|
|
41
|
+
fireEvent.input(editable)
|
|
42
|
+
const emitted = onChange.mock.calls[onChange.mock.calls.length - 1][0]
|
|
43
|
+
rerender(<WysiwygEditor value={emitted} onChange={onChange} />)
|
|
44
|
+
expectInert(editable)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('renders formatting but no script in the preview', () => {
|
|
48
|
+
const { container } = render(<WysiwygEditor value={STORED} onChange={jest.fn()} />)
|
|
49
|
+
fireEvent.click(container.querySelector('[data-cy="wysiwyg-preview-toggle"]')!)
|
|
50
|
+
expectInert(container.querySelector('[data-cy="wysiwyg-preview"]')!)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('WysiwygEditor with markup pasted or dropped in', () => {
|
|
55
|
+
const UNSAFE = '<p><strong>bold</strong></p><img title=" x " onerror="globalThis.pwned = 1" src="bad">'
|
|
56
|
+
const transfer = (html: string) => ({ getData: (type: string) => (type === 'text/html' ? html : '') })
|
|
57
|
+
let execCommand: jest.Mock
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
execCommand = jest.fn()
|
|
61
|
+
Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
delete (document as unknown as { execCommand?: unknown }).execCommand
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
function expectSanitisedInsertion() {
|
|
69
|
+
expect(execCommand).toHaveBeenCalledTimes(1)
|
|
70
|
+
const [command, , inserted] = execCommand.mock.calls[0]
|
|
71
|
+
expect(command).toBe('insertHTML')
|
|
72
|
+
expect(inserted).toContain('<strong>bold</strong>')
|
|
73
|
+
expect(inserted).not.toContain('onerror')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
it('inserts a paste with a handler sanitised, instead of the browser paste', () => {
|
|
77
|
+
const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
|
|
78
|
+
const proceeded = fireEvent.paste(container.querySelector('[contenteditable]')!, { clipboardData: transfer(UNSAFE) })
|
|
79
|
+
expect(proceeded).toBe(false)
|
|
80
|
+
expectSanitisedInsertion()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('inserts a drop with a handler sanitised, instead of the browser drop', () => {
|
|
84
|
+
const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
|
|
85
|
+
const proceeded = fireEvent.drop(container.querySelector('[contenteditable]')!, { dataTransfer: transfer(UNSAFE) })
|
|
86
|
+
expect(proceeded).toBe(false)
|
|
87
|
+
expectSanitisedInsertion()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('inserts a paste whose script a document parse would move out of the body, sanitised', () => {
|
|
91
|
+
const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
|
|
92
|
+
const proceeded = fireEvent.paste(container.querySelector('[contenteditable]')!, {
|
|
93
|
+
clipboardData: transfer('<script>globalThis.pwned = 1</script><p><strong>bold</strong></p>'),
|
|
94
|
+
})
|
|
95
|
+
expect(proceeded).toBe(false)
|
|
96
|
+
expectSanitisedInsertion()
|
|
97
|
+
expect(execCommand.mock.calls[0][2]).not.toContain('<script')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('leaves a paste with nothing to take out to the browser', () => {
|
|
101
|
+
const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
|
|
102
|
+
const proceeded = fireEvent.paste(container.querySelector('[contenteditable]')!, { clipboardData: transfer('<p>plain <em>text</em></p>') })
|
|
103
|
+
expect(proceeded).toBe(true)
|
|
104
|
+
expect(execCommand).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('WysiwygEditor preview toggle', () => {
|
|
109
|
+
it('keeps the content in the editable area after leaving the preview', () => {
|
|
110
|
+
const { container } = render(<WysiwygEditor value="<p>kept <strong>bold</strong></p>" onChange={jest.fn()} />)
|
|
111
|
+
const toggle = container.querySelector('[data-cy="wysiwyg-preview-toggle"]')!
|
|
112
|
+
fireEvent.click(toggle)
|
|
113
|
+
expect(container.querySelector('[contenteditable]')).toBeNull()
|
|
114
|
+
fireEvent.click(toggle)
|
|
115
|
+
expect(container.querySelector('[contenteditable]')!.innerHTML).toBe('<p>kept <strong>bold</strong></p>')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -48,6 +48,8 @@ const moduleNameMapper = isNpmMode
|
|
|
48
48
|
// Monorepo mode: resolve from packages/core/src (rootDir is apps/dev)
|
|
49
49
|
'^@nextsparkjs/core/(.*)$': '<rootDir>/../../packages/core/src/$1',
|
|
50
50
|
'^@nextsparkjs/core$': '<rootDir>/../../packages/core/src',
|
|
51
|
+
// core's components import the UI package, resolved from source as core's own jest config does
|
|
52
|
+
'^@nextsparkjs/ui$': '<rootDir>/../../packages/ui/src/index.ts',
|
|
51
53
|
'^@/contents/(.*)$': '<rootDir>/contents/$1',
|
|
52
54
|
'^@/entities/(.*)$': '<rootDir>/contents/entities/$1',
|
|
53
55
|
'^@/plugins/(.*)$': '<rootDir>/contents/plugins/$1',
|
|
@@ -97,10 +99,25 @@ module.exports = {
|
|
|
97
99
|
// Setup files
|
|
98
100
|
setupFilesAfterEnv,
|
|
99
101
|
|
|
100
|
-
//
|
|
102
|
+
// In npm mode, use project's tsconfig with jsx override
|
|
103
|
+
// In monorepo, use the local tsconfig.jest.json
|
|
101
104
|
transform: {
|
|
102
105
|
'^.+\\.(ts|tsx)$': ['ts-jest', {
|
|
103
|
-
tsconfig:
|
|
106
|
+
tsconfig: isNpmMode
|
|
107
|
+
? {
|
|
108
|
+
jsx: 'react-jsx',
|
|
109
|
+
esModuleInterop: true,
|
|
110
|
+
allowSyntheticDefaultImports: true,
|
|
111
|
+
module: 'ESNext',
|
|
112
|
+
moduleResolution: 'bundler',
|
|
113
|
+
strict: true,
|
|
114
|
+
baseUrl: projectRoot,
|
|
115
|
+
paths: {
|
|
116
|
+
'@/*': ['./*'],
|
|
117
|
+
'@/contents/*': ['./contents/*'],
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
: path.join(themeTestsRoot, 'tsconfig.jest.json'),
|
|
104
121
|
}],
|
|
105
122
|
},
|
|
106
123
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { postHtmlHasUnsafeMarkup, sanitizePostHtml } from '@/themes/blog/lib/sanitize-post-html'
|
|
2
|
+
|
|
3
|
+
describe('sanitizePostHtml', () => {
|
|
4
|
+
it('keeps formatting and drops what scripts', () => {
|
|
5
|
+
const out = sanitizePostHtml('<p><strong>bold</strong></p><img src="x" onerror="globalThis.pwned = 1"><script>globalThis.pwned = 1</script>')
|
|
6
|
+
expect(out).toContain('<strong>bold</strong>')
|
|
7
|
+
expect(out).not.toMatch(/onerror|<script/)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns an empty string for no input', () => {
|
|
11
|
+
expect(sanitizePostHtml('')).toBe('')
|
|
12
|
+
expect(sanitizePostHtml(null)).toBe('')
|
|
13
|
+
expect(sanitizePostHtml(undefined)).toBe('')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('postHtmlHasUnsafeMarkup', () => {
|
|
18
|
+
it('is true when sanitising removes or alters anything', () => {
|
|
19
|
+
for (const unsafe of [
|
|
20
|
+
'<img src="x" onerror="globalThis.pwned = 1">',
|
|
21
|
+
'<p>x</p><script>globalThis.pwned = 1</script>',
|
|
22
|
+
'<a href="javascript:globalThis.pwned = 1">x</a>',
|
|
23
|
+
'<img title=" x " onerror="globalThis.pwned = 1" src="bad">',
|
|
24
|
+
'<p onclick="globalThis.pwned = 1">x</p>',
|
|
25
|
+
'<a title=" y " href=" javascript:globalThis.pwned = 1">x</a>',
|
|
26
|
+
'<script>globalThis.pwned = 1</script>',
|
|
27
|
+
'<head><base href="https://attacker.test/"></head><p>safe</p>',
|
|
28
|
+
'<template><img src="x" onerror="globalThis.pwned = 1"></template>',
|
|
29
|
+
'<template><template><img src="x" onerror="globalThis.pwned = 1"></template></template>',
|
|
30
|
+
]) {
|
|
31
|
+
expect(postHtmlHasUnsafeMarkup(unsafe)).toBe(true)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('is false for markup the parser only re-nests or re-serialises', () => {
|
|
36
|
+
for (const safe of [
|
|
37
|
+
'<p><ul><li>item</li></ul></p>',
|
|
38
|
+
'<p>plain <em>text</em></p>',
|
|
39
|
+
' <p>leading whitespace</p>',
|
|
40
|
+
'<p>a b<br></p>',
|
|
41
|
+
'<ol><li>one</li><li><b>two</b></li></ol>',
|
|
42
|
+
'<p style="text-align: center">centred</p>',
|
|
43
|
+
]) {
|
|
44
|
+
expect(postHtmlHasUnsafeMarkup(safe)).toBe(false)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
})
|