diff --git a/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-renderer.js b/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-renderer.js index ad5adde26f..762f62e773 100644 --- a/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-renderer.js +++ b/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-renderer.js @@ -1,5 +1,7 @@ import {addCreateDocumentOption} from '../../utils/add-create-document-option'; import {renderEmptyContainer} from '../../utils/render-empty-container'; +import {escapeHtml} from '../../utils/escape-html'; +import {truncateHtml} from '../../utils/truncate'; export function renderBookmarkNode(node, options = {}) { addCreateDocumentOption(options); @@ -17,63 +19,20 @@ export function renderBookmarkNode(node, options = {}) { } } -function escapeHtml(unsafe) { - return unsafe - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function truncateText(text, maxLength) { - if (text && text.length > maxLength) { - return text.substring(0, maxLength - 1).trim() + '…'; - } else { - return text ?? ''; - } -} - -function truncateHtml(text, maxLength, maxLengthMobile) { - // If no mobile length specified or mobile length is larger than desktop, - // just do a simple truncate - if (!maxLengthMobile || maxLength <= maxLengthMobile) { - return escapeHtml(truncateText(text, maxLength)); - } - - // Handle text shorter than mobile length - if (text.length <= maxLengthMobile) { - return escapeHtml(text); - } - - if (text && text.length > maxLengthMobile) { - let ellipsis = ''; - - if (text.length > maxLengthMobile && text.length <= maxLength) { - ellipsis = ''; - } else if (text.length > maxLength) { - ellipsis = '…'; - } - - return escapeHtml(text.substring(0, maxLengthMobile - 1)) + '' + escapeHtml(text.substring(maxLengthMobile - 1, maxLength - 1)) + '' + ellipsis; - } else { - return escapeHtml(text ?? ''); - } -} - function emailTemplate(node, document) { - const title = node.title; - const publisher = node.publisher; - const author = node.author; + const title = escapeHtml(node.title); + const publisher = escapeHtml(node.publisher); + const author = escapeHtml(node.author); + const description = escapeHtml(node.description); + const icon = node.icon; - const description = node.description; const url = node.url; const thumbnail = node.thumbnail; const caption = node.caption; const element = document.createElement('div'); - const html = + const html = `
diff --git a/packages/kg-default-nodes/lib/utils/escape-html.js b/packages/kg-default-nodes/lib/utils/escape-html.js index 11ac3527a8..dfb8972986 100644 --- a/packages/kg-default-nodes/lib/utils/escape-html.js +++ b/packages/kg-default-nodes/lib/utils/escape-html.js @@ -1,6 +1,6 @@ /** * Escape HTML special characters - * @param {string} unsafe + * @param {string} unsafe * @returns string */ export function escapeHtml(unsafe) { diff --git a/packages/kg-default-nodes/lib/utils/truncate.js b/packages/kg-default-nodes/lib/utils/truncate.js new file mode 100644 index 0000000000..103213e491 --- /dev/null +++ b/packages/kg-default-nodes/lib/utils/truncate.js @@ -0,0 +1,36 @@ +import {escapeHtml} from './escape-html'; + +export function truncateText(text, maxLength) { + if (text && text.length > maxLength) { + return text.substring(0, maxLength - 1).trim() + '…'; + } else { + return text ?? ''; + } +} + +export function truncateHtml(text, maxLength, maxLengthMobile) { + // If no mobile length specified or mobile length is larger than desktop, + // just do a simple truncate + if (!maxLengthMobile || maxLength <= maxLengthMobile) { + return escapeHtml(truncateText(text, maxLength)); + } + + // Handle text shorter than mobile length + if (text.length <= maxLengthMobile) { + return escapeHtml(text); + } + + if (text && text.length > maxLengthMobile) { + let ellipsis = ''; + + if (text.length > maxLengthMobile && text.length <= maxLength) { + ellipsis = ''; + } else if (text.length > maxLength) { + ellipsis = '…'; + } + + return escapeHtml(text.substring(0, maxLengthMobile - 1)) + '' + escapeHtml(text.substring(maxLengthMobile - 1, maxLength - 1)) + '' + ellipsis; + } else { + return escapeHtml(text ?? ''); + } +} diff --git a/packages/kg-default-nodes/test/nodes/bookmark.test.js b/packages/kg-default-nodes/test/nodes/bookmark.test.js index 05e4e6b662..03d34bbcce 100644 --- a/packages/kg-default-nodes/test/nodes/bookmark.test.js +++ b/packages/kg-default-nodes/test/nodes/bookmark.test.js @@ -133,7 +133,7 @@ describe('BookmarkNode', function () { describe('urlTransformMap', function () { it('contains the expected URL mapping', editorTest(function () { BookmarkNode.urlTransformMap.should.deepEqual({ - 'url': 'url', + url: 'url', 'metadata.icon': 'url', 'metadata.thumbnail': 'url' }); @@ -206,6 +206,64 @@ describe('BookmarkNode', function () { element.outerHTML.should.equal(''); })); + + it('escapes HTML for text fields in web', editorTest(function () { + dataset = { + url: 'https://www.fake.org/', + metadata: { + icon: 'https://www.fake.org/favicon.ico', + title: 'Ghost: Independent technology for modern publishing.', + description: 'doing "kewl" stuff', + author: 'fa\'ker', + publisher: 'Fake ', + thumbnail: 'https://fake.org/image.png' + }, + caption: '

This is a caption

' + }; + const bookmarkNode = $createBookmarkNode(dataset); + const {element} = bookmarkNode.exportDOM(exportOptions); + + // Check that text fields are escaped + element.innerHTML.should.containEql('Ghost: Independent technology <script>alert("XSS")</script> for modern publishing.'); + element.innerHTML.should.containEql('doing "kewl" stuff'); + element.innerHTML.should.containEql('fa\'ker'); + element.innerHTML.should.containEql('Fake <script>alert("XSS")</script>'); + + // Check that caption is not escaped + element.innerHTML.should.containEql('

This is a caption

'); + })); + + it('escapes HTML for text fields in email', editorTest(function () { + const options = { + target: 'email' + }; + dataset = { + url: 'https://www.fake.org/', + metadata: { + icon: 'https://www.fake.org/favicon.ico', + title: 'Ghost: Independent technology for modern publishing.', + description: 'doing "kewl" stuff', + author: 'fa\'ker', + publisher: 'Fake ', + thumbnail: 'https://fake.org/image.png' + }, + caption: '

This is a caption

' + }; + const bookmarkNode = $createBookmarkNode(dataset); + const {element} = bookmarkNode.exportDOM({...exportOptions, ...options}); + + // Check that email template is used + element.innerHTML.should.containEql(''); + + // Check that text fields are escaped + element.innerHTML.should.containEql('Ghost: Independent technology <script>alert("XSS")</script> for modern publishing.'); + element.innerHTML.should.containEql('doing &quot;kewl&quot; stuff'); + element.innerHTML.should.containEql('fa\'ker'); + element.innerHTML.should.containEql('Fake <script>alert("XSS")</script>'); + + // Check that caption is not escaped + element.innerHTML.should.containEql('

This is a caption

'); + })); }); describe('exportJSON', function () { @@ -277,7 +335,7 @@ describe('BookmarkNode', function () { it('urlTransformMap', editorTest(function () { BookmarkNode.urlTransformMap.should.deepEqual({ - 'url': 'url', + url: 'url', 'metadata.icon': 'url', 'metadata.thumbnail': 'url' });