diff --git a/extension/chrome/elements/compose-modules/compose-recipients-module.ts b/extension/chrome/elements/compose-modules/compose-recipients-module.ts index 472ce153595..c32253f1644 100644 --- a/extension/chrome/elements/compose-modules/compose-recipients-module.ts +++ b/extension/chrome/elements/compose-modules/compose-recipients-module.ts @@ -807,9 +807,13 @@ export class ComposeRecipientsModule extends ViewModule { ulHtml += `
  • `; if (contact.pgpLoading) { ulHtml += ''; + const emailForSelector = contact.email + .replace(/<\/?b>/g, '') + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); contact.pgpLoading .then(hasPgp => { - Xss.replaceElementDANGEROUSLY($(`[email="${contact.email}"] .loading-icon`)[0], this.getPgpIconHtml(hasPgp)); // xss-direct + Xss.replaceElementDANGEROUSLY($(`[email="${emailForSelector}"] .loading-icon`)[0], this.getPgpIconHtml(hasPgp)); // xss-direct }) .catch(() => { this.failedLookupEmails.push(contact.email); diff --git a/extension/js/common/browser/ui.ts b/extension/js/common/browser/ui.ts index f6ed7694ba4..f27ab7cad14 100644 --- a/extension/js/common/browser/ui.ts +++ b/extension/js/common/browser/ui.ts @@ -236,7 +236,7 @@ export class Ui { }, confirmWithCheckbox: async (label: string, html = ''): Promise => { const userResponsePromise = Ui.swal().fire({ - html, + html: Xss.htmlSanitize(html), input: 'checkbox', inputPlaceholder: label, allowOutsideClick: false, diff --git a/extension/js/common/inject.ts b/extension/js/common/inject.ts index fe6121b24d1..0b6e35799a1 100644 --- a/extension/js/common/inject.ts +++ b/extension/js/common/inject.ts @@ -121,7 +121,7 @@ export class Injector { if (this.webmailName === 'gmail') { // eslint-disable-next-line local-rules/standard-loops $('.' + (window as unknown as ContentScriptWindow).reloadable_class).each((i, reloadableEl) => { - $(reloadableEl).replaceWith($(reloadableEl)[0].outerHTML); // xss-reinsert - inserting code that was already present should not be dangerous + $(reloadableEl).replaceWith($(reloadableEl)[0].outerHTML); // xss-reinsert }); } else { window.location.reload(); diff --git a/extension/js/common/message-renderer.ts b/extension/js/common/message-renderer.ts index 5b7c1270beb..195e391d690 100644 --- a/extension/js/common/message-renderer.ts +++ b/extension/js/common/message-renderer.ts @@ -496,7 +496,7 @@ export class MessageRenderer { const fromString = GmailParser.findHeader(fullMsg, 'from'); const from = fromString ? Str.parseEmail(fromString) : undefined; const fromEmail = from?.email ?? ''; - const fromHtml = from?.name ? `${Xss.escape(from.name)} <${fromEmail}>` : fromEmail; + const fromHtml = from?.name ? `${Xss.escape(from.name)} <${Xss.escape(fromEmail)}>` : Xss.escape(fromEmail); /* eslint-disable @typescript-eslint/no-non-null-assertion */ const ccString = GmailParser.findHeader(fullMsg, 'cc') ? `Cc: ${Xss.escape(GmailParser.findHeader(fullMsg, 'cc')!)}
    ` @@ -518,7 +518,7 @@ export class MessageRenderer { From: ${fromHtml}
    - ${sentDateStr} + ${Xss.escape(sentDateStr ?? '')}
    To: ${Xss.escape(GmailParser.findHeader(fullMsg, 'to') ?? '')}
    @@ -780,7 +780,7 @@ export class MessageRenderer { if (fallbackToPlainText) { renderModule.renderAsRegularContent(Str.with(encryptedData)); } else { - renderModule.renderErr(Lang.pgpBlock.badFormat + '\n\n' + result.error.message, Str.with(encryptedData)); + renderModule.renderErr(Lang.pgpBlock.badFormat + '\n\n' + Xss.escape(result.error.message), Str.with(encryptedData)); } } else if (result.longids.needPassphrase.length) { renderModule.renderPassphraseNeeded(result.longids.needPassphrase); @@ -799,16 +799,19 @@ export class MessageRenderer { renderModule.renderErr(Lang.pgpBlock.pwdMsgAskSenderUsePubkey, undefined); } else if (result.error.type === DecryptErrTypes.noMdc) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - renderModule.renderErr(result.error.message, result.content!.toUtfStr()); // missing mdc - only render the result after user confirmation + renderModule.renderErr(Xss.escape(result.error.message), result.content!.toUtfStr()); // missing mdc - only render the result after user confirmation } else if (result.error) { - renderModule.renderErr(`${Lang.pgpBlock.cantOpen}\n\n${result.error.type}: ${result.error.message}`, Str.with(encryptedData)); + renderModule.renderErr( + `${Lang.pgpBlock.cantOpen}\n\n${Xss.escape(result.error.type)}: ${Xss.escape(result.error.message)}`, + Str.with(encryptedData) + ); } else { // should generally not happen renderModule.renderErr( Lang.pgpBlock.cantOpen + Lang.general.writeMeToFixIt(await isCustomerUrlFesUsed(this.acctEmail)) + '\n\nDiagnostic info: "' + - JSON.stringify(result) + + Xss.escape(JSON.stringify(result)) + '"', Str.with(encryptedData) ); diff --git a/extension/js/common/platform/xss.ts b/extension/js/common/platform/xss.ts index ecda2a5e2ec..344aec98c7a 100644 --- a/extension/js/common/platform/xss.ts +++ b/extension/js/common/platform/xss.ts @@ -47,10 +47,34 @@ export class Xss { 'col', ]; private static ADD_ATTR = ['email', 'page', 'addurltext', 'longid', 'index', 'target', 'fingerprint', 'cryptup-data']; - private static FORBID_ATTR = ['background', 'srcset']; + private static FORBID_ATTR = ['background', 'srcset', 'ping']; + private static FORBID_TAGS = [ + 'script', + 'noscript', + 'style', + 'link', + 'meta', + 'base', + 'title', + 'head', + 'html', + 'body', + 'template', + 'iframe', + 'object', + 'embed', + 'math', + 'form', + 'textarea', + 'menu', + 'dialog', + 'video', + 'audio', + 'source', + 'canvas', + ]; private static HREF_REGEX_CACHE: RegExp | undefined; private static EMOJI_REGEX = /(?![*#0-9]+)[\p{Emoji}\p{Emoji_Modifier}\p{Emoji_Component}\p{Emoji_Modifier_Base}\p{Emoji_Presentation}]/gu; - /* eslint-disable @typescript-eslint/naming-convention */ private static readonly ALLOWED_EMAIL_CSS_PROPERTIES = new Set([ // Colors 'color', @@ -120,7 +144,7 @@ export class Xss { 'direction', 'unicode-bidi', ]); - /* eslint-enable @typescript-eslint/naming-convention */ + private static readonly DANGEROUS_CSS_PROPERTIES = new Set(['z-index', 'pointer-events', 'transform', 'filter', 'clip-path', 'clip']); // xss-none public static sanitizeRender = (selector: string | HTMLElement | JQuery, dirtyHtml: string) => { // browser-only (not on node) @@ -151,14 +175,34 @@ export class Xss { */ public static htmlSanitize = (dirtyHtml: string, tagCheck = false): string => { Xss.throwIfNotSupported(); + const purgeStyleHook = (node: Node) => { + if (!(node instanceof Element)) { + return; + } + if (node.hasAttribute('style')) { + const style = Xss.purgeDangerousCss(node.getAttribute('style') || ''); // xss-none + if (style) { + node.setAttribute('style', style); + } else { + node.removeAttribute('style'); + } + } + if (node.tagName === 'A' && (node.getAttribute('target') || '').toLowerCase().includes('_blank')) { + node.setAttribute('rel', 'noopener noreferrer'); + } + }; + DOMPurify.addHook('afterSanitizeAttributes', purgeStyleHook); /* eslint-disable @typescript-eslint/naming-convention */ - return DOMPurify.sanitize(dirtyHtml, { + const cleanHtml = DOMPurify.sanitize(dirtyHtml, { ADD_ATTR: Xss.ADD_ATTR, FORBID_ATTR: Xss.FORBID_ATTR, + FORBID_TAGS: Xss.FORBID_TAGS, ...(tagCheck && { ALLOWED_TAGS: Xss.ALLOWED_HTML_TAGS }), ALLOWED_URI_REGEXP: Xss.sanitizeHrefRegexp(), }); /* eslint-enable @typescript-eslint/naming-convention */ + DOMPurify.removeHook('afterSanitizeAttributes', purgeStyleHook); + return cleanHtml; }; /** @@ -330,13 +374,13 @@ export class Xss { }; // prettier-ignore - public static replaceElementDANGEROUSLY = (el: Element, safeHtml: string) => { // xss-dangerous-function - must pass a sanitized value - el.outerHTML = safeHtml; // xss-dangerous-function - must pass a sanitized value + public static replaceElementDANGEROUSLY = (el: Element, safeHtml: string) => { // xss-dangerous-function + el.outerHTML = safeHtml; // xss-dangerous-function }; // prettier-ignore - public static setElementContentDANGEROUSLY = (el: Element, safeHtml: string) => { // xss-dangerous-function - must pass a sanitized value - el.innerHTML = safeHtml; // xss-dangerous-function - must pass a sanitized value + public static setElementContentDANGEROUSLY = (el: Element, safeHtml: string) => { // xss-dangerous-function + el.innerHTML = safeHtml; // xss-dangerous-function }; private static throwIfNotSupported = () => { @@ -365,6 +409,30 @@ export class Xss { return style.cssText; }; + // prettier-ignore + private static purgeDangerousCss = (css: string): string => { // xss-none + if (!css || typeof document === 'undefined') { + return css; + } + const style = document.createElement('span').style; + style.cssText = css; + for (const property of Array.from(style)) { + const lower = property.toLowerCase(); + const value = style.getPropertyValue(property).trim().toLowerCase(); + if (lower === 'position' && (['fixed', 'absolute', 'sticky'].includes(value) || value.includes('var('))) { + style.removeProperty(property); + } else if ( + this.DANGEROUS_CSS_PROPERTIES.has(lower) || // xss-none + lower === 'backdrop-filter' || + lower.startsWith('mask') || + lower.startsWith('-webkit-mask') + ) { + style.removeProperty(property); + } + } + return style.cssText; + }; + /** * allow href links that have same origin as our extension + cid + inline image */ diff --git a/extension/js/content_scripts/webmail/generic/setup-webmail-content-script.ts b/extension/js/content_scripts/webmail/generic/setup-webmail-content-script.ts index a9ab961fff2..0bf18afaf85 100644 --- a/extension/js/content_scripts/webmail/generic/setup-webmail-content-script.ts +++ b/extension/js/content_scripts/webmail/generic/setup-webmail-content-script.ts @@ -504,7 +504,7 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi $('.' + win.destroyable_class).remove(); // eslint-disable-next-line local-rules/standard-loops $('.' + win.reloadable_class).each((i, reloadableEl) => { - $(reloadableEl).replaceWith($(reloadableEl)[0].outerHTML); // xss-reinsert - inserting code that was already present should not be dangerous + $(reloadableEl).replaceWith($(reloadableEl)[0].outerHTML); // xss-reinsert }); wasDestroyed = true; })(); diff --git a/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts b/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts index f083808623d..80c9c22e6a9 100644 --- a/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts +++ b/extension/js/content_scripts/webmail/gmail/gmail-element-replacer.ts @@ -191,7 +191,7 @@ export class GmailElementReplacer extends WebmailElementReplacer { let currentEmailContainer = $(emailContainer); if (!this.isPlainTextOrHtml(blocksFromEmailContainer)) { const { renderedXssSafe: renderedFromEmailContainerXssSafe } = this.messageRenderer.renderMsg({ blocks: blocksFromEmailContainer }, false); // xss-safe-value - currentEmailContainer = GmailLoaderContext.updateMsgBodyEl_DANGEROUSLY(emailContainer, 'set', renderedFromEmailContainerXssSafe); // xss-safe-factory: replace_blocks is XSS safe + currentEmailContainer = GmailLoaderContext.updateMsgBodyEl_DANGEROUSLY(emailContainer, 'set', renderedFromEmailContainerXssSafe); // xss-safe-factory } let blocks: MsgBlock[] = []; @@ -238,7 +238,7 @@ export class GmailElementReplacer extends WebmailElementReplacer { if (this.debug) { console.debug('replaceArmoredBlocks() for of emailsContainingPgpBlock -> emailContainer replacing'); } - GmailLoaderContext.updateMsgBodyEl_DANGEROUSLY(currentEmailContainer, 'set', renderedXssSafe); // xss-safe-factory: replace_blocks is XSS safe + GmailLoaderContext.updateMsgBodyEl_DANGEROUSLY(currentEmailContainer, 'set', renderedXssSafe); // xss-safe-factory if (this.debug) { console.debug('replaceArmoredBlocks() for of emailsContainingPgpBlock -> emailContainer replaced'); } @@ -946,7 +946,7 @@ export class GmailElementReplacer extends WebmailElementReplacer { $(this.sel.draftsList).append(offlineDraftsContainer); // xss-safe-factory for (const draftId of draftIdsSortedByTimestamp) { const draft = offlineComposeDrafts[draftId]; - const draftLink = $(`${new Date(draft.timestamp).toLocaleString()}`); + const draftLink = $(`${Xss.escape(new Date(draft.timestamp).toLocaleString())}`); draftLink.on('click', event => { event.preventDefault(); this.injector.openComposeWin(draftId); diff --git a/extension/js/content_scripts/webmail/gmail/gmail-loader-context.ts b/extension/js/content_scripts/webmail/gmail/gmail-loader-context.ts index 403011f67c2..ee4a2c6b6d2 100644 --- a/extension/js/content_scripts/webmail/gmail/gmail-loader-context.ts +++ b/extension/js/content_scripts/webmail/gmail/gmail-loader-context.ts @@ -44,7 +44,7 @@ export class GmailLoaderContext implements LoaderContextInterface { } else if (method === 'append') { if (replace) { const parent = msgBody.parent(); - const existingHtml = msgBody.html() || ''; // xss-direct - preserving existing Gmail-rendered content + const existingHtml = msgBody.html() || ''; // xss-direct msgBody.replaceWith(this.wrapMsgBodyEl(existingHtml + newHtmlContent_MUST_BE_XSS_SAFE)); // xss-safe-value this.ensureHasParentNode(msgBody); // Gmail is using msgBody.parentNode (#2271) return parent.find('.message_inner_body'); // need to return new selector - old element was replaced