decap-cms-widget-markdown 3.12.0 → 3.12.1

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.
@@ -207,6 +207,74 @@ I get 10 times more traffic from [Google] than from [Yahoo] or [MSN].
207
207
  expect(img).not.toHaveAttribute('onerror');
208
208
  });
209
209
 
210
+ it('should preserve same-origin blob: URLs used for not-yet-committed asset previews', () => {
211
+ // Editors preview a selected-but-not-yet-committed image via URL.createObjectURL(),
212
+ // which produces a blob: URL - DOMPurify's default ALLOWED_URI_REGEXP doesn't include
213
+ // that scheme, so it silently stripped `src` here once sanitize_preview defaulted to
214
+ // true, even though the image itself is entirely local and safe. A real
215
+ // URL.createObjectURL() call always embeds the current document's own origin, so the
216
+ // fixture must too - a cross-origin value isn't something this exception should (or, per
217
+ // the test below, does) preserve.
218
+ const blobUrl = `blob:${window.location.origin}/1234-5678-90ab`;
219
+ const value = `<img src="${blobUrl}">`;
220
+ const field = Map({ sanitize_preview: true });
221
+
222
+ const { container } = render(
223
+ <MarkdownPreview
224
+ value={value}
225
+ getAsset={jest.fn()}
226
+ resolveWidget={jest.fn()}
227
+ field={field}
228
+ />,
229
+ );
230
+ const img = container.querySelector('img');
231
+ expect(img).toHaveAttribute('src', blobUrl);
232
+ });
233
+
234
+ it('should NOT preserve blob: URLs on non-<img> attributes', () => {
235
+ // The blob: exception above must be scoped to <img src>. Widening DOMPurify's global
236
+ // ALLOWED_URI_REGEXP instead of using a node-scoped hook would let blob: through on any
237
+ // URI-bearing attribute - including <a href> and <form action>, both of which could be
238
+ // used to smuggle attacker-controlled blob content (see PR review discussion). Uses a
239
+ // same-origin blob: URL so this test isolates the tag/attribute scoping specifically,
240
+ // independent of the separate cross-origin check below.
241
+ const blobUrl = `blob:${window.location.origin}/should-be-stripped`;
242
+ const value = [`<a href="${blobUrl}">click</a>`, `<form action="${blobUrl}"></form>`].join(
243
+ '',
244
+ );
245
+ const field = Map({ sanitize_preview: true });
246
+
247
+ const { container } = render(
248
+ <MarkdownPreview
249
+ value={value}
250
+ getAsset={jest.fn()}
251
+ resolveWidget={jest.fn()}
252
+ field={field}
253
+ />,
254
+ );
255
+ expect(container.querySelector('a')).not.toHaveAttribute('href');
256
+ expect(container.querySelector('form')).not.toHaveAttribute('action');
257
+ });
258
+
259
+ it('should NOT preserve a cross-origin blob: URL even on <img src>', () => {
260
+ // A blob: URL embeds the origin that created it and a browser already refuses to
261
+ // dereference one from a different origin, but the sanitizer should reject it outright
262
+ // rather than pass through a value that could not be one of this CMS instance's own
263
+ // asset previews.
264
+ const value = '<img src="blob:https://attacker.example/1234-5678-90ab">';
265
+ const field = Map({ sanitize_preview: true });
266
+
267
+ const { container } = render(
268
+ <MarkdownPreview
269
+ value={value}
270
+ getAsset={jest.fn()}
271
+ resolveWidget={jest.fn()}
272
+ field={field}
273
+ />,
274
+ );
275
+ expect(container.querySelector('img')).not.toHaveAttribute('src');
276
+ });
277
+
210
278
  it('should sanitize dangerous link protocols', () => {
211
279
  const value = '<a href="javascript:alert(1)">click</a>';
212
280
 
@@ -1,8 +1,8 @@
1
- import React from 'react';
2
1
  import map from 'lodash/map';
3
2
  import has from 'lodash/has';
4
3
  import { renderToString } from 'react-dom/server';
5
4
  import u from 'unist-builder';
5
+ import { createElement } from 'react';
6
6
 
7
7
  /**
8
8
  * This plugin doesn't actually transform Remark (MDAST) nodes to Rehype
@@ -58,7 +58,6 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWid
58
58
  if (toPreview) {
59
59
  return toPreview(shortcodeData, getAsset, fields);
60
60
  }
61
-
62
61
  /**
63
62
  * For editor components without a custom `toPreview` (e.g. container
64
63
  * components with nested markdown/richtext fields), render each sub-field
@@ -88,7 +87,7 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWid
88
87
  * Last resort fallback: try resolving the widget and rendering its preview.
89
88
  */
90
89
  const preview = resolveWidget(plugin.widget);
91
- return React.createElement(preview.preview, {
90
+ return createElement(preview.preview, {
92
91
  value: shortcodeData,
93
92
  field: plugin,
94
93
  getAsset,
package/LICENSE DELETED
@@ -1,22 +0,0 @@
1
- Copyright (c) 2016 Netlify <decap@p-m.si>
2
-
3
- MIT License
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining
6
- a copy of this software and associated documentation files (the
7
- "Software"), to deal in the Software without restriction, including
8
- without limitation the rights to use, copy, modify, merge, publish,
9
- distribute, sublicense, and/or sell copies of the Software, and to
10
- permit persons to whom the Software is furnished to do so, subject to
11
- the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be
14
- included in all copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
- LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
- OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
- WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -1,26 +0,0 @@
1
- import isHotkey from 'is-hotkey';
2
- function BreakToDefaultBlock({
3
- defaultType
4
- }) {
5
- return {
6
- onKeyDown(event, editor, next) {
7
- const {
8
- selection,
9
- startBlock
10
- } = editor.value;
11
- const isEnter = isHotkey('enter', event);
12
- if (!isEnter) {
13
- return next();
14
- }
15
- if (selection.isExpanded) {
16
- editor.delete();
17
- return next();
18
- }
19
- if (selection.start.isAtEndOfNode(startBlock) && startBlock.type !== defaultType) {
20
- return editor.insertBlock(defaultType);
21
- }
22
- return next();
23
- }
24
- };
25
- }
26
- export default BreakToDefaultBlock;
@@ -1,28 +0,0 @@
1
- import isHotkey from 'is-hotkey';
2
- function CloseBlock({
3
- defaultType
4
- }) {
5
- return {
6
- onKeyDown(event, editor, next) {
7
- const {
8
- selection,
9
- startBlock
10
- } = editor.value;
11
- const isBackspace = isHotkey('backspace', event);
12
- if (!isBackspace) {
13
- return next();
14
- }
15
- if (selection.isExpanded) {
16
- return editor.delete();
17
- }
18
- if (!selection.start.isAtStartOfNode(startBlock) || startBlock.text.length > 0) {
19
- return next();
20
- }
21
- if (startBlock.type !== defaultType) {
22
- editor.setBlocks(defaultType);
23
- }
24
- return next();
25
- }
26
- };
27
- }
28
- export default CloseBlock;
@@ -1,180 +0,0 @@
1
- import isArray from 'lodash/isArray';
2
- import tail from 'lodash/tail';
3
- import castArray from 'lodash/castArray';
4
- function CommandsAndQueries({
5
- defaultType
6
- }) {
7
- return {
8
- queries: {
9
- atStartOf(editor, node) {
10
- const {
11
- selection
12
- } = editor.value;
13
- return selection.isCollapsed && selection.start.isAtStartOfNode(node);
14
- },
15
- getAncestor(editor, firstKey, lastKey) {
16
- if (firstKey === lastKey) {
17
- return editor.value.document.getParent(firstKey);
18
- }
19
- return editor.value.document.getCommonAncestor(firstKey, lastKey);
20
- },
21
- getOffset(editor, node) {
22
- const parent = editor.value.document.getParent(node.key);
23
- return parent.nodes.indexOf(node);
24
- },
25
- getSelectedChildren(editor, node) {
26
- return node.nodes.filter(child => editor.isSelected(child));
27
- },
28
- getCommonAncestor(editor) {
29
- const {
30
- startBlock,
31
- endBlock,
32
- document: doc
33
- } = editor.value;
34
- return doc.getCommonAncestor(startBlock.key, endBlock.key);
35
- },
36
- getClosestType(editor, node, type) {
37
- const types = castArray(type);
38
- return editor.value.document.getClosest(node.key, n => types.includes(n.type));
39
- },
40
- getBlockContainer(editor, node) {
41
- const targetTypes = ['bulleted-list', 'numbered-list', 'list-item', 'quote', 'table-cell'];
42
- const {
43
- startBlock,
44
- selection
45
- } = editor.value;
46
- const target = node ? editor.value.document.getParent(node.key) : selection.isCollapsed && startBlock || editor.getCommonAncestor();
47
- if (!target) {
48
- return editor.value.document;
49
- }
50
- if (targetTypes.includes(target.type)) {
51
- return target;
52
- }
53
- return editor.getBlockContainer(target);
54
- },
55
- isSelected(editor, nodes) {
56
- return castArray(nodes).every(node => {
57
- return editor.value.document.isInRange(node.key, editor.value.selection);
58
- });
59
- },
60
- isFirstChild(editor, node) {
61
- return editor.value.document.getParent(node.key).nodes.first().key === node.key;
62
- },
63
- areSiblings(editor, nodes) {
64
- if (!isArray(nodes) || nodes.length < 2) {
65
- return true;
66
- }
67
- const parent = editor.value.document.getParent(nodes[0].key);
68
- return tail(nodes).every(node => {
69
- return editor.value.document.getParent(node.key).key === parent.key;
70
- });
71
- },
72
- everyBlock(editor, type) {
73
- return editor.value.blocks.every(block => block.type === type);
74
- },
75
- hasMark(editor, type) {
76
- return editor.value.activeMarks.some(mark => mark.type === type);
77
- },
78
- hasBlock(editor, type) {
79
- return editor.value.blocks.some(node => node.type === type);
80
- },
81
- hasInline(editor, type) {
82
- return editor.value.inlines.some(node => node.type === type);
83
- },
84
- hasQuote(editor, quoteLabel) {
85
- const {
86
- value
87
- } = editor;
88
- const {
89
- document,
90
- blocks
91
- } = value;
92
- return blocks.some(node => {
93
- const {
94
- key: descendantNodeKey
95
- } = node;
96
- /* When focusing a quote block, the actual block that gets the focus is the paragraph block whose parent is a `quote` block.
97
- Hence, we need to get its parent and check if its type is `quote`. This parent will always be defined because every block in the editor
98
- has a Document object as parent by default.
99
- */
100
- const parent = document.getParent(descendantNodeKey);
101
- return parent.type === quoteLabel;
102
- });
103
- },
104
- hasListItems(editor, listType) {
105
- const {
106
- value
107
- } = editor;
108
- const {
109
- document,
110
- blocks
111
- } = value;
112
- return blocks.some(node => {
113
- const {
114
- key: lowestNodeKey
115
- } = node;
116
- /* A list block has the following structure:
117
- <ol>
118
- <li>
119
- <p>Coffee</p>
120
- </li>
121
- <li>
122
- <p>Tea</p>
123
- </li>
124
- </ol>
125
- */
126
- const parent = document.getParent(lowestNodeKey);
127
- const grandparent = document.getParent(parent.key);
128
- return parent.type === 'list-item' && grandparent?.type === listType;
129
- });
130
- }
131
- },
132
- commands: {
133
- toggleBlock(editor, type) {
134
- switch (type) {
135
- case 'heading-one':
136
- case 'heading-two':
137
- case 'heading-three':
138
- case 'heading-four':
139
- case 'heading-five':
140
- case 'heading-six':
141
- return editor.setBlocks(editor.everyBlock(type) ? defaultType : type);
142
- case 'quote':
143
- return editor.toggleQuoteBlock();
144
- case 'numbered-list':
145
- case 'bulleted-list':
146
- {
147
- return editor.toggleList(type);
148
- }
149
- }
150
- },
151
- unwrapBlockChildren(editor, block) {
152
- if (!block || block.object !== 'block') {
153
- throw Error(`Expected block but received ${block}.`);
154
- }
155
- const index = editor.value.document.getPath(block.key).last();
156
- const parent = editor.value.document.getParent(block.key);
157
- editor.withoutNormalizing(() => {
158
- block.nodes.forEach((node, idx) => {
159
- editor.moveNodeByKey(node.key, parent.key, index + idx);
160
- });
161
- editor.removeNodeByKey(block.key);
162
- });
163
- },
164
- unwrapNodeToDepth(editor, node, depth) {
165
- let currentDepth = 0;
166
- editor.withoutNormalizing(() => {
167
- while (currentDepth < depth) {
168
- editor.unwrapNodeByKey(node.key);
169
- currentDepth += 1;
170
- }
171
- });
172
- },
173
- unwrapNodeFromAncestor(editor, node, ancestor) {
174
- const depth = ancestor.getDepth(node.key);
175
- editor.unwrapNodeToDepth(node, depth);
176
- }
177
- }
178
- };
179
- }
180
- export default CommandsAndQueries;
@@ -1,42 +0,0 @@
1
- function ForceInsert({
2
- defaultType
3
- }) {
4
- return {
5
- queries: {
6
- canInsertBeforeNode(editor, node) {
7
- if (!editor.isVoid(node)) {
8
- return true;
9
- }
10
- return !!editor.value.document.getPreviousSibling(node.key);
11
- },
12
- canInsertAfterNode(editor, node) {
13
- if (!editor.isVoid(node)) {
14
- return true;
15
- }
16
- const nextSibling = editor.value.document.getNextSibling(node.key);
17
- return nextSibling && !editor.isVoid(nextSibling);
18
- }
19
- },
20
- commands: {
21
- forceInsertBeforeNode(editor, node) {
22
- const block = {
23
- type: defaultType,
24
- object: 'block'
25
- };
26
- const parent = editor.value.document.getParent(node.key);
27
- return editor.insertNodeByKey(parent.key, 0, block).moveToStartOfNode(parent).focus();
28
- },
29
- forceInsertAfterNode(editor, node) {
30
- return editor.moveToEndOfNode(node).insertBlock(defaultType).focus();
31
- },
32
- moveToEndOfDocument(editor) {
33
- const lastBlock = editor.value.document.nodes.last();
34
- if (editor.isVoid(lastBlock)) {
35
- editor.insertBlock(defaultType);
36
- }
37
- return editor.moveToEndOfNode(lastBlock).focus();
38
- }
39
- }
40
- };
41
- }
42
- export default ForceInsert;
@@ -1,26 +0,0 @@
1
- import isHotkey from 'is-hotkey';
2
- export const HOT_KEY_MAP = {
3
- bold: 'mod+b',
4
- code: 'mod+shift+c',
5
- italic: 'mod+i',
6
- strikethrough: 'mod+shift+s',
7
- 'heading-one': 'mod+1',
8
- 'heading-two': 'mod+2',
9
- 'heading-three': 'mod+3',
10
- 'heading-four': 'mod+4',
11
- 'heading-five': 'mod+5',
12
- 'heading-six': 'mod+6',
13
- link: 'mod+k'
14
- };
15
- function Hotkey(key, fn) {
16
- return {
17
- onKeyDown(event, editor, next) {
18
- if (!isHotkey(key, event)) {
19
- return next();
20
- }
21
- event.preventDefault();
22
- editor.command(fn);
23
- }
24
- };
25
- }
26
- export default Hotkey;
@@ -1,13 +0,0 @@
1
- import isHotkey from 'is-hotkey';
2
- function LineBreak() {
3
- return {
4
- onKeyDown(event, editor, next) {
5
- const isShiftEnter = isHotkey('shift+enter', event);
6
- if (!isShiftEnter) {
7
- return next();
8
- }
9
- return editor.insertInline('break').insertText('').moveToStartOfNextText();
10
- }
11
- };
12
- }
13
- export default LineBreak;
@@ -1,49 +0,0 @@
1
- function Link({
2
- type
3
- }) {
4
- return {
5
- commands: {
6
- toggleLink(editor, getUrl) {
7
- const selection = editor.value.selection;
8
- const isCollapsed = selection && selection.isCollapsed;
9
- if (editor.hasInline(type)) {
10
- const inlines = editor.value.inlines.toJSON();
11
- const link = inlines.find(item => item.type === type);
12
- const url = getUrl(link.data.url);
13
- if (url) {
14
- // replace the old link
15
- return editor.setInlines({
16
- data: {
17
- url
18
- }
19
- });
20
- } else {
21
- // remove url if it was removed by the user
22
- return editor.unwrapInline(type);
23
- }
24
- } else {
25
- const url = getUrl();
26
- if (!url) {
27
- return;
28
- }
29
- return isCollapsed ? editor.insertInline({
30
- type,
31
- data: {
32
- url
33
- },
34
- nodes: [{
35
- object: 'text',
36
- text: url
37
- }]
38
- }) : editor.wrapInline({
39
- type,
40
- data: {
41
- url
42
- }
43
- }).moveToEnd();
44
- }
45
- }
46
- }
47
- };
48
- }
49
- export default Link;
@@ -1,6 +0,0 @@
1
- import getListContainedInListItem from '../selectors/getListContainedInListItem';
2
- function isCursorInItemContainingNestedList(editor) {
3
- const nestedList = getListContainedInListItem(editor);
4
- return !!nestedList && `${nestedList[0].type}`.endsWith('-list');
5
- }
6
- export default isCursorInItemContainingNestedList;
@@ -1,11 +0,0 @@
1
- import castArray from 'lodash/castArray';
2
- import isArray from 'lodash/isArray';
3
- export function assertType(nodes, type) {
4
- const nodesArray = castArray(nodes);
5
- const validate = isArray(type) ? node => type.includes(node.type) : node => type === node.type;
6
- const invalidNode = nodesArray.find(node => !validate(node));
7
- if (invalidNode) {
8
- throw Error(`Expected node of type "${type}", received "${invalidNode.type}".`);
9
- }
10
- return true;
11
- }
@@ -1,31 +0,0 @@
1
- /**
2
- * Images must be parsed as shortcodes for asset proxying. This plugin converts
3
- * MDAST image nodes back to text to allow shortcode pattern matching. Note that
4
- * this transformation only occurs for images that are the sole child of a top
5
- * level paragraph - any other image is left alone and treated as an inline
6
- * image.
7
- */
8
- export default function remarkImagesToText() {
9
- return transform;
10
- function transform(node) {
11
- const children = node.children.map(child => {
12
- if (child.type === 'paragraph' && child.children.length === 1 && child.children[0].type === 'image') {
13
- const {
14
- alt,
15
- url,
16
- title
17
- } = child.children[0];
18
- const value = `![${alt || ''}](${url || ''}${title ? ` "${title}"` : ''})`;
19
- child.children = [{
20
- type: 'text',
21
- value
22
- }];
23
- }
24
- return child;
25
- });
26
- return {
27
- ...node,
28
- children
29
- };
30
- }
31
- }
package/dist/esm/types.js DELETED
@@ -1,2 +0,0 @@
1
- export const SLATE_DEFAULT_BLOCK_TYPE = 'paragraph';
2
- export const SLATE_BLOCK_PARENT_TYPES = ['list-item', 'quote'];