Skip to content

Commit

Permalink
feat: Implement XSS and DOM Clobbering protection
Browse files Browse the repository at this point in the history
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
  • Loading branch information
lucasnetau committed Sep 21, 2023
1 parent 843eca7 commit a268a0a
Show file tree
Hide file tree
Showing 5 changed files with 180 additions and 16 deletions.
1 change: 1 addition & 0 deletions src/js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const defaultOptions = {
roles: {
1: 'Administrator',
},
sanitizerOptions: {},
scrollToFieldOnAdd: true,
showActionButtons: true,
sortableControls: false,
Expand Down
33 changes: 21 additions & 12 deletions src/js/form-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <form> Element')
}

// prepare a new layout object with appropriate templates
if (!opts.layout) {
opts.layout = layout
Expand Down Expand Up @@ -93,11 +101,7 @@ function FormBuilder(opts, element, $) {
let cloneControls

function enhancedBootstrapEnabled() {
if (!opts.enableEnhancedBootstrapGrid) {
return false
}

return true
return !!opts.enableEnhancedBootstrapGrid
}

$stage.sortable({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2210,7 +2219,7 @@ function FormBuilder(opts, element, $) {
gridMode = false
gridModeTargetField = null

$(gridModeHelp).html('')
$(gridModeHelp).empty()

//Show controls
$cbUL.css('display', 'unset')
Expand All @@ -2220,7 +2229,7 @@ function FormBuilder(opts, element, $) {

function buildGridModeHelp() {
$(gridModeHelp).html(`
<div style='padding:5px'>
<div style='padding:5px'>
<h3 class="text text-center">Grid Mode</h3>
<table style='border-spacing:7px;border-collapse: separate'>
Expand Down
11 changes: 8 additions & 3 deletions src/js/form-render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,6 +39,7 @@ class FormRender {
},
onRender: () => {},
render: true,
sanitizerOptions: {},
templates: {}, // custom inline defined templates
notify: {
error: error => {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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}`
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
148 changes: 148 additions & 0 deletions src/js/sanitizer.js
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion src/js/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { setElementContent } from './sanitizer'
/**
* Cross file utilities for working with arrays,
* sorting and other fun stuff
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit a268a0a

Please sign in to comment.