diff --git a/e2e/tests/functional/plugins/notebook/notebook.e2e.spec.js b/e2e/tests/functional/plugins/notebook/notebook.e2e.spec.js index 66c1b038c0..8d867e7517 100644 --- a/e2e/tests/functional/plugins/notebook/notebook.e2e.spec.js +++ b/e2e/tests/functional/plugins/notebook/notebook.e2e.spec.js @@ -263,4 +263,77 @@ test.describe('Notebook entry tests', () => { }); test.fixme('new entries persist through navigation events without save', async ({ page }) => {}); test.fixme('previous and new entries can be deleted', async ({ page }) => {}); + test.fixme('when a valid link is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => { + const TEST_LINK = 'http://www.google.com'; + await page.goto('./#/browse/mine', { waitUntil: 'networkidle' }); + + // Create Notebook + const notebook = await createDomainObjectWithDefaults(page, { + type: 'Notebook', + name: "Entry Link Test" + }); + + await expandTreePaneItemByName(page, 'My Items'); + + await page.goto(notebook.url); + + await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`); + + const validLink = page.locator(`a[href="${TEST_LINK}"]`); + + // Start waiting for popup before clicking. Note no await. + const popupPromise = page.waitForEvent('popup'); + + await validLink.click(); + const popup = await popupPromise; + + // Wait for the popup to load. + await popup.waitForLoadState(); + expect.soft(popup.url()).toContain('www.google.com'); + + expect(await validLink.count()).toBe(1); + }); + test.fixme('when an invalid link is entered into a notebook entry, it does not become clickable when viewing', async ({ page }) => { + const TEST_LINK = 'www.google.com'; + await page.goto('./#/browse/mine', { waitUntil: 'networkidle' }); + + // Create Notebook + const notebook = await createDomainObjectWithDefaults(page, { + type: 'Notebook', + name: "Entry Link Test" + }); + + await expandTreePaneItemByName(page, 'My Items'); + + await page.goto(notebook.url); + + await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`); + + const invalidLink = page.locator(`a[href="${TEST_LINK}"]`); + + expect(await invalidLink.count()).toBe(0); + }); + test.fixme('when a nefarious link is entered into a notebook entry, it is sanitized when viewing', async ({ page }) => { + const TEST_LINK = 'http://www.google.com?bad='; + const TEST_LINK_BAD = `http://www.google.com?bad=`; + await page.goto('./#/browse/mine', { waitUntil: 'networkidle' }); + + // Create Notebook + const notebook = await createDomainObjectWithDefaults(page, { + type: 'Notebook', + name: "Entry Link Test" + }); + + await expandTreePaneItemByName(page, 'My Items'); + + await page.goto(notebook.url); + + await nbUtils.enterTextEntry(page, `This should be a link, BUT not a bad link: ${TEST_LINK_BAD} is it?`); + + const sanitizedLink = page.locator(`a[href="${TEST_LINK}"]`); + const unsanitizedLink = page.locator(`a[href="${TEST_LINK_BAD}"]`); + + expect.soft(await sanitizedLink.count()).toBe(1); + expect(await unsanitizedLink.count()).toBe(0); + }); }); diff --git a/e2e/tests/functional/plugins/notebook/notebookWithCouchDB.e2e.spec.js b/e2e/tests/functional/plugins/notebook/notebookWithCouchDB.e2e.spec.js index 87a352797d..9c01100472 100644 --- a/e2e/tests/functional/plugins/notebook/notebookWithCouchDB.e2e.spec.js +++ b/e2e/tests/functional/plugins/notebook/notebookWithCouchDB.e2e.spec.js @@ -76,6 +76,7 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => { await page.locator('text=To start a new entry, click here or drag and drop any object').click(); await page.locator('[aria-label="Notebook Entry Input"]').click(); await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`); + await page.locator('[aria-label="Notebook Entry Input"]').press('Enter'); await page.waitForLoadState('networkidle'); expect(addingNotebookElementsRequests.length).toBeLessThanOrEqual(2); @@ -148,14 +149,17 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => { await page.locator('text=To start a new entry, click here or drag and drop any object').click(); await page.locator('[aria-label="Notebook Entry Input"]').click(); await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`); + await page.locator('[aria-label="Notebook Entry Input"]').press('Enter'); await page.locator('text=To start a new entry, click here or drag and drop any object').click(); await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').click(); await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').fill(`Second Entry`); + await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').press('Enter'); await page.locator('text=To start a new entry, click here or drag and drop any object').click(); await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').click(); await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').fill(`Third Entry`); + await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').press('Enter'); // Add three tags await page.hover(`button:has-text("Add Tag") >> nth=2`); diff --git a/package.json b/package.json index 23731bb152..53d773c28d 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "plotly.js-gl2d-dist": "2.17.1", "printj": "1.3.1", "resolve-url-loader": "5.0.0", + "sanitize-html": "2.8.1", "sass": "1.57.1", "sass-loader": "13.2.0", "sinon": "15.0.1", diff --git a/src/plugins/notebook/NotebookViewProvider.js b/src/plugins/notebook/NotebookViewProvider.js index 66617789c7..8bcaf1ad82 100644 --- a/src/plugins/notebook/NotebookViewProvider.js +++ b/src/plugins/notebook/NotebookViewProvider.js @@ -25,13 +25,14 @@ import Notebook from './components/Notebook.vue'; import Agent from '@/utils/agent/Agent'; export default class NotebookViewProvider { - constructor(openmct, name, key, type, cssClass, snapshotContainer) { + constructor(openmct, name, key, type, cssClass, snapshotContainer, entryUrlWhitelist) { this.openmct = openmct; this.key = key; this.name = `${name} View`; this.type = type; this.cssClass = cssClass; this.snapshotContainer = snapshotContainer; + this.entryUrlWhitelist = entryUrlWhitelist; } canView(domainObject) { @@ -43,6 +44,7 @@ export default class NotebookViewProvider { let openmct = this.openmct; let snapshotContainer = this.snapshotContainer; let agent = new Agent(window); + let entryUrlWhitelist = this.entryUrlWhitelist; return { show(container) { @@ -54,7 +56,8 @@ export default class NotebookViewProvider { provide: { openmct, snapshotContainer, - agent + agent, + entryUrlWhitelist }, data() { return { diff --git a/src/plugins/notebook/components/NotebookEntry.vue b/src/plugins/notebook/components/NotebookEntry.vue index 932b45a3c4..8d1c3a15b0 100644 --- a/src/plugins/notebook/components/NotebookEntry.vue +++ b/src/plugins/notebook/components/NotebookEntry.vue @@ -1,3 +1,4 @@ + /***************************************************************************** * Open MCT, Copyright (c) 2014-2022, United States Government * as represented by the Administrator of the National Aeronautics and Space @@ -75,12 +76,14 @@ class="c-ne__text c-ne__input" aria-label="Notebook Entry Input" tabindex="0" - contenteditable="true" + :contenteditable="canEdit" + @mouseover="checkEditability($event)" + @mouseleave="canEdit = true" @focus="editingEntry()" @blur="updateEntryValue($event)" @keydown.enter.exact.prevent @keyup.enter.exact.prevent="forceBlur($event)" - v-text="entry.text" + v-html="formattedText" > @@ -91,7 +94,7 @@ class="c-ne__text" contenteditable="false" tabindex="0" - v-text="entry.text" + v-html="formattedText" > @@ -156,10 +159,16 @@ import TextHighlight from '../../../utils/textHighlight/TextHighlight.vue'; import { createNewEmbed } from '../utils/notebook-entries'; import { saveNotebookImageDomainObject, updateNamespaceOfDomainObject } from '../utils/notebook-image'; +import sanitizeHtml from 'sanitize-html'; import _ from 'lodash'; import Moment from 'moment'; +const SANITIZATION_SCHEMA = { + allowedTags: [], + allowedAttributes: {} +}; +const URL_REGEX = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g; const UNKNOWN_USER = 'Unknown'; export default { @@ -167,7 +176,7 @@ export default { NotebookEmbed, TextHighlight }, - inject: ['openmct', 'snapshotContainer'], + inject: ['openmct', 'snapshotContainer', 'entryUrlWhitelist'], props: { domainObject: { type: Object, @@ -224,6 +233,8 @@ export default { }, data() { return { + editMode: false, + canEdit: true, enableEmbedsWrapperScroll: false }; }, @@ -234,6 +245,31 @@ export default { createdOnTime() { return this.formatTime(this.entry.createdOn, 'HH:mm:ss'); }, + formattedText() { + // remove ANY tags + let text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA); + + if (this.editMode || !this.urlWhitelist) { + return text; + } + + text = text.replace(URL_REGEX, (match) => { + const url = new URL(match); + const domain = url.hostname; + let result = match; + let isMatch = this.urlWhitelist.find((partialDomain) => { + return domain.endsWith(partialDomain); + }); + + if (isMatch) { + result = `${match}`; + } + + return result; + }); + + return text; + }, isSelectedEntry() { return this.selectedEntryId === this.entry.id; }, @@ -271,6 +307,9 @@ export default { this.manageEmbedLayout(); this.dropOnEntry = this.dropOnEntry.bind(this); + if (this.entryUrlWhitelist?.length > 0) { + this.urlWhitelist = this.entryUrlWhitelist; + } }, beforeDestroy() { if (this.embedsWrapperResizeObserver) { @@ -307,6 +346,11 @@ export default { event.dataTransfer.effectAllowed = 'none'; } }, + checkEditability($event) { + if ($event.target.nodeName === 'A') { + this.canEdit = false; + } + }, deleteEntry() { this.$emit('deleteEntry', this.entry.id); }, @@ -405,9 +449,11 @@ export default { this.$emit('updateEntry', this.entry); }, editingEntry() { + this.editMode = true; this.$emit('editingEntry'); }, updateEntryValue($event) { + this.editMode = false; const value = $event.target.innerText; if (value !== this.entry.text && value.match(/\S/)) { this.entry.text = value; diff --git a/src/plugins/notebook/plugin.js b/src/plugins/notebook/plugin.js index 7fcabbe747..f24742644b 100644 --- a/src/plugins/notebook/plugin.js +++ b/src/plugins/notebook/plugin.js @@ -103,7 +103,7 @@ function installBaseNotebookFunctionality(openmct) { monkeyPatchObjectAPIForNotebooks(openmct); } -function NotebookPlugin(name = 'Notebook') { +function NotebookPlugin(name = 'Notebook', entryUrlWhitelist = []) { return function install(openmct) { if (openmct[NOTEBOOK_INSTALLED_KEY]) { return; @@ -118,8 +118,8 @@ function NotebookPlugin(name = 'Notebook') { const notebookType = new NotebookType(name, description, icon); openmct.types.addType(NOTEBOOK_TYPE, notebookType); - const notebookView = new NotebookViewProvider(openmct, name, NOTEBOOK_VIEW_TYPE, NOTEBOOK_TYPE, icon, snapshotContainer); - openmct.objectViews.addProvider(notebookView); + const notebookView = new NotebookViewProvider(openmct, name, NOTEBOOK_VIEW_TYPE, NOTEBOOK_TYPE, icon, snapshotContainer, entryUrlWhitelist); + openmct.objectViews.addProvider(notebookView, entryUrlWhitelist); installBaseNotebookFunctionality(openmct); @@ -127,7 +127,7 @@ function NotebookPlugin(name = 'Notebook') { }; } -function RestrictedNotebookPlugin(name = 'Notebook Shift Log') { +function RestrictedNotebookPlugin(name = 'Notebook Shift Log', entryUrlWhitelist = []) { return function install(openmct) { if (openmct[RESTRICTED_NOTEBOOK_INSTALLED_KEY]) { return; @@ -140,8 +140,8 @@ function RestrictedNotebookPlugin(name = 'Notebook Shift Log') { const notebookType = new NotebookType(name, description, icon); openmct.types.addType(RESTRICTED_NOTEBOOK_TYPE, notebookType); - const notebookView = new NotebookViewProvider(openmct, name, RESTRICTED_NOTEBOOK_VIEW_TYPE, RESTRICTED_NOTEBOOK_TYPE, icon, snapshotContainer); - openmct.objectViews.addProvider(notebookView); + const notebookView = new NotebookViewProvider(openmct, name, RESTRICTED_NOTEBOOK_VIEW_TYPE, RESTRICTED_NOTEBOOK_TYPE, icon, snapshotContainer, entryUrlWhitelist); + openmct.objectViews.addProvider(notebookView, entryUrlWhitelist); installBaseNotebookFunctionality(openmct);