refactor(core): move sanitization into core (#22540)
This is in preparation of having Ivy have sanitization inline. PR Close #22540
This commit is contained in:

committed by
Kara Erickson

parent
065bcc5aad
commit
538f1d980f
@ -6,14 +6,10 @@
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {Inject, Injectable, Sanitizer, SecurityContext} from '@angular/core';
|
||||
import {Inject, Injectable, Sanitizer, SecurityContext, ɵsanitizeHtml as sanitizeHtml, ɵsanitizeStyle as sanitizeStyle, ɵsanitizeUrl as sanitizeUrl} from '@angular/core';
|
||||
|
||||
import {DOCUMENT} from '../dom/dom_tokens';
|
||||
|
||||
import {sanitizeHtml} from './html_sanitizer';
|
||||
import {sanitizeStyle} from './style_sanitizer';
|
||||
import {sanitizeUrl} from './url_sanitizer';
|
||||
|
||||
export {SecurityContext};
|
||||
|
||||
|
||||
|
@ -1,258 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {isDevMode} from '@angular/core';
|
||||
|
||||
import {DomAdapter, getDOM} from '../dom/dom_adapter';
|
||||
|
||||
import {InertBodyHelper} from './inert_body';
|
||||
import {sanitizeSrcset, sanitizeUrl} from './url_sanitizer';
|
||||
|
||||
function tagSet(tags: string): {[k: string]: boolean} {
|
||||
const res: {[k: string]: boolean} = {};
|
||||
for (const t of tags.split(',')) res[t] = true;
|
||||
return res;
|
||||
}
|
||||
|
||||
function merge(...sets: {[k: string]: boolean}[]): {[k: string]: boolean} {
|
||||
const res: {[k: string]: boolean} = {};
|
||||
for (const s of sets) {
|
||||
for (const v in s) {
|
||||
if (s.hasOwnProperty(v)) res[v] = true;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Good source of info about elements and attributes
|
||||
// http://dev.w3.org/html5/spec/Overview.html#semantics
|
||||
// http://simon.html5.org/html-elements
|
||||
|
||||
// Safe Void Elements - HTML5
|
||||
// http://dev.w3.org/html5/spec/Overview.html#void-elements
|
||||
const VOID_ELEMENTS = tagSet('area,br,col,hr,img,wbr');
|
||||
|
||||
// Elements that you can, intentionally, leave open (and which close themselves)
|
||||
// http://dev.w3.org/html5/spec/Overview.html#optional-tags
|
||||
const OPTIONAL_END_TAG_BLOCK_ELEMENTS = tagSet('colgroup,dd,dt,li,p,tbody,td,tfoot,th,thead,tr');
|
||||
const OPTIONAL_END_TAG_INLINE_ELEMENTS = tagSet('rp,rt');
|
||||
const OPTIONAL_END_TAG_ELEMENTS =
|
||||
merge(OPTIONAL_END_TAG_INLINE_ELEMENTS, OPTIONAL_END_TAG_BLOCK_ELEMENTS);
|
||||
|
||||
// Safe Block Elements - HTML5
|
||||
const BLOCK_ELEMENTS = merge(
|
||||
OPTIONAL_END_TAG_BLOCK_ELEMENTS,
|
||||
tagSet(
|
||||
'address,article,' +
|
||||
'aside,blockquote,caption,center,del,details,dialog,dir,div,dl,figure,figcaption,footer,h1,h2,h3,h4,h5,' +
|
||||
'h6,header,hgroup,hr,ins,main,map,menu,nav,ol,pre,section,summary,table,ul'));
|
||||
|
||||
// Inline Elements - HTML5
|
||||
const INLINE_ELEMENTS = merge(
|
||||
OPTIONAL_END_TAG_INLINE_ELEMENTS,
|
||||
tagSet(
|
||||
'a,abbr,acronym,audio,b,' +
|
||||
'bdi,bdo,big,br,cite,code,del,dfn,em,font,i,img,ins,kbd,label,map,mark,picture,q,ruby,rp,rt,s,' +
|
||||
'samp,small,source,span,strike,strong,sub,sup,time,track,tt,u,var,video'));
|
||||
|
||||
const VALID_ELEMENTS =
|
||||
merge(VOID_ELEMENTS, BLOCK_ELEMENTS, INLINE_ELEMENTS, OPTIONAL_END_TAG_ELEMENTS);
|
||||
|
||||
// Attributes that have href and hence need to be sanitized
|
||||
const URI_ATTRS = tagSet('background,cite,href,itemtype,longdesc,poster,src,xlink:href');
|
||||
|
||||
// Attributes that have special href set hence need to be sanitized
|
||||
const SRCSET_ATTRS = tagSet('srcset');
|
||||
|
||||
const HTML_ATTRS = tagSet(
|
||||
'abbr,accesskey,align,alt,autoplay,axis,bgcolor,border,cellpadding,cellspacing,class,clear,color,cols,colspan,' +
|
||||
'compact,controls,coords,datetime,default,dir,download,face,headers,height,hidden,hreflang,hspace,' +
|
||||
'ismap,itemscope,itemprop,kind,label,lang,language,loop,media,muted,nohref,nowrap,open,preload,rel,rev,role,rows,rowspan,rules,' +
|
||||
'scope,scrolling,shape,size,sizes,span,srclang,start,summary,tabindex,target,title,translate,type,usemap,' +
|
||||
'valign,value,vspace,width');
|
||||
|
||||
// NB: This currently consciously doesn't support SVG. SVG sanitization has had several security
|
||||
// issues in the past, so it seems safer to leave it out if possible. If support for binding SVG via
|
||||
// innerHTML is required, SVG attributes should be added here.
|
||||
|
||||
// NB: Sanitization does not allow <form> elements or other active elements (<button> etc). Those
|
||||
// can be sanitized, but they increase security surface area without a legitimate use case, so they
|
||||
// are left out here.
|
||||
|
||||
const VALID_ATTRS = merge(URI_ATTRS, SRCSET_ATTRS, HTML_ATTRS);
|
||||
|
||||
/**
|
||||
* SanitizingHtmlSerializer serializes a DOM fragment, stripping out any unsafe elements and unsafe
|
||||
* attributes.
|
||||
*/
|
||||
class SanitizingHtmlSerializer {
|
||||
// Explicitly track if something was stripped, to avoid accidentally warning of sanitization just
|
||||
// because characters were re-encoded.
|
||||
public sanitizedSomething = false;
|
||||
private buf: string[] = [];
|
||||
private DOM = getDOM();
|
||||
|
||||
sanitizeChildren(el: Element): string {
|
||||
// This cannot use a TreeWalker, as it has to run on Angular's various DOM adapters.
|
||||
// However this code never accesses properties off of `document` before deleting its contents
|
||||
// again, so it shouldn't be vulnerable to DOM clobbering.
|
||||
let current: Node = this.DOM.firstChild(el) !;
|
||||
while (current) {
|
||||
if (this.DOM.isElementNode(current)) {
|
||||
this.startElement(current as Element);
|
||||
} else if (this.DOM.isTextNode(current)) {
|
||||
this.chars(this.DOM.nodeValue(current) !);
|
||||
} else {
|
||||
// Strip non-element, non-text nodes.
|
||||
this.sanitizedSomething = true;
|
||||
}
|
||||
if (this.DOM.firstChild(current)) {
|
||||
current = this.DOM.firstChild(current) !;
|
||||
continue;
|
||||
}
|
||||
while (current) {
|
||||
// Leaving the element. Walk up and to the right, closing tags as we go.
|
||||
if (this.DOM.isElementNode(current)) {
|
||||
this.endElement(current as Element);
|
||||
}
|
||||
|
||||
let next = this.checkClobberedElement(current, this.DOM.nextSibling(current) !);
|
||||
|
||||
if (next) {
|
||||
current = next;
|
||||
break;
|
||||
}
|
||||
|
||||
current = this.checkClobberedElement(current, this.DOM.parentElement(current) !);
|
||||
}
|
||||
}
|
||||
return this.buf.join('');
|
||||
}
|
||||
|
||||
private startElement(element: Element) {
|
||||
const tagName = this.DOM.nodeName(element).toLowerCase();
|
||||
if (!VALID_ELEMENTS.hasOwnProperty(tagName)) {
|
||||
this.sanitizedSomething = true;
|
||||
return;
|
||||
}
|
||||
this.buf.push('<');
|
||||
this.buf.push(tagName);
|
||||
this.DOM.attributeMap(element).forEach((value: string, attrName: string) => {
|
||||
const lower = attrName.toLowerCase();
|
||||
if (!VALID_ATTRS.hasOwnProperty(lower)) {
|
||||
this.sanitizedSomething = true;
|
||||
return;
|
||||
}
|
||||
// TODO(martinprobst): Special case image URIs for data:image/...
|
||||
if (URI_ATTRS[lower]) value = sanitizeUrl(value);
|
||||
if (SRCSET_ATTRS[lower]) value = sanitizeSrcset(value);
|
||||
this.buf.push(' ');
|
||||
this.buf.push(attrName);
|
||||
this.buf.push('="');
|
||||
this.buf.push(encodeEntities(value));
|
||||
this.buf.push('"');
|
||||
});
|
||||
this.buf.push('>');
|
||||
}
|
||||
|
||||
private endElement(current: Element) {
|
||||
const tagName = this.DOM.nodeName(current).toLowerCase();
|
||||
if (VALID_ELEMENTS.hasOwnProperty(tagName) && !VOID_ELEMENTS.hasOwnProperty(tagName)) {
|
||||
this.buf.push('</');
|
||||
this.buf.push(tagName);
|
||||
this.buf.push('>');
|
||||
}
|
||||
}
|
||||
|
||||
private chars(chars: string) { this.buf.push(encodeEntities(chars)); }
|
||||
|
||||
checkClobberedElement(node: Node, nextNode: Node): Node {
|
||||
if (nextNode && this.DOM.contains(node, nextNode)) {
|
||||
throw new Error(
|
||||
`Failed to sanitize html because the element is clobbered: ${this.DOM.getOuterHTML(node)}`);
|
||||
}
|
||||
return nextNode;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular Expressions for parsing tags and attributes
|
||||
const SURROGATE_PAIR_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
|
||||
// ! to ~ is the ASCII range.
|
||||
const NON_ALPHANUMERIC_REGEXP = /([^\#-~ |!])/g;
|
||||
|
||||
/**
|
||||
* Escapes all potentially dangerous characters, so that the
|
||||
* resulting string can be safely inserted into attribute or
|
||||
* element text.
|
||||
* @param value
|
||||
*/
|
||||
function encodeEntities(value: string) {
|
||||
return value.replace(/&/g, '&')
|
||||
.replace(
|
||||
SURROGATE_PAIR_REGEXP,
|
||||
function(match: string) {
|
||||
const hi = match.charCodeAt(0);
|
||||
const low = match.charCodeAt(1);
|
||||
return '&#' + (((hi - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000) + ';';
|
||||
})
|
||||
.replace(
|
||||
NON_ALPHANUMERIC_REGEXP,
|
||||
function(match: string) { return '&#' + match.charCodeAt(0) + ';'; })
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
let inertBodyHelper: InertBodyHelper;
|
||||
|
||||
/**
|
||||
* Sanitizes the given unsafe, untrusted HTML fragment, and returns HTML text that is safe to add to
|
||||
* the DOM in a browser environment.
|
||||
*/
|
||||
export function sanitizeHtml(defaultDoc: any, unsafeHtmlInput: string): string {
|
||||
const DOM = getDOM();
|
||||
let inertBodyElement: HTMLElement|null = null;
|
||||
try {
|
||||
inertBodyHelper = inertBodyHelper || new InertBodyHelper(defaultDoc, DOM);
|
||||
// Make sure unsafeHtml is actually a string (TypeScript types are not enforced at runtime).
|
||||
let unsafeHtml = unsafeHtmlInput ? String(unsafeHtmlInput) : '';
|
||||
inertBodyElement = inertBodyHelper.getInertBodyElement(unsafeHtml);
|
||||
|
||||
// mXSS protection. Repeatedly parse the document to make sure it stabilizes, so that a browser
|
||||
// trying to auto-correct incorrect HTML cannot cause formerly inert HTML to become dangerous.
|
||||
let mXSSAttempts = 5;
|
||||
let parsedHtml = unsafeHtml;
|
||||
|
||||
do {
|
||||
if (mXSSAttempts === 0) {
|
||||
throw new Error('Failed to sanitize html because the input is unstable');
|
||||
}
|
||||
mXSSAttempts--;
|
||||
|
||||
unsafeHtml = parsedHtml;
|
||||
parsedHtml = DOM.getInnerHTML(inertBodyElement);
|
||||
inertBodyElement = inertBodyHelper.getInertBodyElement(unsafeHtml);
|
||||
} while (unsafeHtml !== parsedHtml);
|
||||
|
||||
const sanitizer = new SanitizingHtmlSerializer();
|
||||
const safeHtml =
|
||||
sanitizer.sanitizeChildren(DOM.getTemplateContent(inertBodyElement) || inertBodyElement);
|
||||
if (isDevMode() && sanitizer.sanitizedSomething) {
|
||||
DOM.log('WARNING: sanitizing HTML stripped some content (see http://g.co/ng/security#xss).');
|
||||
}
|
||||
|
||||
return safeHtml;
|
||||
} finally {
|
||||
// In case anything goes wrong, clear out inertElement to reset the entire DOM structure.
|
||||
if (inertBodyElement) {
|
||||
const parent = DOM.getTemplateContent(inertBodyElement) || inertBodyElement;
|
||||
for (const child of DOM.childNodesAsList(parent)) {
|
||||
DOM.removeChild(parent, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,171 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {DomAdapter, getDOM} from '../dom/dom_adapter';
|
||||
|
||||
/**
|
||||
* This helper class is used to get hold of an inert tree of DOM elements containing dirty HTML
|
||||
* that needs sanitizing.
|
||||
* Depending upon browser support we must use one of three strategies for doing this.
|
||||
* Support: Safari 10.x -> XHR strategy
|
||||
* Support: Firefox -> DomParser strategy
|
||||
* Default: InertDocument strategy
|
||||
*/
|
||||
export class InertBodyHelper {
|
||||
private inertBodyElement: HTMLElement;
|
||||
|
||||
constructor(private defaultDoc: any, private DOM: DomAdapter) {
|
||||
const inertDocument = this.DOM.createHtmlDocument();
|
||||
this.inertBodyElement = inertDocument.body;
|
||||
|
||||
if (this.inertBodyElement == null) {
|
||||
// usually there should be only one body element in the document, but IE doesn't have any, so
|
||||
// we need to create one.
|
||||
const inertHtml = this.DOM.createElement('html', inertDocument);
|
||||
this.inertBodyElement = this.DOM.createElement('body', inertDocument);
|
||||
this.DOM.appendChild(inertHtml, this.inertBodyElement);
|
||||
this.DOM.appendChild(inertDocument, inertHtml);
|
||||
}
|
||||
|
||||
this.DOM.setInnerHTML(
|
||||
this.inertBodyElement, '<svg><g onload="this.parentNode.remove()"></g></svg>');
|
||||
if (this.inertBodyElement.querySelector && !this.inertBodyElement.querySelector('svg')) {
|
||||
// We just hit the Safari 10.1 bug - which allows JS to run inside the SVG G element
|
||||
// so use the XHR strategy.
|
||||
this.getInertBodyElement = this.getInertBodyElement_XHR;
|
||||
return;
|
||||
}
|
||||
|
||||
this.DOM.setInnerHTML(
|
||||
this.inertBodyElement, '<svg><p><style><img src="</style><img src=x onerror=alert(1)//">');
|
||||
if (this.inertBodyElement.querySelector && this.inertBodyElement.querySelector('svg img')) {
|
||||
// We just hit the Firefox bug - which prevents the inner img JS from being sanitized
|
||||
// so use the DOMParser strategy, if it is available.
|
||||
// If the DOMParser is not available then we are not in Firefox (Server/WebWorker?) so we
|
||||
// fall through to the default strategy below.
|
||||
if (isDOMParserAvailable()) {
|
||||
this.getInertBodyElement = this.getInertBodyElement_DOMParser;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// None of the bugs were hit so it is safe for us to use the default InertDocument strategy
|
||||
this.getInertBodyElement = this.getInertBodyElement_InertDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an inert DOM element containing DOM created from the dirty HTML string provided.
|
||||
* The implementation of this is determined in the constructor, when the class is instantiated.
|
||||
*/
|
||||
getInertBodyElement: (html: string) => HTMLElement | null;
|
||||
|
||||
/**
|
||||
* Use XHR to create and fill an inert body element (on Safari 10.1)
|
||||
* See
|
||||
* https://github.com/cure53/DOMPurify/blob/a992d3a75031cb8bb032e5ea8399ba972bdf9a65/src/purify.js#L439-L449
|
||||
*/
|
||||
private getInertBodyElement_XHR(html: string) {
|
||||
// We add these extra elements to ensure that the rest of the content is parsed as expected
|
||||
// e.g. leading whitespace is maintained and tags like `<meta>` do not get hoisted to the
|
||||
// `<head>` tag.
|
||||
html = '<body><remove></remove>' + html + '</body>';
|
||||
try {
|
||||
html = encodeURI(html);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.responseType = 'document';
|
||||
xhr.open('GET', 'data:text/html;charset=utf-8,' + html, false);
|
||||
xhr.send(null);
|
||||
const body: HTMLBodyElement = xhr.response.body;
|
||||
body.removeChild(body.firstChild !);
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use DOMParser to create and fill an inert body element (on Firefox)
|
||||
* See https://github.com/cure53/DOMPurify/releases/tag/0.6.7
|
||||
*
|
||||
*/
|
||||
private getInertBodyElement_DOMParser(html: string) {
|
||||
// We add these extra elements to ensure that the rest of the content is parsed as expected
|
||||
// e.g. leading whitespace is maintained and tags like `<meta>` do not get hoisted to the
|
||||
// `<head>` tag.
|
||||
html = '<body><remove></remove>' + html + '</body>';
|
||||
try {
|
||||
const body = new (window as any)
|
||||
.DOMParser()
|
||||
.parseFromString(html, 'text/html')
|
||||
.body as HTMLBodyElement;
|
||||
body.removeChild(body.firstChild !);
|
||||
return body;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use an HTML5 `template` element, if supported, or an inert body element created via
|
||||
* `createHtmlDocument` to create and fill an inert DOM element.
|
||||
* This is the default sane strategy to use if the browser does not require one of the specialised
|
||||
* strategies above.
|
||||
*/
|
||||
private getInertBodyElement_InertDocument(html: string) {
|
||||
// Prefer using <template> element if supported.
|
||||
const templateEl = this.DOM.createElement('template');
|
||||
if ('content' in templateEl) {
|
||||
this.DOM.setInnerHTML(templateEl, html);
|
||||
return templateEl;
|
||||
}
|
||||
|
||||
this.DOM.setInnerHTML(this.inertBodyElement, html);
|
||||
|
||||
// Support: IE 9-11 only
|
||||
// strip custom-namespaced attributes on IE<=11
|
||||
if (this.defaultDoc.documentMode) {
|
||||
this.stripCustomNsAttrs(this.inertBodyElement);
|
||||
}
|
||||
|
||||
return this.inertBodyElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* When IE9-11 comes across an unknown namespaced attribute e.g. 'xlink:foo' it adds 'xmlns:ns1'
|
||||
* attribute to declare ns1 namespace and prefixes the attribute with 'ns1' (e.g.
|
||||
* 'ns1:xlink:foo').
|
||||
*
|
||||
* This is undesirable since we don't want to allow any of these custom attributes. This method
|
||||
* strips them all.
|
||||
*/
|
||||
private stripCustomNsAttrs(el: Element) {
|
||||
this.DOM.attributeMap(el).forEach((_, attrName) => {
|
||||
if (attrName === 'xmlns:ns1' || attrName.indexOf('ns1:') === 0) {
|
||||
this.DOM.removeAttribute(el, attrName);
|
||||
}
|
||||
});
|
||||
for (const n of this.DOM.childNodesAsList(el)) {
|
||||
if (this.DOM.isElementNode(n)) this.stripCustomNsAttrs(n as Element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to determine whether the DOMParser exists in the global context.
|
||||
* The try-catch is because, on some browsers, trying to access this property
|
||||
* on window can actually throw an error.
|
||||
*
|
||||
* @suppress {uselessCode}
|
||||
*/
|
||||
function isDOMParserAvailable() {
|
||||
try {
|
||||
return !!(window as any).DOMParser;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
@ -1,106 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {isDevMode} from '@angular/core';
|
||||
|
||||
import {getDOM} from '../dom/dom_adapter';
|
||||
|
||||
import {sanitizeUrl} from './url_sanitizer';
|
||||
|
||||
|
||||
/**
|
||||
* Regular expression for safe style values.
|
||||
*
|
||||
* Quotes (" and ') are allowed, but a check must be done elsewhere to ensure they're balanced.
|
||||
*
|
||||
* ',' allows multiple values to be assigned to the same property (e.g. background-attachment or
|
||||
* font-family) and hence could allow multiple values to get injected, but that should pose no risk
|
||||
* of XSS.
|
||||
*
|
||||
* The function expression checks only for XSS safety, not for CSS validity.
|
||||
*
|
||||
* This regular expression was taken from the Closure sanitization library, and augmented for
|
||||
* transformation values.
|
||||
*/
|
||||
const VALUES = '[-,."\'%_!# a-zA-Z0-9]+';
|
||||
const TRANSFORMATION_FNS = '(?:matrix|translate|scale|rotate|skew|perspective)(?:X|Y|3d)?';
|
||||
const COLOR_FNS = '(?:rgb|hsl)a?';
|
||||
const GRADIENTS = '(?:repeating-)?(?:linear|radial)-gradient';
|
||||
const CSS3_FNS = '(?:calc|attr)';
|
||||
const FN_ARGS = '\\([-0-9.%, #a-zA-Z]+\\)';
|
||||
const SAFE_STYLE_VALUE = new RegExp(
|
||||
`^(${VALUES}|` +
|
||||
`(?:${TRANSFORMATION_FNS}|${COLOR_FNS}|${GRADIENTS}|${CSS3_FNS})` +
|
||||
`${FN_ARGS})$`,
|
||||
'g');
|
||||
|
||||
/**
|
||||
* Matches a `url(...)` value with an arbitrary argument as long as it does
|
||||
* not contain parentheses.
|
||||
*
|
||||
* The URL value still needs to be sanitized separately.
|
||||
*
|
||||
* `url(...)` values are a very common use case, e.g. for `background-image`. With carefully crafted
|
||||
* CSS style rules, it is possible to construct an information leak with `url` values in CSS, e.g.
|
||||
* by observing whether scroll bars are displayed, or character ranges used by a font face
|
||||
* definition.
|
||||
*
|
||||
* Angular only allows binding CSS values (as opposed to entire CSS rules), so it is unlikely that
|
||||
* binding a URL value without further cooperation from the page will cause an information leak, and
|
||||
* if so, it is just a leak, not a full blown XSS vulnerability.
|
||||
*
|
||||
* Given the common use case, low likelihood of attack vector, and low impact of an attack, this
|
||||
* code is permissive and allows URLs that sanitize otherwise.
|
||||
*/
|
||||
const URL_RE = /^url\(([^)]+)\)$/;
|
||||
|
||||
/**
|
||||
* Checks that quotes (" and ') are properly balanced inside a string. Assumes
|
||||
* that neither escape (\) nor any other character that could result in
|
||||
* breaking out of a string parsing context are allowed;
|
||||
* see http://www.w3.org/TR/css3-syntax/#string-token-diagram.
|
||||
*
|
||||
* This code was taken from the Closure sanitization library.
|
||||
*/
|
||||
function hasBalancedQuotes(value: string) {
|
||||
let outsideSingle = true;
|
||||
let outsideDouble = true;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const c = value.charAt(i);
|
||||
if (c === '\'' && outsideDouble) {
|
||||
outsideSingle = !outsideSingle;
|
||||
} else if (c === '"' && outsideSingle) {
|
||||
outsideDouble = !outsideDouble;
|
||||
}
|
||||
}
|
||||
return outsideSingle && outsideDouble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the given untrusted CSS style property value (i.e. not an entire object, just a single
|
||||
* value) and returns a value that is safe to use in a browser environment.
|
||||
*/
|
||||
export function sanitizeStyle(value: string): string {
|
||||
value = String(value).trim(); // Make sure it's actually a string.
|
||||
if (!value) return '';
|
||||
|
||||
// Single url(...) values are supported, but only for URLs that sanitize cleanly. See above for
|
||||
// reasoning behind this.
|
||||
const urlMatch = value.match(URL_RE);
|
||||
if ((urlMatch && sanitizeUrl(urlMatch[1]) === urlMatch[1]) ||
|
||||
value.match(SAFE_STYLE_VALUE) && hasBalancedQuotes(value)) {
|
||||
return value; // Safe style values.
|
||||
}
|
||||
|
||||
if (isDevMode()) {
|
||||
getDOM().log(
|
||||
`WARNING: sanitizing unsafe style value ${value} (see http://g.co/ng/security#xss).`);
|
||||
}
|
||||
|
||||
return 'unsafe';
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {isDevMode} from '@angular/core';
|
||||
|
||||
import {getDOM} from '../dom/dom_adapter';
|
||||
|
||||
|
||||
/**
|
||||
* A pattern that recognizes a commonly useful subset of URLs that are safe.
|
||||
*
|
||||
* This regular expression matches a subset of URLs that will not cause script
|
||||
* execution if used in URL context within a HTML document. Specifically, this
|
||||
* regular expression matches if (comment from here on and regex copied from
|
||||
* Soy's EscapingConventions):
|
||||
* (1) Either a protocol in a whitelist (http, https, mailto or ftp).
|
||||
* (2) or no protocol. A protocol must be followed by a colon. The below
|
||||
* allows that by allowing colons only after one of the characters [/?#].
|
||||
* A colon after a hash (#) must be in the fragment.
|
||||
* Otherwise, a colon after a (?) must be in a query.
|
||||
* Otherwise, a colon after a single solidus (/) must be in a path.
|
||||
* Otherwise, a colon after a double solidus (//) must be in the authority
|
||||
* (before port).
|
||||
*
|
||||
* The pattern disallows &, used in HTML entity declarations before
|
||||
* one of the characters in [/?#]. This disallows HTML entities used in the
|
||||
* protocol name, which should never happen, e.g. "http" for "http".
|
||||
* It also disallows HTML entities in the first path part of a relative path,
|
||||
* e.g. "foo<bar/baz". Our existing escaping functions should not produce
|
||||
* that. More importantly, it disallows masking of a colon,
|
||||
* e.g. "javascript:...".
|
||||
*
|
||||
* This regular expression was taken from the Closure sanitization library.
|
||||
*/
|
||||
const SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file):|[^&:/?#]*(?:[/?#]|$))/gi;
|
||||
|
||||
/* A pattern that matches safe srcset values */
|
||||
const SAFE_SRCSET_PATTERN = /^(?:(?:https?|file):|[^&:/?#]*(?:[/?#]|$))/gi;
|
||||
|
||||
/** A pattern that matches safe data URLs. Only matches image, video and audio types. */
|
||||
const DATA_URL_PATTERN =
|
||||
/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+\/]+=*$/i;
|
||||
|
||||
export function sanitizeUrl(url: string): string {
|
||||
url = String(url);
|
||||
if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url;
|
||||
|
||||
if (isDevMode()) {
|
||||
getDOM().log(`WARNING: sanitizing unsafe URL value ${url} (see http://g.co/ng/security#xss)`);
|
||||
}
|
||||
|
||||
return 'unsafe:' + url;
|
||||
}
|
||||
|
||||
export function sanitizeSrcset(srcset: string): string {
|
||||
srcset = String(srcset);
|
||||
return srcset.split(',').map((srcset) => sanitizeUrl(srcset.trim())).join(', ');
|
||||
}
|
Reference in New Issue
Block a user