-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
843eca7
commit a268a0a
Showing
5 changed files
with
180 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters