3 * - background:url(
4 * - background:url(
5 * - background:url(
8 const CSS_URL = '((url|image-set)(\\(|&(#40|#x00028|lpar);))';
9 const REGEXP_URL_ATTR = new RegExp(CSS_URL, 'gi');
11 const REGEXP_HEIGHT_PERCENTAGE = /((?:min-|max-|line-)?height)\s*:\s*([\d.,]+%)/gi;
12 const REGEXP_POSITION_ABSOLUTE = /position\s*:\s*absolute/gi;
13 const REGEXP_MEDIA_DARK_STYLE_2 = /Color-scheme/gi;
15 const HTML_ESCAPES: [search: string, replace: string][] = [
23 const HTML_UNESCAPES: [search: string, replace: string][] = HTML_ESCAPES.map(([a, b]) => [b, a]);
25 export const escape = (string: string) => {
26 HTML_ESCAPES.forEach(([search, replace]) => {
27 string = string.replaceAll(search, replace);
33 export const unescape = (string: string) => {
34 HTML_UNESCAPES.forEach(([search, replace]) => {
35 string = string.replaceAll(search, replace);
42 * Unescape a string in hex or octal encoding.
43 * See https://www.w3.org/International/questions/qa-escapes#css_other for all possible cases.
45 export const unescapeCSSEncoding = (str: string) => {
46 // Regexp declared inside the function to reset its state (because of the global flag).
47 // cf https://stackoverflow.com/questions/1520800/why-does-a-regexp-with-global-flag-give-wrong-results
48 const UNESCAPE_CSS_ESCAPES_REGEX = /\\([0-9A-Fa-f]{1,6}) ?/g;
49 const UNESCAPE_HTML_DEC_REGEX = /&#(\d+)(;|(?=[^\d;]))/g;
50 const UNESCAPE_HTML_HEX_REGEX = /&#x([0-9A-Fa-f]+)(;|(?=[^\d;]))/g;
51 const OTHER_ESC = /\\(.)/g;
53 const handleEscape = (radix: number) => (ignored: any, val: string) => {
55 return String.fromCodePoint(Number.parseInt(val, radix));
57 // Unescape regexps have some limitations, for those rare situations, fromCodePoint can throw
58 // One real found is: `font-family:\2018Calibri`
64 * basic unescaped named sequences: & etcetera, lodash does not support a lot, but that is not a problem for our case.
65 * Actually handling all escaped sequences would mean keeping track of a very large and ever growing amount of named sequences
67 const namedUnescaped = unescape(str);
68 // lodash doesn't unescape   or   sequences, we have to do this manually:
69 const decUnescaped = namedUnescaped.replace(UNESCAPE_HTML_DEC_REGEX, handleEscape(10));
70 const hexUnescaped = decUnescaped.replace(UNESCAPE_HTML_HEX_REGEX, handleEscape(16));
71 // unescape css backslash sequences
72 const strUnescapedHex = hexUnescaped.replace(UNESCAPE_CSS_ESCAPES_REGEX, handleEscape(16));
74 return strUnescapedHex.replace(OTHER_ESC, (_, char) => char);
78 * Input can be escaped multiple times to escape replacement while still works
79 * Best solution I found is to escape recursively
80 * This is done 5 times maximum. If there are too much escape, we consider the string
81 * "invalid" and we prefer to return an empty string
82 * @argument str style to unescape
83 * @augments stop extra security to prevent infinite loop
85 export const recurringUnescapeCSSEncoding = (str: string, stop = 5): string => {
86 const escaped = unescapeCSSEncoding(str);
87 if (escaped === str) {
89 } else if (stop === 0) {
92 return recurringUnescapeCSSEncoding(escaped, stop - 1);
97 * Escape some WTF from the CSSParser, cf spec files
98 * @param {String} style
101 export const escapeURLinStyle = (style: string) => {
102 // handle the case where the value is html encoded, e.g.:
103 // background:url("https://i.imgur.com/WScAnHr.jpg")
105 const unescapedEncoding = recurringUnescapeCSSEncoding(style);
107 // If we cancelled the unescape encoding step because it was too long, we are returning an empty string.
108 // In that case we also need to return an empty string in this function, otherwise we will not escape correctly the content
109 if (unescapedEncoding === '') {
113 const escapeFlag = unescapedEncoding !== style;
115 const escapedStyle = unescapedEncoding.replace(/\\r/g, 'r').replace(REGEXP_URL_ATTR, 'proton-$2(');
117 if (escapedStyle === unescapedEncoding) {
118 // nothing escaped: just return input
122 return escapeFlag ? escape(escapedStyle) : escapedStyle;
125 export const escapeForbiddenStyle = (style: string): string => {
126 let parsedStyle = style
127 .replaceAll(REGEXP_POSITION_ABSOLUTE, 'position: relative')
128 .replaceAll(REGEXP_HEIGHT_PERCENTAGE, (rule, prop) => {
129 // Replace nothing in this case.
130 if (['line-height', 'max-height'].includes(prop)) {
134 return `${prop}: unset`;
136 // To replace if we support dark styles in the future.
137 // Disable the Color-scheme so that the message do not use dark mode, message always being displayed on a white bg today
138 .replaceAll(REGEXP_MEDIA_DARK_STYLE_2, 'proton-disabled-Color-scheme');
143 const HTML_ENTITIES_TO_REMOVE_CHAR_CODES: number[] = [
144 9, // Tab : 	 - 	 - 	
145 10, // New line : 
 - 
 -
146 173, // Soft hyphen : ­ - ­ - ­
147 8203, // Zero width space : ​ - ​ - ​ - ​ - ​ - ​ - ​
151 * Remove completely some HTML entities from a string
152 * @param {String} string
155 export const unescapeFromString = (string: string) => {
156 const toRemove = HTML_ENTITIES_TO_REMOVE_CHAR_CODES.map((charCode) => String.fromCharCode(charCode));
157 const regex = new RegExp(toRemove.join('|'), 'g');
159 return string.replace(regex, '');