UNPKG

@ionic/core

Version:
432 lines (428 loc) • 16.7 kB
/*! * (C) Ionic http://ionicframework.com - MIT License */ 'use strict'; var index = require('./index-BiQyIvoc.js'); /** * Sanitize an untrusted HTML string. * * Parses the string into a detached DOM, removes blocked tags, strips * attributes outside the narrow `domStringAllowedAttributes` list (refer * `sanitizeElement`), and scrubs script-scheme URLs. Returns the sanitized * HTML string. * * Use this when you have an HTML string from an unknown source and need to * render it via `innerHTML`. Use `sanitizeDOMTree` instead when you already * have a DOM tree and want to sanitize it in place without a string round * trip. The two apply the same dangerous-vector scrubbing but different * attribute allowlists: this path stays narrow so existing consumers * (toast, loading, alert message, etc.) are unaffected, while * `sanitizeDOMTree` uses the wider rich-content allowlist. * * @param untrustedString - The HTML string to sanitize. Pass an * `IonicSafeString` to bypass sanitization, or `undefined` to short-circuit. * @returns The sanitized HTML string, or `undefined` if the input was * `undefined`. Returns `''` if sanitization fails or the input appears to * carry an inline `onload` handler. */ const sanitizeDOMString = (untrustedString) => { try { if (untrustedString instanceof IonicSafeString) { return untrustedString.value; } if (!isSanitizerEnabled() || typeof untrustedString !== 'string' || untrustedString === '') { return untrustedString; } /** * In Blink a non-outermost `<svg>` (e.g. `<svg><svg onload=...>`) fires * its load handler while `innerHTML` below parses the string, so it runs * before the attribute-stripping pass can remove it. The match has to * stay this loose because attribute names are case-insensitive and * whitespace is allowed before the `=`. */ if (/onload\s*=/i.test(untrustedString)) { index.printIonWarning('sanitizeDOMString - Content was discarded because it appears to contain an onload handler:', untrustedString.substring(0, 100)); return ''; } /** * Create a document fragment * separate from the main DOM, * create a div to do our work in */ const documentFragment = document.createDocumentFragment(); const workingDiv = document.createElement('div'); documentFragment.appendChild(workingDiv); workingDiv.innerHTML = untrustedString; /** * Remove any elements * that are blocked */ blockedTags.forEach((blockedTag) => { const getElementsToRemove = documentFragment.querySelectorAll(blockedTag); for (let elementIndex = getElementsToRemove.length - 1; elementIndex >= 0; elementIndex--) { const element = getElementsToRemove[elementIndex]; if (element.parentNode) { element.parentNode.removeChild(element); } else { documentFragment.removeChild(element); } /** * We still need to sanitize * the children of this element * as they are left behind */ const childElements = getElementChildren(element); /* eslint-disable-next-line */ for (let childIndex = 0; childIndex < childElements.length; childIndex++) { sanitizeElement(childElements[childIndex], domStringAllowedAttributes); } } }); /** * Go through remaining elements and remove * non-allowed attribs */ // IE does not support .children on document fragments, only .childNodes const dfChildren = getElementChildren(documentFragment); /* eslint-disable-next-line */ for (let childIndex = 0; childIndex < dfChildren.length; childIndex++) { sanitizeElement(dfChildren[childIndex], domStringAllowedAttributes); } // Append document fragment to div const fragmentDiv = document.createElement('div'); fragmentDiv.appendChild(documentFragment); // First child is always the div we did our work in const getInnerDiv = fragmentDiv.querySelector('div'); return getInnerDiv !== null ? getInnerDiv.innerHTML : fragmentDiv.innerHTML; } catch (err) { index.printIonError('sanitizeDOMString', err); return ''; } }; /** * Sanitize an entire trusted DOM tree in place. * * Removes blocked tags (`script`, `iframe`, etc.) from the subtree and * then sanitizes attributes on every remaining element using the wider * `richContentAllowedAttributes` allowlist (refer `sanitizeElement`). * Component presentational attributes (`size`, `color`, `shape`, inline * SVG, `aria-*`, `data-*`) are preserved; `style`, event handlers (`on*`), * form/navigation-hijack attributes, script-scheme URLs, and non-image * `data:` URLs are stripped. * * Use this when you have a DOM tree the developer controls (e.g. * cloned slot content from a component) and you need to render it * elsewhere safely. * * @param root - The root element whose subtree will be sanitized in * place. No-op when the sanitizer is disabled via `Ionic.config`. */ const sanitizeDOMTree = (root) => { if (!isSanitizerEnabled()) { return; } blockedTags.forEach((tag) => { const matches = root.querySelectorAll(tag); for (let i = matches.length - 1; i >= 0; i--) { matches[i].remove(); } }); sanitizeElement(root, richContentAllowedAttributes, richContentAllowedAttributePrefixes); }; /** * Clean up current element based on allowed attributes * and then recursively dig down into any child elements to * clean those up as well */ // TODO(FW-2832): type (using Element triggers other type errors as well) const sanitizeElement = (element, allowedAttributes, allowedAttributePrefixes = []) => { // IE uses childNodes, so ignore nodes that are not elements if (element.nodeType && element.nodeType !== 1) { return; } /** * If attributes is not a NamedNodeMap * then we should remove the element entirely. * This helps avoid DOM Clobbering attacks where * attributes is overridden. */ if (typeof NamedNodeMap !== 'undefined' && !(element.attributes instanceof NamedNodeMap)) { element.remove(); return; } /** * Always strip `style` (CSS injection, `background:url()` beaconing, UI * spoofing). It is never on the allowlist, but it is removed explicitly * here because some engines (e.g. jsdom) don't enumerate the CSSOM-backed * `style` attribute in `element.attributes`, which would let the loop * below skip over it. */ element.removeAttribute('style'); for (let i = element.attributes.length - 1; i >= 0; i--) { const attribute = element.attributes.item(i); const attributeName = attribute.name; const lowerName = attributeName.toLowerCase(); /** * Remove any attribute that is not on the allowlist. This drops event * handlers (`on*`), `style`, the form/navigation-hijack attributes * (`formaction`, `action`, `target`), namespaced attributes like * `xlink:href`, and anything else not explicitly known to be safe. */ if (!isAttributeAllowed(lowerName, allowedAttributes, allowedAttributePrefixes)) { element.removeAttribute(attributeName); continue; } // clean up any allowed attribs // that attempt to do any JS funny-business const attributeValue = attribute.value; if (attributeValue == null) { continue; } /** * Scrub dangerous schemes from the value. The value is normalized first * (whitespace and ASCII control characters removed) so entity-obfuscated * payloads such as `java&#9;script:`, which the parser decodes to * `java\tscript:`, are still caught. Normalizing the value also covers * namespaced attributes, where the previous `element[attributeName]` * property reflection returned `undefined` and let them slip through. */ const normalizedValue = attributeValue.replace(controlCharactersAndWhitespace, '').toLowerCase(); // Script schemes are never allowed, on any attribute. if (normalizedValue.includes('javascript:') || normalizedValue.includes('vbscript:')) { element.removeAttribute(attributeName); continue; } /** * For URL-bearing attributes, allow `data:` URIs only for raster * images. Document-bearing types (`text/html`, `image/svg+xml`, * `application/*`, etc.) can carry markup or script when navigated to * or rendered, so they are stripped, while safe image types are kept so * inline images keep working. */ if (urlAttributes.includes(lowerName) && normalizedValue.startsWith('data:') && !safeDataImageUri.test(normalizedValue)) { element.removeAttribute(attributeName); } } /** * Sanitize any nested children */ const childElements = getElementChildren(element); /* eslint-disable-next-line */ for (let i = 0; i < childElements.length; i++) { sanitizeElement(childElements[i], allowedAttributes, allowedAttributePrefixes); } }; /** * IE doesn't always support .children * so we revert to .childNodes instead */ // TODO(FW-2832): type const getElementChildren = (el) => { return el.children != null ? el.children : el.childNodes; }; const isSanitizerEnabled = () => { const win = window; const config = win?.Ionic?.config; if (config) { if (config.get) { return config.get('sanitizerEnabled', true); } else { return config.sanitizerEnabled === true || config.sanitizerEnabled === undefined; } } return true; }; /** * Mirror known custom-element DOM properties onto attributes so they * survive `cloneNode`. Call this on a DOM subtree before cloning it for * rendering elsewhere (e.g. cloning slotted option content into an * overlay). * * Only sets the attribute when the property holds a non-empty string * and the attribute isn't already present, so existing attributes * take precedence. * * @param root - The root element whose subtree (and itself) will be * inspected. */ const reflectPropertiesToAttributes = (root) => { const candidates = []; if (root.tagName in elementPropsToReflect) { candidates.push(root); } for (const tagName of Object.keys(elementPropsToReflect)) { candidates.push(...Array.from(root.querySelectorAll(tagName.toLowerCase()))); } for (const el of candidates) { if (!(el.tagName in elementPropsToReflect)) { continue; } const props = elementPropsToReflect[el.tagName]; for (const prop of props) { const value = el[prop]; if (typeof value === 'string' && value.length > 0 && !el.hasAttribute(prop)) { el.setAttribute(prop, value); } } } }; /** * Attribute allowlist for `sanitizeDOMString`. Intentionally narrow: this * path sanitizes developer HTML strings rendered via `innerHTML` by * `ion-toast`, `ion-loading`, the `ion-alert` message, * `ion-refresher-content`, and `ion-infinite-scroll-content`, so the list * is kept to the minimum those have always needed. Broadening it here would * change sanitization output for all of those consumers, so the wider * rich-content allowlist below is deliberately scoped to `sanitizeDOMTree` * instead. */ const domStringAllowedAttributes = ['class', 'id', 'href', 'src', 'name', 'slot']; /** * Attribute allowlist for `sanitizeDOMTree` (the select rich-content path). * Covers global HTML attributes, the Ionic component presentational props * that cloned rich content (e.g. `ion-select-option` markup) relies on, and * the inert SVG presentation attributes used by inline icons. * * `aria-*` and `data-*` are allowed separately by prefix (refer * `richContentAllowedAttributePrefixes`) since they are inert and not worth * enumerating. URL-bearing names (`href`, `src`) are allowed here, but * their values are still scrubbed for script schemes in `sanitizeElement`. * * Notably absent: `style`, event handlers (`on*`), and the * form/navigation-hijack attributes (`formaction`, `action`, `target`), * which are therefore stripped. */ const richContentAllowedAttributes = [ // Global / structural 'class', 'id', 'slot', 'name', 'title', 'alt', 'lang', 'dir', 'role', 'type', 'value', 'disabled', 'width', 'height', 'href', 'src', // Ionic component presentational props 'color', 'size', 'shape', 'fill', 'expand', 'mode', 'theme', 'icon', 'label', 'label-placement', 'justify', 'inset', 'lines', 'ios', 'md', // SVG presentation attributes (compared lowercased, e.g. `viewBox`) 'xmlns', 'viewbox', 'preserveaspectratio', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'stroke-dasharray', 'fill-rule', 'fill-opacity', 'clip-rule', 'd', 'points', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'transform', 'opacity', ]; /** * Attribute-name prefixes that are always safe to keep in the rich-content * path. `aria-*` and `data-*` attributes cannot execute script or load * resources, so they are allowed wholesale rather than enumerated by name. */ const richContentAllowedAttributePrefixes = ['aria-', 'data-']; /** * Whether an attribute name (already lowercased) is safe to keep, by exact * match or by an allowed prefix. */ const isAttributeAllowed = (lowerName, allowedAttributes, allowedAttributePrefixes) => { if (allowedAttributes.includes(lowerName)) { return true; } return allowedAttributePrefixes.some((prefix) => lowerName.startsWith(prefix)); }; /** * Matches ASCII control characters and whitespace (including the * non-breaking space). Used to normalize attribute values before matching * a URL scheme so entity-obfuscated payloads such as `java&#9;script:` * (decoded by the parser to `java\tscript:`) can't smuggle a scheme past * the check. */ // eslint-disable-next-line no-control-regex -- matching control characters is the point const controlCharactersAndWhitespace = /[\u0000-\u0020\u007f-\u00a0]/g; /** * Attributes whose values are URLs. Their values are scheme-checked in * `sanitizeElement` (e.g. `data:` filtering) beyond the script-scheme scrub * applied to every attribute. */ const urlAttributes = ['href', 'src']; /** * Matches a `data:` URI for a raster image type that cannot carry script. * `image/svg+xml` is deliberately excluded (SVG can execute script), as are * document types like `text/html` and `application/*`. The value is already * lowercased and whitespace-stripped before this is tested. */ const safeDataImageUri = /^data:image\/(?:png|jpe?g|gif|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)[;,]/; /** * Tags removed entirely (with their subtree) before attribute sanitization. * Exported so tests can assert the set without hardcoding it. */ const blockedTags = ['script', 'style', 'iframe', 'meta', 'link', 'object', 'embed', 'base']; /** * Properties on custom elements that frameworks (Vue, Angular) often * set as DOM properties rather than attributes. `cloneNode` only copies * attributes, so these values are lost when slotted content is cloned * into an overlay. For each known custom element, we mirror the listed * properties onto attributes so the cloned copy still has the data it * needs to render. * * Keyed by uppercased tagName so the lookup matches `Element.tagName`. */ const elementPropsToReflect = { 'ION-ICON': ['icon', 'name', 'src', 'ios', 'md'], }; class IonicSafeString { constructor(value) { this.value = value; } } exports.IonicSafeString = IonicSafeString; exports.blockedTags = blockedTags; exports.reflectPropertiesToAttributes = reflectPropertiesToAttributes; exports.sanitizeDOMString = sanitizeDOMString; exports.sanitizeDOMTree = sanitizeDOMTree;