From d5dccb4de4946430ab9b28378443f6217ac3ebfb Mon Sep 17 00:00:00 2001 From: Neil MacDougall Date: Thu, 23 Nov 2023 09:33:48 +0000 Subject: [PATCH] Support "target=_blank" in A links via clean-html directive (#9928) * Allow target=_blank on A tags through DOMPurify * Remove unused params * Fix lint issue * Prevent directives from being overwritten * Fix lint issues --- shell/initialize/index.js | 28 ++++++++++++++--- shell/plugins/clean-html-directive.js | 20 +----------- shell/plugins/clean-html.js | 40 ++++++++++++++++++++++++ shell/plugins/clean-tooltip-directive.js | 2 +- shell/plugins/index.js | 11 +++++++ 5 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 shell/plugins/clean-html.js create mode 100644 shell/plugins/index.js diff --git a/shell/initialize/index.js b/shell/initialize/index.js index bad1e22774b..02a2f738bef 100644 --- a/shell/initialize/index.js +++ b/shell/initialize/index.js @@ -13,7 +13,7 @@ import { setContext, getLocation, getRouteData, normalizeError } from '../utils/ import { createStore } from '../config/store.js'; /* Plugins */ - +import { loadDirectives } from '@shell/plugins'; import '../plugins/portal-vue.js'; import cookieUniversalNuxt from '../utils/cookie-universal-nuxt.js'; import axios from '../utils/axios.js'; @@ -21,11 +21,8 @@ import plugins from '../core/plugins.js'; import pluginsLoader from '../core/plugins-loader.js'; import axiosShell from '../plugins/axios'; import '../plugins/tooltip'; -import '../plugins/clean-tooltip-directive'; import '../plugins/vue-clipboard2'; import '../plugins/v-select'; -import '../plugins/directives'; -import '../plugins/clean-html-directive'; import '../plugins/transitions'; import '../plugins/vue-js-modal'; import '../plugins/js-yaml'; @@ -47,6 +44,29 @@ import '../plugins/formatters'; import version from '../plugins/version'; import steveCreateWorker from '../plugins/steve-create-worker'; +// Prevent extensions from overriding existing directives +// Hook into Vue.directive and keep track of the directive names that have been added +// and prevent an existing directive from being overwritten +const directiveNames = {}; +const vueDirective = Vue.directive; + +Vue.directive = function(name) { + if (directiveNames[name]) { + console.log(`Can not override directive: ${ name }`); // eslint-disable-line no-console + + return; + } + + directiveNames[name] = true; + + vueDirective.apply(Vue, arguments); +}; + +// Load the directives from the plugins - we do this with a function so we know +// these are initialized here, after the code above which keeps track of them and +// prevents over-writes +loadDirectives(); + // Component: Vue.component(ClientOnly.name, ClientOnly); diff --git a/shell/plugins/clean-html-directive.js b/shell/plugins/clean-html-directive.js index 194ed99ee92..66209e879d1 100644 --- a/shell/plugins/clean-html-directive.js +++ b/shell/plugins/clean-html-directive.js @@ -1,23 +1,5 @@ import Vue from 'vue'; -import DOMPurify from 'dompurify'; - -const ALLOWED_TAGS = [ - 'code', - 'li', - 'a', - 'p', - 'b', - 'br', - 'ul', - 'pre', - 'span', - 'div', - 'i', - 'em', - 'strong', -]; - -export const purifyHTML = (value) => DOMPurify.sanitize(value, { ALLOWED_TAGS }); +import { purifyHTML } from './clean-html'; export const cleanHtmlDirective = { inserted(el, binding) { diff --git a/shell/plugins/clean-html.js b/shell/plugins/clean-html.js new file mode 100644 index 00000000000..79634ed09c8 --- /dev/null +++ b/shell/plugins/clean-html.js @@ -0,0 +1,40 @@ +import DOMPurify from 'dompurify'; +import { uniq } from '@shell/utils/array'; + +const ALLOWED_TAGS = [ + 'code', + 'li', + 'a', + 'p', + 'b', + 'br', + 'ul', + 'pre', + 'span', + 'div', + 'i', + 'em', + 'strong', +]; + +// Allow 'A' tags to keep the target=_blank attribute if they have it +DOMPurify.addHook('uponSanitizeAttribute', (node, data) => { + if (node.tagName === 'A' && data.attrName === 'target' && data.attrValue === '_blank') { + data.forceKeepAttr = true; + } +}); + +// Ensure if an 'A' tag has target=_blank that we add noopener, noreferrer and nofollow to the 'rel' attribute +DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'A' && node?.target === '_blank') { + const rel = ['noopener', 'noreferrer', 'nofollow']; + const existingRel = node.rel?.length ? node.rel.split(' ') : []; + const combined = uniq([...rel, ...existingRel]); + + node.setAttribute('rel', combined.join(' ')); + } +}); + +export const purifyHTML = (value, options = { ALLOWED_TAGS }) => { + return DOMPurify.sanitize(value, options); +}; diff --git a/shell/plugins/clean-tooltip-directive.js b/shell/plugins/clean-tooltip-directive.js index ccdb12125b8..3b52a854fc1 100644 --- a/shell/plugins/clean-tooltip-directive.js +++ b/shell/plugins/clean-tooltip-directive.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { VTooltip } from 'v-tooltip'; -import { purifyHTML } from './clean-html-directive'; +import { purifyHTML } from './clean-html'; function purifyContent(value) { const type = typeof value; diff --git a/shell/plugins/index.js b/shell/plugins/index.js new file mode 100644 index 00000000000..ebcd8004968 --- /dev/null +++ b/shell/plugins/index.js @@ -0,0 +1,11 @@ +/** + * Load the directives + * + * These are included in a function that can be explictly called, so that we can be sure + * of the execution order, rather than importing them at the top of a file. + */ +export function loadDirectives() { + import('./clean-html-directive'); + import('./clean-tooltip-directive'); + import('./directives'); +}