decap-cms-widget-markdown 3.8.0 → 3.10.0

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.
@@ -1,11 +1,34 @@
1
1
  // source: https://github.com/ianstormtaylor/slate/blob/main/site/examples/ts/paste-html.tsx
2
+ import DOMPurify from 'dompurify';
2
3
  import { jsx } from 'slate-hyperscript';
3
4
  import { Transforms } from 'slate';
5
+ function sanitizeElementUrl(url, {
6
+ allowDataImage = false
7
+ } = {}) {
8
+ if (!url) {
9
+ return null;
10
+ }
11
+ const trimmed = url.trim();
12
+ if (!trimmed) {
13
+ return null;
14
+ }
15
+ const normalized = trimmed.replace(/\s+/g, '').toLowerCase();
16
+ if (normalized.startsWith('javascript:') || normalized.startsWith('vbscript:') || normalized.startsWith('file:')) {
17
+ return null;
18
+ }
19
+ if (normalized.startsWith('data:')) {
20
+ return allowDataImage && /^data:image\/(?!svg\+xml)[a-z0-9.+-]+[;,]/i.test(normalized) ? trimmed : null;
21
+ }
22
+ return trimmed;
23
+ }
4
24
  const ELEMENT_TAGS = {
5
- A: el => ({
6
- type: 'link',
7
- url: el.getAttribute('href')
8
- }),
25
+ A: el => {
26
+ const url = sanitizeElementUrl(el.getAttribute('href'));
27
+ return url ? {
28
+ type: 'link',
29
+ url
30
+ } : undefined;
31
+ },
9
32
  BLOCKQUOTE: () => ({
10
33
  type: 'quote'
11
34
  }),
@@ -27,10 +50,15 @@ const ELEMENT_TAGS = {
27
50
  H6: () => ({
28
51
  type: 'heading-six'
29
52
  }),
30
- IMG: el => ({
31
- type: 'image',
32
- url: el.getAttribute('src')
33
- }),
53
+ IMG: el => {
54
+ const url = sanitizeElementUrl(el.getAttribute('src'), {
55
+ allowDataImage: true
56
+ });
57
+ return url ? {
58
+ type: 'image',
59
+ url
60
+ } : null;
61
+ },
34
62
  LI: () => ({
35
63
  type: 'list-item'
36
64
  }),
@@ -106,6 +134,12 @@ function deserialize(el) {
106
134
  }
107
135
  if (ELEMENT_TAGS[nodeName]) {
108
136
  const attrs = ELEMENT_TAGS[nodeName](el);
137
+ if (attrs === undefined) {
138
+ return children;
139
+ }
140
+ if (attrs === null) {
141
+ return null;
142
+ }
109
143
  return jsx('element', attrs, children);
110
144
  }
111
145
  if (TEXT_TAGS[nodeName]) {
@@ -143,7 +177,8 @@ function withHtml(editor) {
143
177
  editor.insertData = data => {
144
178
  const html = data.getData('text/html');
145
179
  if (html) {
146
- const parsed = new DOMParser().parseFromString(html, 'text/html');
180
+ const sanitizedHtml = DOMPurify.sanitize(html);
181
+ const parsed = new DOMParser().parseFromString(sanitizedHtml, 'text/html');
147
182
  const fragment = deserialize(parsed.body);
148
183
  Transforms.insertFragment(editor, fragment);
149
184
  return;
@@ -29,7 +29,8 @@ class MarkdownPreview extends React.Component {
29
29
  getAsset,
30
30
  resolveWidget
31
31
  }, getRemarkPlugins?.());
32
- const toRender = field?.get('sanitize_preview', false) ? DOMPurify.sanitize(html) : html;
32
+ const shouldSanitizePreview = field?.get('sanitize_preview') ?? true;
33
+ const toRender = shouldSanitizePreview ? DOMPurify.sanitize(html) : html;
33
34
  return ___EmotionJSX(WidgetPreviewContainer, {
34
35
  dangerouslySetInnerHTML: {
35
36
  __html: toRender
@@ -9,60 +9,37 @@ export function remarkParseShortcodes({
9
9
  });
10
10
  methods.unshift('shortcode');
11
11
  }
12
- export function getLinesWithOffsets(value) {
13
- const SEPARATOR = '\n\n';
14
- const splitted = value.split(SEPARATOR);
15
- const trimmedLines = splitted.reduce((acc, line) => {
16
- const {
17
- start: previousLineStart,
18
- originalLength: previousLineOriginalLength
19
- } = acc[acc.length - 1];
20
- return [...acc, {
21
- line: line.trimEnd(),
22
- start: previousLineStart + previousLineOriginalLength + SEPARATOR.length,
23
- originalLength: line.length
24
- }];
25
- }, [{
26
- start: -SEPARATOR.length,
27
- originalLength: 0
28
- }]).slice(1).map(({
29
- line,
30
- start
31
- }) => ({
32
- line,
33
- start
34
- }));
35
- return trimmedLines;
36
- }
37
12
  function createShortcodeTokenizer({
38
13
  plugins
39
14
  }) {
15
+ plugins.forEach(plugin => {
16
+ if (plugin.pattern.flags.includes('m')) {
17
+ console.warn(`Invalid RegExp: editor component '${plugin.id}' must not use the multiline flag in its pattern.`);
18
+ }
19
+ });
40
20
  return function tokenizeShortcode(eat, value, silent) {
41
- // Attempt to find a regex match for each plugin's pattern, and then
42
- // select the first by its occurrence in `value`. This ensures we won't
43
- // skip a plugin that occurs later in the plugin registry, but earlier
44
- // in the `value`.
45
- const [{
46
- plugin,
47
- match
48
- } = {}] = plugins.toArray().map(plugin => {
21
+ let match;
22
+ const potentialMatchValue = value.split('\n\n')[0].trimEnd();
23
+ const plugin = plugins.find(plugin => {
49
24
  let {
50
25
  pattern
51
26
  } = plugin;
52
- // Plugin patterns must start with a caret (^) to match the beginning of the line.
27
+ // Plugin patterns must start with a caret (^) to match the beginning of the block.
53
28
  // If the pattern does not start with a caret, we add it
54
29
  // to ensure that remark consumes only the shortcode, without any leading text.
55
30
  if (!pattern.source.startsWith('^')) {
56
31
  pattern = new RegExp(`^${pattern.source}`, pattern.flags);
57
32
  }
58
- return {
59
- match: value.match(pattern),
60
- plugin
61
- };
62
- }).filter(({
63
- match
64
- }) => !!match).sort((a, b) => a.match.index - b.match.index);
33
+ match = value.match(pattern);
34
+ if (!match) {
35
+ match = potentialMatchValue.match(pattern);
36
+ }
37
+ return !!match;
38
+ });
65
39
  if (match) {
40
+ if (match.index > 0) {
41
+ console.warn(`Invalid RegExp: editor component '${plugin.id}' must match from the beginning of the block.`);
42
+ }
66
43
  if (silent) {
67
44
  return true;
68
45
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "decap-cms-widget-markdown",
3
3
  "description": "Widget for editing markdown in Decap CMS.",
4
- "version": "3.8.0",
4
+ "version": "3.10.0",
5
5
  "homepage": "https://www.decapcms.org/docs/widgets/#markdown",
6
6
  "repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown",
7
7
  "bugs": "https://github.com/decaporg/decap-cms/issues",
@@ -21,10 +21,8 @@
21
21
  "build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward"
22
22
  },
23
23
  "dependencies": {
24
- "detab": "^2.0.4",
25
24
  "dompurify": "^3.4.0",
26
25
  "is-hotkey": "^0.2.0",
27
- "is-url": "^1.2.4",
28
26
  "mdast-util-definitions": "^1.2.3",
29
27
  "mdast-util-to-string": "^1.0.5",
30
28
  "rehype-parse": "^6.0.0",
@@ -33,21 +31,15 @@
33
31
  "rehype-stringify": "^7.0.0",
34
32
  "remark-parse": "^6.0.3",
35
33
  "remark-rehype": "^4.0.0",
36
- "remark-slate": "^1.8.6",
37
- "remark-slate-transformer": "^0.7.4",
38
34
  "remark-stringify": "^6.0.4",
39
35
  "slate": "^0.118.1",
40
- "slate-base64-serializer": "^0.2.107",
41
36
  "slate-dom": "^0.118.1",
42
37
  "slate-history": "^0.113.1",
43
38
  "slate-hyperscript": "^0.100.0",
44
- "slate-plain-serializer": "^0.7.3",
45
39
  "slate-react": "^0.117.4",
46
- "slate-soft-break": "^0.9.0",
47
40
  "unified": "^9.2.0",
48
41
  "unist-builder": "^1.0.3",
49
- "unist-util-visit-parents": "^2.0.1",
50
- "vfile-location": "^2.0.6"
42
+ "unist-util-visit-parents": "^2.0.1"
51
43
  },
52
44
  "peerDependencies": {
53
45
  "@emotion/react": "^11.11.1",
@@ -64,5 +56,5 @@
64
56
  "commonmark": "^0.30.0",
65
57
  "commonmark-spec": "^0.30.0"
66
58
  },
67
- "gitHead": "45c9f5b9a1a12f74321ce4658b71ec88d6365ec1"
59
+ "gitHead": "567a80101f4846853701ad7d8abdc29b5e4fab56"
68
60
  }
@@ -0,0 +1,67 @@
1
+ import { Transforms } from 'slate';
2
+
3
+ import withHtml from '../withHtml';
4
+
5
+ describe('withHtml', () => {
6
+ afterEach(() => {
7
+ jest.restoreAllMocks();
8
+ });
9
+
10
+ function createEditor() {
11
+ return {
12
+ insertData: jest.fn(),
13
+ isInline: jest.fn(() => false),
14
+ isVoid: jest.fn(() => false),
15
+ };
16
+ }
17
+
18
+ function createDataTransfer(html) {
19
+ return {
20
+ getData: jest.fn(type => (type === 'text/html' ? html : '')),
21
+ };
22
+ }
23
+
24
+ it('should unwrap links with dangerous protocols', () => {
25
+ const editor = withHtml(createEditor());
26
+ const insertFragmentSpy = jest.spyOn(Transforms, 'insertFragment').mockImplementation(() => {});
27
+
28
+ editor.insertData(createDataTransfer('<p><a href="javascript:alert(1)">click me</a></p>'));
29
+
30
+ expect(insertFragmentSpy).toHaveBeenCalledWith(editor, [
31
+ {
32
+ type: 'paragraph',
33
+ children: [{ text: 'click me' }],
34
+ },
35
+ ]);
36
+ });
37
+
38
+ it('should drop images with dangerous protocols', () => {
39
+ const editor = withHtml(createEditor());
40
+ const insertFragmentSpy = jest.spyOn(Transforms, 'insertFragment').mockImplementation(() => {});
41
+
42
+ editor.insertData(createDataTransfer('<p>before<img src="javascript:alert(1)">after</p>'));
43
+
44
+ expect(insertFragmentSpy).toHaveBeenCalledWith(editor, [
45
+ {
46
+ type: 'paragraph',
47
+ children: [{ text: 'beforeafter' }],
48
+ },
49
+ ]);
50
+ });
51
+
52
+ it('should keep safe image URLs', () => {
53
+ const editor = withHtml(createEditor());
54
+ const insertFragmentSpy = jest.spyOn(Transforms, 'insertFragment').mockImplementation(() => {});
55
+
56
+ editor.insertData(createDataTransfer('<p><img src="https://example.com/image.png"></p>'));
57
+
58
+ expect(insertFragmentSpy).toHaveBeenCalledWith(editor, [
59
+ {
60
+ type: 'paragraph',
61
+ children: [
62
+ { type: 'image', url: 'https://example.com/image.png', children: [{ text: '' }] },
63
+ ],
64
+ },
65
+ ]);
66
+ });
67
+ });
@@ -1,9 +1,43 @@
1
1
  // source: https://github.com/ianstormtaylor/slate/blob/main/site/examples/ts/paste-html.tsx
2
+ import DOMPurify from 'dompurify';
2
3
  import { jsx } from 'slate-hyperscript';
3
4
  import { Transforms } from 'slate';
4
5
 
6
+ function sanitizeElementUrl(url, { allowDataImage = false } = {}) {
7
+ if (!url) {
8
+ return null;
9
+ }
10
+
11
+ const trimmed = url.trim();
12
+
13
+ if (!trimmed) {
14
+ return null;
15
+ }
16
+
17
+ const normalized = trimmed.replace(/\s+/g, '').toLowerCase();
18
+
19
+ if (
20
+ normalized.startsWith('javascript:') ||
21
+ normalized.startsWith('vbscript:') ||
22
+ normalized.startsWith('file:')
23
+ ) {
24
+ return null;
25
+ }
26
+
27
+ if (normalized.startsWith('data:')) {
28
+ return allowDataImage && /^data:image\/(?!svg\+xml)[a-z0-9.+-]+[;,]/i.test(normalized)
29
+ ? trimmed
30
+ : null;
31
+ }
32
+
33
+ return trimmed;
34
+ }
35
+
5
36
  const ELEMENT_TAGS = {
6
- A: el => ({ type: 'link', url: el.getAttribute('href') }),
37
+ A: el => {
38
+ const url = sanitizeElementUrl(el.getAttribute('href'));
39
+ return url ? { type: 'link', url } : undefined;
40
+ },
7
41
  BLOCKQUOTE: () => ({ type: 'quote' }),
8
42
  H1: () => ({ type: 'heading-one' }),
9
43
  H2: () => ({ type: 'heading-two' }),
@@ -11,7 +45,10 @@ const ELEMENT_TAGS = {
11
45
  H4: () => ({ type: 'heading-four' }),
12
46
  H5: () => ({ type: 'heading-five' }),
13
47
  H6: () => ({ type: 'heading-six' }),
14
- IMG: el => ({ type: 'image', url: el.getAttribute('src') }),
48
+ IMG: el => {
49
+ const url = sanitizeElementUrl(el.getAttribute('src'), { allowDataImage: true });
50
+ return url ? { type: 'image', url } : null;
51
+ },
15
52
  LI: () => ({ type: 'list-item' }),
16
53
  OL: () => ({ type: 'numbered-list' }),
17
54
  P: () => ({ type: 'paragraph' }),
@@ -62,6 +99,15 @@ function deserialize(el) {
62
99
 
63
100
  if (ELEMENT_TAGS[nodeName]) {
64
101
  const attrs = ELEMENT_TAGS[nodeName](el);
102
+
103
+ if (attrs === undefined) {
104
+ return children;
105
+ }
106
+
107
+ if (attrs === null) {
108
+ return null;
109
+ }
110
+
65
111
  return jsx('element', attrs, children);
66
112
  }
67
113
 
@@ -102,7 +148,8 @@ function withHtml(editor) {
102
148
  const html = data.getData('text/html');
103
149
 
104
150
  if (html) {
105
- const parsed = new DOMParser().parseFromString(html, 'text/html');
151
+ const sanitizedHtml = DOMPurify.sanitize(html);
152
+ const parsed = new DOMParser().parseFromString(sanitizedHtml, 'text/html');
106
153
  const fragment = deserialize(parsed.body);
107
154
  Transforms.insertFragment(editor, fragment);
108
155
  return;
@@ -4,6 +4,7 @@ import { WidgetPreviewContainer } from 'decap-cms-ui-default';
4
4
  import DOMPurify from 'dompurify';
5
5
 
6
6
  import { markdownToHtml } from './serializers';
7
+
7
8
  class MarkdownPreview extends React.Component {
8
9
  static propTypes = {
9
10
  getAsset: PropTypes.func.isRequired,
@@ -23,7 +24,8 @@ class MarkdownPreview extends React.Component {
23
24
  }
24
25
 
25
26
  const html = markdownToHtml(value, { getAsset, resolveWidget }, getRemarkPlugins?.());
26
- const toRender = field?.get('sanitize_preview', false) ? DOMPurify.sanitize(html) : html;
27
+ const shouldSanitizePreview = field?.get('sanitize_preview') ?? true;
28
+ const toRender = shouldSanitizePreview ? DOMPurify.sanitize(html) : html;
27
29
 
28
30
  return <WidgetPreviewContainer dangerouslySetInnerHTML={{ __html: toRender }} />;
29
31
  }
@@ -208,6 +208,16 @@ I get 10 times more traffic from [Google] than from [Yahoo] or [MSN].
208
208
  expect(img).not.toHaveAttribute('onerror');
209
209
  });
210
210
 
211
+ it('should sanitize dangerous link protocols', () => {
212
+ const value = '<a href="javascript:alert(1)">click</a>';
213
+
214
+ const { container } = render(
215
+ <MarkdownPreview value={value} getAsset={jest.fn()} resolveWidget={jest.fn()} />,
216
+ );
217
+ const link = container.querySelector('a');
218
+ expect(link).not.toHaveAttribute('href');
219
+ });
220
+
211
221
  it('should not sanitize HTML', async () => {
212
222
  const value = `<img src="foobar.png" onerror="alert('hello')">`;
213
223
  const field = Map({ sanitize_preview: false });
@@ -2,7 +2,7 @@ import { Map, OrderedMap } from 'immutable';
2
2
  import unified from 'unified';
3
3
  import markdownToRemarkPlugin from 'remark-parse';
4
4
 
5
- import { remarkParseShortcodes, getLinesWithOffsets } from '../remarkShortcodes';
5
+ import { remarkParseShortcodes } from '../remarkShortcodes';
6
6
 
7
7
  function process(value, plugins) {
8
8
  return unified()
@@ -33,30 +33,29 @@ describe('remarkParseShortcodes', () => {
33
33
  expect.arrayContaining(['foo\n\nbar']),
34
34
  );
35
35
  });
36
- it('should match shortcodes based on order of occurrence in value', () => {
37
- const fooEditorComponent = EditorComponent({ id: 'foo', pattern: /foo/ });
38
- const barEditorComponent = EditorComponent({ id: 'bar', pattern: /bar/ });
36
+ it('should match shortcodes by first matching plugin', () => {
37
+ const fooEditorComponent = EditorComponent({ id: 'foo', pattern: /^foo/ });
38
+ const barEditorComponent = EditorComponent({ id: 'bar', pattern: /^bar/ });
39
39
  process(
40
- 'foo\n\nbar',
40
+ 'bar\n\nfoo',
41
41
  OrderedMap([
42
- [barEditorComponent.id, barEditorComponent],
43
42
  [fooEditorComponent.id, fooEditorComponent],
44
- ]),
45
- );
46
- expect(fooEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['foo']));
47
- });
48
- it('should match shortcodes based on order of occurrence in value even when some use line anchors', () => {
49
- const barEditorComponent = EditorComponent({ id: 'bar', pattern: /bar/ });
50
- const bazEditorComponent = EditorComponent({ id: 'baz', pattern: /^baz$/ });
51
- process(
52
- 'foo\n\nbar\n\nbaz',
53
- OrderedMap([
54
- [bazEditorComponent.id, bazEditorComponent],
55
43
  [barEditorComponent.id, barEditorComponent],
56
44
  ]),
57
45
  );
46
+ // 'bar' is the first block, but 'foo' plugin is first in registry,
47
+ // so 'foo' doesn't match 'bar'. 'bar' plugin matches 'bar'.
58
48
  expect(barEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar']));
59
49
  });
50
+ it('should warn when pattern uses multiline flag', () => {
51
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
52
+ const editorComponent = EditorComponent({ pattern: /^foo$/m });
53
+ process('foo', Map({ [editorComponent.id]: editorComponent }));
54
+ expect(warnSpy).toHaveBeenCalledWith(
55
+ expect.stringContaining('must not use the multiline flag'),
56
+ );
57
+ warnSpy.mockRestore();
58
+ });
60
59
  });
61
60
  describe('parse', () => {
62
61
  describe('pattern with leading caret', () => {
@@ -122,24 +121,3 @@ describe('remarkParseShortcodes', () => {
122
121
  return obj;
123
122
  }
124
123
  });
125
-
126
- describe('getLinesWithOffsets', () => {
127
- test('should split into lines', () => {
128
- const value = ' line1\n\nline2 \n\n line3 \n\n';
129
-
130
- const lines = getLinesWithOffsets(value);
131
- expect(lines).toEqual([
132
- { line: ' line1', start: 0 },
133
- { line: 'line2', start: 8 },
134
- { line: ' line3', start: 16 },
135
- { line: '', start: 30 },
136
- ]);
137
- });
138
-
139
- test('should return single item on no match', () => {
140
- const value = ' line1 ';
141
-
142
- const lines = getLinesWithOffsets(value);
143
- expect(lines).toEqual([{ line: ' line1', start: 0 }]);
144
- });
145
- });
@@ -8,57 +8,40 @@ export function remarkParseShortcodes({ plugins }) {
8
8
  methods.unshift('shortcode');
9
9
  }
10
10
 
11
- export function getLinesWithOffsets(value) {
12
- const SEPARATOR = '\n\n';
13
- const splitted = value.split(SEPARATOR);
14
- const trimmedLines = splitted
15
- .reduce(
16
- (acc, line) => {
17
- const { start: previousLineStart, originalLength: previousLineOriginalLength } =
18
- acc[acc.length - 1];
19
-
20
- return [
21
- ...acc,
22
- {
23
- line: line.trimEnd(),
24
- start: previousLineStart + previousLineOriginalLength + SEPARATOR.length,
25
- originalLength: line.length,
26
- },
27
- ];
28
- },
29
- [{ start: -SEPARATOR.length, originalLength: 0 }],
30
- )
31
- .slice(1)
32
- .map(({ line, start }) => ({ line, start }));
33
- return trimmedLines;
34
- }
35
-
36
11
  function createShortcodeTokenizer({ plugins }) {
12
+ plugins.forEach(plugin => {
13
+ if (plugin.pattern.flags.includes('m')) {
14
+ console.warn(
15
+ `Invalid RegExp: editor component '${plugin.id}' must not use the multiline flag in its pattern.`,
16
+ );
17
+ }
18
+ });
37
19
  return function tokenizeShortcode(eat, value, silent) {
38
- // Attempt to find a regex match for each plugin's pattern, and then
39
- // select the first by its occurrence in `value`. This ensures we won't
40
- // skip a plugin that occurs later in the plugin registry, but earlier
41
- // in the `value`.
42
- const [{ plugin, match } = {}] = plugins
43
- .toArray()
44
- .map(plugin => {
45
- let { pattern } = plugin;
46
- // Plugin patterns must start with a caret (^) to match the beginning of the line.
47
- // If the pattern does not start with a caret, we add it
48
- // to ensure that remark consumes only the shortcode, without any leading text.
49
- if (!pattern.source.startsWith('^')) {
50
- pattern = new RegExp(`^${pattern.source}`, pattern.flags);
51
- }
20
+ let match;
21
+ const potentialMatchValue = value.split('\n\n')[0].trimEnd();
22
+ const plugin = plugins.find(plugin => {
23
+ let { pattern } = plugin;
24
+ // Plugin patterns must start with a caret (^) to match the beginning of the block.
25
+ // If the pattern does not start with a caret, we add it
26
+ // to ensure that remark consumes only the shortcode, without any leading text.
27
+ if (!pattern.source.startsWith('^')) {
28
+ pattern = new RegExp(`^${pattern.source}`, pattern.flags);
29
+ }
52
30
 
53
- return {
54
- match: value.match(pattern),
55
- plugin,
56
- };
57
- })
58
- .filter(({ match }) => !!match)
59
- .sort((a, b) => a.match.index - b.match.index);
31
+ match = value.match(pattern);
32
+ if (!match) {
33
+ match = potentialMatchValue.match(pattern);
34
+ }
35
+
36
+ return !!match;
37
+ });
60
38
 
61
39
  if (match) {
40
+ if (match.index > 0) {
41
+ console.warn(
42
+ `Invalid RegExp: editor component '${plugin.id}' must match from the beginning of the block.`,
43
+ );
44
+ }
62
45
  if (silent) {
63
46
  return true;
64
47
  }