From a268a0a21d5e9ac620bef0758bc3a541968fe0f1 Mon Sep 17 00:00:00 2001 From: James Lucas Date: Tue, 19 Sep 2023 12:26:27 +1000 Subject: [PATCH] feat: Implement XSS and DOM Clobbering protection Sanitizer is implemented with support for different backends: the experimental Sanitizer API (currently supported by Chrome and Edge), DomPurify, and a fallback jQuery sanitizer implementation, each will be tried in order until one is successful. DomPurify need to be included prior to formBuilder/formRender being initialised. Sanitizer and DomPurify can be configured by passing in a new configured instance of their respective Class via the sanitizerOptions option. The fallback function can be overridden with a new function the same way. Backends can be disabled by passing in a false value for that backend via the sanitizerOptions option. All sanitization can be disabled by setting all backends to false sanitizerOptions = { sanitizer: false, dompurify: false, fallback: false } Each backend has been tested against a variety of inputs from https://github.com/mjfroman/moz-libwebrtc-third-party/blob/980ec542e93f88658089ee4ac78fc3dcfd066847/blink/web_tests/wpt_internal/sanitizer-api/support/testcases.sub.js and DomPurify --- src/js/config.js | 1 + src/js/form-builder.js | 33 +++++---- src/js/form-render.js | 11 ++- src/js/sanitizer.js | 148 +++++++++++++++++++++++++++++++++++++++++ src/js/utils.js | 3 +- 5 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 src/js/sanitizer.js diff --git a/src/js/config.js b/src/js/config.js index 9f0a1ca15..8e6fa22f9 100644 --- a/src/js/config.js +++ b/src/js/config.js @@ -68,6 +68,7 @@ export const defaultOptions = { roles: { 1: 'Administrator', }, + sanitizerOptions: {}, scrollToFieldOnAdd: true, showActionButtons: true, sortableControls: false, diff --git a/src/js/form-builder.js b/src/js/form-builder.js index 68497bac0..f4921613a 100644 --- a/src/js/form-builder.js +++ b/src/js/form-builder.js @@ -35,6 +35,7 @@ import { getContentType, generateSelectorClassNames, } from './utils' +import { setElementContent, setSanitizerConfig } from './sanitizer' import fontConfig from '../fonts/config.json' const css_prefix_text = fontConfig.css_prefix_text @@ -65,6 +66,13 @@ function FormBuilder(opts, element, $) { formBuilder.colWrapperClass = colWrapperClass formBuilder.fieldSelector = opts.enableEnhancedBootstrapGrid ? rowWrapperClassSelector : defaultFieldSelector + //Initialise HTML sanitizer + setSanitizerConfig(opts.sanitizerOptions) + if ($(element).closest('form').length) { + //Due to Dom Clobbering potential with the stage and the lack of requirement for a Form element, warn for this type of setup + opts.notify.warning('WARNING: FormBuilder does not support being contained with a
Element') + } + // prepare a new layout object with appropriate templates if (!opts.layout) { opts.layout = layout @@ -93,11 +101,7 @@ function FormBuilder(opts, element, $) { let cloneControls function enhancedBootstrapEnabled() { - if (!opts.enableEnhancedBootstrapGrid) { - return false - } - - return true + return !!opts.enableEnhancedBootstrapGrid } $stage.sortable({ @@ -1710,6 +1714,15 @@ function FormBuilder(opts, element, $) { $valWrap.toggle(e.target.value !== 'quill') }) + const testForm = document.createElement('form') + $stage.on('change', '[name="name"]', e => { + const name = e.target.value + if (name in document || name in testForm) { + //@TODO Notify the user of this potential issue + opts.notify.error('Potential for Dom Clobbering with field name ' + name) + } + }) + const stageOnChangeSelectors = ['.prev-holder input', '.prev-holder select', '.prev-holder textarea'] $stage.on('change', stageOnChangeSelectors.join(', '), e => { let prevOptions @@ -1748,11 +1761,7 @@ function FormBuilder(opts, element, $) { if (!target.classList.contains('fld-label')) return const value = target.value || target.innerHTML const label = closest(target, '.form-field').querySelector('.field-label') - if (config.opts.disableHTMLLabels) { - label.textContent = value - } else { - label.innerHTML = parsedHtml(value) - } + setElementContent(label, parsedHtml(value), config.opts.disableHTMLLabels) }) // remove error styling when users tries to correct mistake @@ -2210,7 +2219,7 @@ function FormBuilder(opts, element, $) { gridMode = false gridModeTargetField = null - $(gridModeHelp).html('') + $(gridModeHelp).empty() //Show controls $cbUL.css('display', 'unset') @@ -2220,7 +2229,7 @@ function FormBuilder(opts, element, $) { function buildGridModeHelp() { $(gridModeHelp).html(` -
+

