Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 62 additions & 21 deletions extension/js/common/core/mime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type MimeProccesedMsg = {
type SendingType = 'to' | 'cc' | 'bcc';

export class Mime {
private static readonly MAX_SIGNED_CONTENT_DEPTH = 50;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is similar recursive call in

if (!sigResult?.match && signedContentInDecryptedData) {

which can cause extension lag for nested signed content. we'll need to implement similar fix there, but let's leave it for separate PR

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it - Will remember this one and fix it on separate PR after this one.

public static processBody(decoded: MessageBody): MsgBlock[] {
const blocks: MsgBlock[] = [];
if (decoded.text) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -356,33 +357,73 @@ 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 <CR><LF> as in // use CR-LF https://tools.ietf.org/html/rfc3156#section-5
// however emailjs parser will replace it to <LF>, 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 */
}
/* 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 <CR><LF> as in // use CR-LF https://tools.ietf.org/html/rfc3156#section-5
// however emailjs parser will replace it to <LF>, 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 || fallbackSubject;
/* 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 */
}
return fallbackSubject;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
private static createAttachmentNode(attachment: Attachment, encodeType: MimeEncodeType): any {
// todo: MimeBuilder types
Expand Down
147 changes: 147 additions & 0 deletions test/source/tests/browser-unit-tests/unit-Mime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>html body</body></html>',
'--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';
})();
Loading