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);