Grid Mode

diff --git a/src/js/form-render.js b/src/js/form-render.js index 3d8c0d573..8e2f44e9c 100644 --- a/src/js/form-render.js +++ b/src/js/form-render.js @@ -8,6 +8,7 @@ import './control/index' import controlCustom from './control/custom' import { defaultI18n } from './config' import '../sass/form-render.scss' +import { setSanitizerConfig } from './sanitizer' /** * FormRender Class @@ -38,6 +39,7 @@ class FormRender { }, onRender: () => {}, render: true, + sanitizerOptions: {}, templates: {}, // custom inline defined templates notify: { error: error => { @@ -54,6 +56,9 @@ class FormRender { this.options = jQuery.extend(true, defaults, options) this.instanceContainers = [] + //Override any sanitizer configuration + setSanitizerConfig(this.options.sanitizerOptions) + if (!mi18n.current) { mi18n.init(this.options.i18n) } @@ -134,7 +139,7 @@ class FormRender { * @param {Number} instanceIndex - instance index * @return {Object} sanitized field object */ - santizeField(field, instanceIndex) { + sanitizeField(field, instanceIndex) { const sanitizedField = Object.assign({}, field) if (instanceIndex) { sanitizedField.id = field.id && `${field.id}-${instanceIndex}` @@ -191,7 +196,7 @@ class FormRender { const engine = new opts.layout(opts.layoutTemplates, false, opts.disableHTMLLabels) for (let i = 0; i < opts.formData.length; i++) { const fieldData = opts.formData[i] - const sanitizedField = this.santizeField(fieldData, instanceIndex) + const sanitizedField = this.sanitizeField(fieldData, instanceIndex) // determine the control class for this type, and then process it through the layout engine const controlClass = control.getClass(fieldData.type, fieldData.subtype) @@ -249,7 +254,7 @@ class FormRender { 'To render a single element, please specify a single object of formData for the field in question', ) } - const sanitizedField = this.santizeField(fieldData) + const sanitizedField = this.sanitizeField(fieldData) // determine the control class for this type, and then build it const engine = new opts.layout() diff --git a/src/js/sanitizer.js b/src/js/sanitizer.js new file mode 100644 index 000000000..d686dc9fd --- /dev/null +++ b/src/js/sanitizer.js @@ -0,0 +1,148 @@ +/** + * Sanitizer utility for handling untrusted HTML + */ + +const sanitizerConfig = { + sanitizer: typeof window['Sanitizer'] === 'function' ? new window.Sanitizer() : false, + dompurify: window.DOMPurify ? (purify => { + purify.setConfig({ + //USE_PROFILES: { html: true }, //Only process HTML (exclude SVG and MATHML) + SANITIZE_DOM: false, //formBuilder uses inputs with names that clash built-in attributes of Form element, we use our modified DomClobbing function instead + ADD_ATTR: ['contenteditable'] //label input requires this to be allowed + }) + return purify + })(window.DOMPurify) : false, + fallback: content => { + //Fallback function if no other sanitizer is available + + //jQuery < 3.5 doesn't have this safety feature, so we implement it here + // Stop scripts or inline event handlers from being executed immediately + // by using document.implementation + const context = document.implementation.createHTMLDocument('') + + // Set the base href for the created document + // so any parsed elements with URLs + // are based on the document's URL + const base = context.createElement('base') + base.href = document.location.href + context.head.appendChild(base) + + const exclude_tags = [ + 'applet', + 'comment', + 'embed', + 'iframe', + 'link', + 'listing', + 'meta', + 'noscript', + 'object', + 'plaintext', + 'script', + 'style', + 'xmp', + ] + + const output = $.parseHTML(content, context, false) + $(output).find('*').addBack().each((nindex, node) => { + if (node.nodeName === '#text') { + return //Allow through text nodes + } + + //Strip potentially dangerous tags + if (node.tagName && exclude_tags.includes(node.tagName.toLowerCase())) { + if (node.parentElement) { + node.parentElement.removeChild(node) + } else if (output.includes(node)) { + output.splice(output.indexOf(node), 1) + } + return + } + + //Strip attributes that can execute Javascript or cause dom clobbering + if (node.attributes) { + Array.from(node.attributes).forEach(attribute => { + const attrNameLc = attribute.name.toLowerCase() + if ( + attrNameLc.startsWith('on') + || ['form','formaction'].includes(attrNameLc) + || attribute.value.trim().toLowerCase().startsWith('javascript:') + ) { + $(node).removeAttr(attribute.name) + } + }) + } + }) + + const tmp = context.createElement('div') + $(tmp).html(output) + return tmp.innerHTML + } +} + +export const setSanitizerConfig = config => { Object.keys(config).forEach(implementation => sanitizerConfig[implementation] = config[implementation]) } + +const sanitizeDomClobbering = element => { + $(element).find('*').each((nindex, node) => { + //Prevent dom clobbering of document.X from Element.name + if (['embed', 'form', 'iframe', 'image', 'img', 'object'].includes(node.tagName.toLowerCase())) { + node.removeAttribute('name') + } + + ['id','name'].forEach(attrName => { + if (node.hasAttribute(attrName) && (node.getAttribute(attrName) in document)) { //@TODO for formRender we should also ensure no DomClobbering for Form + node.removeAttribute(attrName) + } + }) + }) + return element +} + +const sanitizersCallbacks = { + fallback: (element, content) => { + //fallback will return the content as-is if the fallback is disabled + const purifier = sanitizerConfig.fallback + const supported = typeof purifier === 'function' + if (supported) { + content = purifier(content) + } + element.innerHTML = content + return supported + }, + dompurify: (element, content) => { + const purifier = sanitizerConfig.dompurify + if (purifier === false || !purifier.isSupported) { + return false + } + + element.innerHTML = purifier.sanitize(content) + return true + }, + sanitizer: (element, content) => { + const sanitizer = sanitizerConfig.sanitizer + if (sanitizer) { + element.setHTML(content, {sanitizer: sanitizer}) + } + return false + } +} + +export const setElementContent = (element, content, asText = false) => { + if (asText) { + element.textContent = content + } else { + const proxyElem = document.createElement(element.tagName) + const performedBy = ['sanitizer','dompurify','fallback'].find(type => sanitizersCallbacks[type](proxyElem, content)) + if (performedBy !== undefined) { + sanitizeDomClobbering(proxyElem, '') + } + element.innerHTML = proxyElem.innerHTML + } +} + +const sanitizer = { + setElementContent, + setSanitizerConfig, +} + +export default sanitizer \ No newline at end of file diff --git a/src/js/utils.js b/src/js/utils.js index 2869dfd80..f8d9c0e06 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -1,3 +1,4 @@ +import { setElementContent } from './sanitizer' /** * Cross file utilities for working with arrays, * sorting and other fun stuff @@ -211,7 +212,7 @@ export const markup = function (tag, content = '', attributes = {}) { const appendContent = { string: content => { - field.innerHTML += content + setElementContent(field,field.innerHTML + content) }, object: config => { const { tag, content, ...data } = config