From f8f8396bfcb1b27ce7c3bba520bcf242a6e5e509 Mon Sep 17 00:00:00 2001 From: martgil <46025304+martgil@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:25:08 +0800 Subject: [PATCH 1/2] feat: improve message parsing for signed message --- extension/js/common/core/mime.ts | 83 +++++++--- .../tests/browser-unit-tests/unit-Mime.js | 147 ++++++++++++++++++ 2 files changed, 209 insertions(+), 21 deletions(-) diff --git a/extension/js/common/core/mime.ts b/extension/js/common/core/mime.ts index 69f3b554682..41582429ead 100644 --- a/extension/js/common/core/mime.ts +++ b/extension/js/common/core/mime.ts @@ -60,6 +60,7 @@ export type MimeProccesedMsg = { type SendingType = 'to' | 'cc' | 'bcc'; export class Mime { + private static readonly MAX_SIGNED_CONTENT_DEPTH = 50; public static processBody(decoded: MessageBody): MsgBlock[] { const blocks: MsgBlock[] = []; if (decoded.text) { @@ -198,15 +199,15 @@ export class Mime { }; return await new Promise((resolve, reject) => { try { - parser.onend = async () => { + parser.onend = () => { try { for (const name of Object.keys(parser.node.headers)) { mimeContent.headers[name] = parser.node.headers[name][0].value; } mimeContent.rawSignedContent = Mime.retrieveRawSignedContent([parser.node]); - if (!mimeContent.subject && mimeContent.rawSignedContent) { - const rawSignedContentDecoded = await Mime.decode(Buf.fromUtfStr(mimeContent.rawSignedContent)); - mimeContent.subject = rawSignedContentDecoded.subject; + const signedContentNode = Mime.retrieveSignedContentNode([parser.node]); + if (!mimeContent.subject && signedContentNode) { + mimeContent.subject = Mime.getSubjectFromNode(signedContentNode); } for (const node of Object.values(leafNodes)) { const nodeType = Mime.getNodeType(node); @@ -356,30 +357,70 @@ export class Mime { return { ...result, from }; } - private static retrieveRawSignedContent(nodes: MimeParserNode[]): string | undefined { + private static isSignedContentNode(node: MimeParserNode): boolean { + /* eslint-disable no-underscore-dangle */ + if (node._isMultipart === 'signed') { + return true; + } + return ( + node._isMultipart === 'mixed' && + node._childNodes !== false && + node._childNodes.length === 2 && + (Mime.getNodeType(node._childNodes[1]) === 'application/pgp-signature' || node._childNodes[1].contentType?.params?.name === 'signature.asc') + ); + /* eslint-enable no-underscore-dangle */ + } + + private static retrieveSignedContentNode(nodes: MimeParserNode[], depth = 0): MimeParserNode | undefined { + if (depth > Mime.MAX_SIGNED_CONTENT_DEPTH) { + return undefined; + } for (const node of nodes) { /* eslint-disable no-underscore-dangle */ if (!node._childNodes || !node._childNodes.length) { continue; // signed nodes tend contain two children: content node, signature node. If no node, then this is not pgp/mime signed content } - const isSigned = node._isMultipart === 'signed'; - const isMixedWithSig = - node._isMultipart === 'mixed' && - node._childNodes.length === 2 && - (Mime.getNodeType(node._childNodes[1]) === 'application/pgp-signature' || node._childNodes[1].contentType?.params?.name === 'signature.asc'); - if (isSigned || isMixedWithSig) { - // PGP/MIME signed content uses as in // use CR-LF https://tools.ietf.org/html/rfc3156#section-5 - // however emailjs parser will replace it to , so we fix it here - let rawSignedContent = node._childNodes[0].raw.replace(/\r?\n/g, '\r\n'); - if (rawSignedContent.endsWith('--')) { - // end of boundary without a mandatory newline - rawSignedContent += '\r\n'; // emailjs wrongly leaves out the last newline, fix it here - } - return rawSignedContent; + if (Mime.isSignedContentNode(node)) { + return node._childNodes[0]; } - return Mime.retrieveRawSignedContent(node._childNodes); + return Mime.retrieveSignedContentNode(node._childNodes, depth + 1); + /* eslint-enable no-underscore-dangle */ + } + return undefined; + } + + private static retrieveRawSignedContent(nodes: MimeParserNode[]): string | undefined { + const signedContentNode = Mime.retrieveSignedContentNode(nodes); + if (!signedContentNode) { + return undefined; + } + // PGP/MIME signed content uses as in // use CR-LF https://tools.ietf.org/html/rfc3156#section-5 + // however emailjs parser will replace it to , so we fix it here + let rawSignedContent = signedContentNode.raw.replace(/\r?\n/g, '\r\n'); + if (rawSignedContent.endsWith('--')) { + // end of boundary without a mandatory newline + rawSignedContent += '\r\n'; // emailjs wrongly leaves out the last newline, fix it here + } + return rawSignedContent; + } + + private static getSubjectFromNode(node: MimeParserNode): string | undefined { + let current = node; + let fallbackSubject: string | undefined; + for (let depth = 0; depth <= Mime.MAX_SIGNED_CONTENT_DEPTH; depth++) { + const currentSubject = current.headers.subject?.[0]?.value; + if (!Mime.isSignedContentNode(current)) { + return currentSubject || fallbackSubject; + } + fallbackSubject = currentSubject; + /* eslint-disable no-underscore-dangle */ + const childNodes = current._childNodes; + if (childNodes === false || !childNodes.length) { + return currentSubject || fallbackSubject; + } + current = childNodes[0]; + /* eslint-enable no-underscore-dangle */ } - /* eslint-enable no-underscore-dangle */ return undefined; } diff --git a/test/source/tests/browser-unit-tests/unit-Mime.js b/test/source/tests/browser-unit-tests/unit-Mime.js index e63672f334d..a68566ba84d 100644 --- a/test/source/tests/browser-unit-tests/unit-Mime.js +++ b/test/source/tests/browser-unit-tests/unit-Mime.js @@ -311,3 +311,150 @@ BROWSER_UNIT_TEST_NAME(`Mime attachment file name issue 3352`); } return 'pass'; })(); + +BROWSER_UNIT_TEST_NAME(`Mime.decode parses nested signed message exactly once`); +(async () => { + const nestedSignedMime = [ + 'Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha256"; boundary="b0"', + 'MIME-Version: 1.0', + '', + '--b0', + 'Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha256"; boundary="b1"', + '', + '--b1', + 'Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha256"; boundary="b2"', + '', + '--b2', + 'Content-Type: text/plain', + 'Subject: nested subject', + '', + 'hello nested world', + '--b2', + 'Content-Type: application/pgp-signature', + '', + 'sig2', + '--b2--', + '--b1', + 'Content-Type: application/pgp-signature', + '', + 'sig1', + '--b1--', + '--b0', + 'Content-Type: application/pgp-signature', + '', + 'sig0', + '--b0--', + ].join('\r\n'); + + const countDecodeCalls = async mime => { + const originalDecode = Mime.decode; + let decodeCalls = 0; + Mime.decode = (...args) => { + decodeCalls++; + return originalDecode(...args); + }; + try { + return { decoded: await Mime.decode(mime), decodeCalls }; + } finally { + Mime.decode = originalDecode; + } + }; + + const nestedResult = await countDecodeCalls(nestedSignedMime); + if (nestedResult.decodeCalls !== 1) { + throw Error(`Mime.decode was called ${nestedResult.decodeCalls} times for a nested signed message, expecting exactly 1`); + } + if (nestedResult.decoded.subject !== 'nested subject') { + throw Error(`unexpected subject '${nestedResult.decoded.subject}', expecting 'nested subject'`); + } + + const controlMime = [ + 'Content-Type: multipart/mixed; boundary="m0"', + 'MIME-Version: 1.0', + 'Subject: control', + '', + '--m0', + 'Content-Type: multipart/mixed; boundary="m1"', + '', + '--m1', + 'Content-Type: multipart/mixed; boundary="m2"', + '', + '--m2', + 'Content-Type: text/plain', + '', + 'hello', + '--m2', + 'Content-Type: application/octet-stream', + '', + 'data', + '--m2--', + '--m1', + 'Content-Type: application/octet-stream', + '', + 'data', + '--m1--', + '--m0', + 'Content-Type: application/octet-stream', + '', + 'data', + '--m0--', + ].join('\r\n'); + + const controlResult = await countDecodeCalls(controlMime); + if (controlResult.decodeCalls !== 1) { + throw Error(`Mime.decode was called ${controlResult.decodeCalls} times for an unsigned control message, expecting exactly 1`); + } + if (controlResult.decoded.subject !== 'control') { + throw Error(`unexpected subject '${controlResult.decoded.subject}', expecting 'control'`); + } + return 'pass'; +})(); + +BROWSER_UNIT_TEST_NAME(`Mime.decode extracts subject from Thunderbird-style signed content`); +(async () => { + const thunderbirdSignedMime = [ + 'Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha256"; boundary="tb"', + 'MIME-Version: 1.0', + '', + 'This is an OpenPGP/MIME signed message.', + '', + '--tb', + 'Content-Type: multipart/alternative; boundary="alt"', + 'Subject: Encrypted Subject: hello thunderbird', + '', + '--alt', + 'Content-Type: text/plain', + '', + 'plain body', + '--alt', + 'Content-Type: text/html', + '', + 'html body', + '--alt--', + '--tb', + 'Content-Type: application/pgp-signature', + '', + 'sig', + '--tb--', + ].join('\r\n'); + + const originalDecode = Mime.decode; + let decodeCalls = 0; + Mime.decode = (...args) => { + decodeCalls++; + return originalDecode(...args); + }; + let decoded; + try { + decoded = await Mime.decode(thunderbirdSignedMime); + } finally { + Mime.decode = originalDecode; + } + if (decodeCalls !== 1) { + throw Error(`Mime.decode was called ${decodeCalls} times for a signed message, expecting exactly 1`); + } + if (decoded.subject !== 'Encrypted Subject: hello thunderbird') { + throw Error(`unexpected subject '${decoded.subject}', expecting 'Encrypted Subject: hello thunderbird'`); + } + return 'pass'; +})(); From d66639c1f6220999348db735b91e23737bd0a440 Mon Sep 17 00:00:00 2001 From: martgil <46025304+martgil@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:06:33 +0800 Subject: [PATCH 2/2] fix: unsafe non-null return values for getSubjectFromNode --- extension/js/common/core/mime.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extension/js/common/core/mime.ts b/extension/js/common/core/mime.ts index 41582429ead..db67fe94528 100644 --- a/extension/js/common/core/mime.ts +++ b/extension/js/common/core/mime.ts @@ -412,7 +412,7 @@ export class Mime { if (!Mime.isSignedContentNode(current)) { return currentSubject || fallbackSubject; } - fallbackSubject = currentSubject; + fallbackSubject = currentSubject || fallbackSubject; /* eslint-disable no-underscore-dangle */ const childNodes = current._childNodes; if (childNodes === false || !childNodes.length) { @@ -421,7 +421,7 @@ export class Mime { current = childNodes[0]; /* eslint-enable no-underscore-dangle */ } - return undefined; + return fallbackSubject; } // eslint-disable-next-line @typescript-eslint/no-explicit-any