From 8d977006328888bf7b6bb98afb686d51438bc102 Mon Sep 17 00:00:00 2001 From: martgil Date: Fri, 29 May 2026 16:13:03 +0800 Subject: [PATCH 1/2] fix: improve css validation --- extension/js/common/platform/xss.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extension/js/common/platform/xss.ts b/extension/js/common/platform/xss.ts index 9e5c5c0536c..9ad27b577f5 100644 --- a/extension/js/common/platform/xss.ts +++ b/extension/js/common/platform/xss.ts @@ -49,7 +49,7 @@ export class Xss { private static ADD_ATTR = ['email', 'page', 'addurltext', 'longid', 'index', 'target', 'fingerprint', 'cryptup-data']; private static FORBID_ATTR = ['background']; private static HREF_REGEX_CACHE: RegExp | undefined; - private static FORBID_CSS_STYLE = /z-index:[^;]+;|position:[^;]+;|background[^;]+;/g; + private static FORBID_CSS_STYLE = /z-index:[^;]+(?=;|$)|position:[^;]+(?=;|$)|background[^;]+(?=;|$)/gi; private static EMOJI_REGEX = /(?![*#0-9]+)[\p{Emoji}\p{Emoji_Modifier}\p{Emoji_Component}\p{Emoji_Modifier_Base}\p{Emoji_Presentation}]/gu; public static sanitizeRender = (selector: string | HTMLElement | JQuery, dirtyHtml: string) => { @@ -118,6 +118,7 @@ export class Xss { const style = node.getAttribute('style')?.toLowerCase(); if (style && (style.includes('url(') || style.includes('@import'))) { node.removeAttribute('style'); // don't want any leaks through css url() + return; // stop processing: do not re-add any part of this style attribute } // strip css styles that could use to overlap with the extension UI if (style && Xss.FORBID_CSS_STYLE.test(style)) { From a644697e55ed65832fc36224fc5e08ac0c556ae5 Mon Sep 17 00:00:00 2001 From: martgil Date: Mon, 1 Jun 2026 17:48:49 +0800 Subject: [PATCH 2/2] feat: strip usage of url() in css --- extension/js/common/platform/xss.ts | 43 ++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/extension/js/common/platform/xss.ts b/extension/js/common/platform/xss.ts index 9ad27b577f5..87415cba932 100644 --- a/extension/js/common/platform/xss.ts +++ b/extension/js/common/platform/xss.ts @@ -49,7 +49,8 @@ export class Xss { private static ADD_ATTR = ['email', 'page', 'addurltext', 'longid', 'index', 'target', 'fingerprint', 'cryptup-data']; private static FORBID_ATTR = ['background']; private static HREF_REGEX_CACHE: RegExp | undefined; - private static FORBID_CSS_STYLE = /z-index:[^;]+(?=;|$)|position:[^;]+(?=;|$)|background[^;]+(?=;|$)/gi; + private static FORBID_CSS_STYLE = + /z-index:[^;]+(?=;|$)|position:[^;]+(?=;|$)|background[^;]+(?=;|$)|display:\s*none|visibility:\s*hidden|opacity:\s*0(?:\.\d+)?|transform:[^;]+|clip(?:-path)?:[^;]+|margin(?:-top|-right|-bottom|-left)?:[^;]+|padding(?:-top|-right|-bottom|-left)?:[^;]+|border(?:-top|-right|-bottom|-left|-width|-style|-color)?:[^;]+|top:[^;]+|left:[^;]+|right:[^;]+|bottom:[^;]+|filter:[^;]+|pointer-events:\s*none|font-size:\s*0(?:px|em|rem)?|line-height:\s*0(?:px|em|rem)?|width:\s*0(?:px)?|height:\s*0(?:px)?|text-indent:\s*-\d/gi; private static EMOJI_REGEX = /(?![*#0-9]+)[\p{Emoji}\p{Emoji_Modifier}\p{Emoji_Component}\p{Emoji_Modifier_Base}\p{Emoji_Presentation}]/gu; public static sanitizeRender = (selector: string | HTMLElement | JQuery, dirtyHtml: string) => { @@ -115,15 +116,16 @@ export class Xss { // Handle style attributes if (node.hasAttribute('style')) { // mitigation rather than a fix, which will involve updating CSP, see https://github.com/FlowCrypt/flowcrypt-browser/issues/2648 - const style = node.getAttribute('style')?.toLowerCase(); - if (style && (style.includes('url(') || style.includes('@import'))) { - node.removeAttribute('style'); // don't want any leaks through css url() - return; // stop processing: do not re-add any part of this style attribute - } - // strip css styles that could use to overlap with the extension UI + let style = node.getAttribute('style') || ''; + style = Xss.sanitizeCssStyle(style); if (style && Xss.FORBID_CSS_STYLE.test(style)) { const updatedStyle = style.replace(Xss.FORBID_CSS_STYLE, ''); node.setAttribute('style', updatedStyle); + } else if (style) { + // if style was modified but still present, update it + node.setAttribute('style', style); + } else { + node.removeAttribute('style'); } } @@ -275,6 +277,33 @@ export class Xss { } }; + /** + * Remove @import rules and any url(...) that would cause an out‑of‑band request. + * Only data: and cid: URLs are allowed. + */ + private static sanitizeCssStyle = (css: string): string => { + let cleaned = css.replace(/@import\s+[^;]*;?/gi, ''); + const urlRegex = /url\(\s*(["']?)(.*?)\1\s*\)/gi; + let match; + // eslint-disable-next-line no-null/no-null + while ((match = urlRegex.exec(cleaned)) !== null) { + const fullMatch = match[0]; + const url = match[2]; + // Only allow data: and cid: schemes + const isSafe = /^(data:|cid:)/i.test(url); + if (!isSafe) { + // Remove the unsafe url(...) token completely + cleaned = cleaned.replace(fullMatch, ''); + } + } + // Clean up leftover artifacts: empty declarations, double semicolons + cleaned = cleaned + .replace(/;\s*;/g, ';') + .replace(/^\s*;\s*/, '') + .trim(); + return cleaned; + }; + /** * allow href links that have same origin as our extension + cid + inline image */