diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index 7faa2132d7..f48dcfb217 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -30,11 +30,13 @@ import { mapGetters } from 'vuex'; import { userHelpers } from './components/mixins/userHelpers'; import { addHeaders, loadLanguage } from './plugins/i18n'; import { restApi } from '@/components/mixins/restApi'; +import { globalLinkActions } from '@/components/mixins/globalLinkActions'; export default mixins( showMessage, userHelpers, restApi, + globalLinkActions, ).extend({ name: 'App', components: { diff --git a/packages/editor-ui/src/components/PageAlert.vue b/packages/editor-ui/src/components/PageAlert.vue index 1a3df215e8..0495e851ca 100644 --- a/packages/editor-ui/src/components/PageAlert.vue +++ b/packages/editor-ui/src/components/PageAlert.vue @@ -7,6 +7,7 @@ import mixins from 'vue-typed-mixins'; import { showMessage } from './mixins/showMessage'; import { ElMessageComponent } from 'element-ui/types/message'; +import { sanitizeHtml } from '@/utils'; export default mixins( showMessage, @@ -28,7 +29,7 @@ export default mixins( }, mounted() { this.alert = this.$showAlert({ - message: this.message, + message: sanitizeHtml(this.message), type: 'warning', duration: 0, showClose: true, diff --git a/packages/editor-ui/src/components/mixins/globalLinkActions.ts b/packages/editor-ui/src/components/mixins/globalLinkActions.ts new file mode 100644 index 0000000000..9f0bcdd950 --- /dev/null +++ b/packages/editor-ui/src/components/mixins/globalLinkActions.ts @@ -0,0 +1,48 @@ +/** + * Creates event listeners for `data-action` attribute to allow for actions to be called from locale without using + * unsafe onclick attribute + */ + import Vue from 'vue'; + + export const globalLinkActions = Vue.extend({ + data(): {[key: string]: {[key: string]: Function}} { + return { + customActions: {}, + }; + }, + mounted() { + window.addEventListener('click', this.delegateClick); + this.$root.$on('registerGlobalLinkAction', this.registerCustomAction); + }, + destroyed() { + window.removeEventListener('click', this.delegateClick); + this.$root.$off('registerGlobalLinkAction', this.registerCustomAction); + }, + computed: { + availableActions(): {[key: string]: Function} { + return { + reload: this.reload, + ...this.customActions, + }; + }, + }, + methods: { + registerCustomAction(key: string, action: Function) { + this.customActions[key] = action; + }, + delegateClick(e: MouseEvent) { + const clickedElement = e.target; + if (!(clickedElement instanceof Element) || clickedElement.tagName !== 'A') return; + + const actionAttribute = clickedElement.getAttribute('data-action'); + if(actionAttribute && typeof this.availableActions[actionAttribute] === 'function') { + e.preventDefault(); + this.availableActions[actionAttribute](); + } + }, + reload() { + window.location.reload(); + }, + }, + }); + diff --git a/packages/editor-ui/src/components/mixins/pushConnection.ts b/packages/editor-ui/src/components/mixins/pushConnection.ts index 46be03672c..ac4c7dad89 100644 --- a/packages/editor-ui/src/components/mixins/pushConnection.ts +++ b/packages/editor-ui/src/components/mixins/pushConnection.ts @@ -233,7 +233,12 @@ export const pushConnection = mixins( let action; if (!isSavingExecutions) { - action = 'Turn on saving manual executions and run again to see what happened after this node.'; + this.$root.$emit('registerGlobalLinkAction', 'open-settings', async () => { + if (this.$store.getters.isNewWorkflow) await this.saveAsNewWorkflow(); + this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY); + }); + + action = 'Turn on saving manual executions and run again to see what happened after this node.'; } else { action = `View the execution to see what happened after this node.`; @@ -246,14 +251,6 @@ export const pushConnection = mixins( message: `${action} More info`, type: 'success', duration: 0, - onLinkClick: async (e: HTMLLinkElement) => { - if (e.classList.contains('open-settings')) { - if (this.$store.getters.isNewWorkflow) { - await this.saveAsNewWorkflow(); - } - this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY); - } - }, }); } else if (runDataExecuted.finished !== true) { this.$titleSet(workflow.name as string, 'ERROR'); diff --git a/packages/editor-ui/src/components/mixins/showMessage.ts b/packages/editor-ui/src/components/mixins/showMessage.ts index e949329d7c..dff7a6afca 100644 --- a/packages/editor-ui/src/components/mixins/showMessage.ts +++ b/packages/editor-ui/src/components/mixins/showMessage.ts @@ -7,6 +7,7 @@ import { ExecutionError } from 'n8n-workflow'; import { ElMessageBoxOptions } from 'element-ui/types/message-box'; import { ElMessage, ElMessageComponent, ElMessageOptions, MessageType } from 'element-ui/types/message'; import { isChildOf } from './helpers'; +import { sanitizeHtml } from '@/utils'; let stickyNotificationQueue: ElNotificationComponent[] = []; @@ -17,6 +18,8 @@ export const showMessage = mixins(externalHooks).extend({ track = true, ) { messageData.dangerouslyUseHTMLString = true; + messageData.message = messageData.message ? sanitizeHtml(messageData.message) : messageData.message; + if (messageData.position === undefined) { messageData.position = 'bottom-right'; } @@ -47,7 +50,6 @@ export const showMessage = mixins(externalHooks).extend({ duration?: number, customClass?: string, closeOnClick?: boolean, - onLinkClick?: (e: HTMLLinkElement) => void, type?: MessageType, }) { // eslint-disable-next-line prefer-const @@ -64,26 +66,6 @@ export const showMessage = mixins(externalHooks).extend({ }; } - if (config.onLinkClick) { - const onLinkClick = (e: MouseEvent) => { - if (e && e.target && config.onLinkClick && isChildOf(notification.$el, e.target as Element)) { - const target = e.target as HTMLElement; - if (target && target.tagName === 'A') { - config.onLinkClick(e.target as HTMLLinkElement); - } - } - }; - window.addEventListener('click', onLinkClick); - - const cb = config.onClose; - config.onClose = () => { - window.removeEventListener('click', onLinkClick); - if (cb) { - cb(); - } - }; - } - notification = this.$showMessage({ title: config.title, message: config.message, @@ -159,7 +141,8 @@ export const showMessage = mixins(externalHooks).extend({ ...(type && { type }), }; - await this.$confirm(message, headline, options); + const sanitizedMessage = sanitizeHtml(message); + await this.$confirm(sanitizedMessage, headline, options); return true; } catch (e) { return false; @@ -176,7 +159,8 @@ export const showMessage = mixins(externalHooks).extend({ ...(type && { type }), }; - await this.$confirm(message, headline, options); + const sanitizedMessage = sanitizeHtml(message); + await this.$confirm(sanitizedMessage, headline, options); return 'confirmed'; } catch (e) { return e as string; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index cfcd12eed9..f33622bd92 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -836,7 +836,7 @@ "showMessage.ok": "OK", "showMessage.showDetails": "Show Details", "startupError": "Error connecting to n8n", - "startupError.message": "Could not connect to server. Refresh to try again", + "startupError.message": "Could not connect to server. Refresh to try again", "tagsDropdown.createTag": "Create tag \"{filter}\"", "tagsDropdown.manageTags": "Manage tags", "tagsDropdown.noMatchingTagsExist": "No matching tags exist", diff --git a/packages/editor-ui/src/utils.ts b/packages/editor-ui/src/utils.ts index 5b8786c8a9..855f101a68 100644 --- a/packages/editor-ui/src/utils.ts +++ b/packages/editor-ui/src/utils.ts @@ -1,3 +1,5 @@ +import xss, { friendlyAttrValue } from 'xss'; + export const omit = (keyToOmit: string, { [keyToOmit]: _, ...remainder }) => remainder; export function isObjectLiteral(maybeObject: unknown): maybeObject is { [key: string]: string } { @@ -12,3 +14,33 @@ export function isJsonKeyObject(item: unknown): item is { return Object.keys(item).includes('json'); } + +export function sanitizeHtml(dirtyHtml: string) { + const allowedAttributes = ['href','name', 'target', 'title', 'class', 'id']; + const allowedTags = ['p', 'strong', 'b', 'code', 'a', 'br', 'i', 'em', 'small' ]; + + const sanitizedHtml = xss(dirtyHtml, { + onTagAttr: (tag, name, value) => { + if (tag === 'img' && name === 'src') { + // Only allow http requests to supported image files from the `static` directory + const isImageFile = value.split('#')[0].match(/\.(jpeg|jpg|gif|png|webp)$/) !== null; + const isStaticImageFile = isImageFile && value.startsWith('/static/'); + if (!value.startsWith('https://') && !isStaticImageFile) { + return ''; + } + } + + // Allow `allowedAttributes` and all `data-*` attributes + if(allowedAttributes.includes(name) || name.startsWith('data-')) return `${name}="${friendlyAttrValue(value)}"`; + + return; + // Return nothing, means keep the default handling measure + }, + onTag: (tag) => { + if(!allowedTags.includes(tag)) return ''; + return; + }, + }); + + return sanitizedHtml; +} diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 1eff378543..550bc22a57 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -634,7 +634,7 @@ export default mixins( if ((data as IExecutionsSummary).waitTill) { this.$showMessage({ title: this.$locale.baseText('nodeView.thisExecutionHasntFinishedYet'), - message: `${this.$locale.baseText('nodeView.refresh')} ${this.$locale.baseText('nodeView.toSeeTheLatestStatus')}.
${this.$locale.baseText('nodeView.moreInfo')}`, + message: `${this.$locale.baseText('nodeView.refresh')} ${this.$locale.baseText('nodeView.toSeeTheLatestStatus')}.
${this.$locale.baseText('nodeView.moreInfo')}`, type: 'warning', duration: 0, });