when submitting message structures then extract embedded attachments from HTML before doing validation checks

This commit is contained in:
Andris Reinman 2020-11-26 18:03:23 +02:00
parent 8a7a46f050
commit 1e1417cedb
7 changed files with 158 additions and 97 deletions

View file

@ -6,7 +6,8 @@ services:
node_js:
- 10
- 12
- 13
- 14
- 15
notifications:
email:
- andris@kreata.ee

View file

@ -2,7 +2,7 @@
process.env.NODE_ENV = 'test';
module.exports = function(grunt) {
module.exports = function (grunt) {
// Project configuration.
grunt.initConfig({
eslint: {
@ -22,7 +22,7 @@ module.exports = function(grunt) {
wait: {
server: {
options: {
delay: 7 * 1000
delay: 12 * 1000
}
}
},

View file

@ -4,8 +4,6 @@ const config = require('wild-config');
const log = require('npmlog');
const libmime = require('libmime');
const Joi = require('joi');
const uuid = require('uuid');
const os = require('os');
const MongoPaging = require('mongo-cursor-pagination');
const addressparser = require('nodemailer/lib/addressparser');
const MailComposer = require('nodemailer/lib/mail-composer');
@ -20,6 +18,7 @@ const Maildropper = require('../maildropper');
const util = require('util');
const roles = require('../roles');
const { nextPageCursorSchema, previousPageCursorSchema, pageNrSchema, sessSchema, sessIPSchema, booleanSchema, metaDataSchema } = require('../schemas');
const { preprocessAttachments } = require('../data-url');
module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
let maildrop = new Maildropper({
@ -1576,6 +1575,9 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
req.params.raw = req.body;
}
// do this before validation so we would not end up with too large html values
preprocessAttachments(req.params);
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
@ -1738,25 +1740,6 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
keepBcc: true
};
if (data.html && typeof data.html === 'string') {
let fromAddress = (data.from && data.from.address).toString() || os.hostname();
let cids = new Map();
data.html = data.html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
if (cids.has(dataUri)) {
return prefix + 'cid:' + cids.get(dataUri);
}
let cid = uuid.v4() + '-attachments@' + fromAddress.split('@').pop();
data.attachments.push(
processDataUrl({
path: dataUri,
cid
})
);
cids.set(dataUri, cid);
return prefix + 'cid:' + cid;
});
}
// ensure plaintext content if html is provided
if (data.html && !data.text) {
try {
@ -3069,28 +3052,3 @@ function parseAddresses(data) {
walk([].concat(data || []));
return Array.from(addresses);
}
function processDataUrl(element) {
let parts = (element.path || element.href).match(/^data:((?:[^;]*;)*(?:[^,]*)),(.*)$/i);
if (!parts) {
return element;
}
element.content = /\bbase64$/i.test(parts[1]) ? Buffer.from(parts[2], 'base64') : Buffer.from(decodeURIComponent(parts[2]));
if ('path' in element) {
element.path = false;
}
if ('href' in element) {
element.href = false;
}
parts[1].split(';').forEach(item => {
if (/^\w+\/[^/]+$/i.test(item)) {
element.contentType = element.contentType || item.toLowerCase();
}
});
return element;
}

View file

@ -3,8 +3,6 @@
const config = require('wild-config');
const log = require('npmlog');
const libmime = require('libmime');
const uuid = require('uuid');
const os = require('os');
const util = require('util');
const MailComposer = require('nodemailer/lib/mail-composer');
const htmlToText = require('html-to-text');
@ -15,6 +13,7 @@ const Maildropper = require('../maildropper');
const roles = require('../roles');
const Transform = require('stream').Transform;
const { sessSchema, sessIPSchema, booleanSchema } = require('../schemas');
const { preprocessAttachments } = require('../data-url');
class StreamCollect extends Transform {
constructor() {
@ -383,25 +382,6 @@ module.exports = (db, server, messageHandler, userHandler) => {
disableUrlAccess: true
};
if (data.html && typeof data.html === 'string') {
let fromAddress = (data.from && data.from.address).toString() || os.hostname();
let cids = new Map();
data.html = data.html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
if (cids.has(dataUri)) {
return prefix + 'cid:' + cids.get(dataUri);
}
let cid = uuid.v4() + '-attachments@' + fromAddress.split('@').pop();
data.attachments.push(
processDataUrl({
path: dataUri,
cid
})
);
cids.set(dataUri, cid);
return prefix + 'cid:' + cid;
});
}
// ensure plaintext content if html is provided
if (data.html && !data.text) {
try {
@ -723,6 +703,9 @@ module.exports = (db, server, messageHandler, userHandler) => {
ip: sessIPSchema
});
// extract embedded attachments from HTML
preprocessAttachments(req.params);
const result = schema.validate(req.params, {
abortEarly: false,
convert: true,
@ -772,28 +755,3 @@ module.exports = (db, server, messageHandler, userHandler) => {
})
);
};
function processDataUrl(element) {
let parts = (element.path || element.href).match(/^data:((?:[^;]*;)*(?:[^,]*)),(.*)$/i);
if (!parts) {
return element;
}
element.content = /\bbase64$/i.test(parts[1]) ? Buffer.from(parts[2], 'base64') : Buffer.from(decodeURIComponent(parts[2]));
if ('path' in element) {
element.path = false;
}
if ('href' in element) {
element.href = false;
}
parts[1].split(';').forEach(item => {
if (/^\w+\/[^/]+$/i.test(item)) {
element.contentType = element.contentType || item.toLowerCase();
}
});
return element;
}

74
lib/data-url.js Normal file
View file

@ -0,0 +1,74 @@
'use strict';
const os = require('os');
const uuid = require('uuid');
function processDataUrl(element, useBase64) {
let parts = (element.path || element.href).match(/^data:((?:[^;]*;)*(?:[^,]*)),(.*)$/i);
if (!parts) {
return element;
}
if (useBase64) {
element.content = /\bbase64$/i.test(parts[1]) ? parts[2] : Buffer.from(decodeURIComponent(parts[2])).toString('base64');
element.encoding = 'base64';
} else {
element.content = /\bbase64$/i.test(parts[1]) ? Buffer.from(parts[2], 'base64') : Buffer.from(decodeURIComponent(parts[2]));
}
if ('path' in element) {
delete element.path;
}
if ('href' in element) {
delete element.href;
}
parts[1].split(';').forEach(item => {
if (/^\w+\/[^/]+$/i.test(item)) {
element.contentType = element.contentType || item.toLowerCase();
}
});
return element;
}
/**
* Extracts attachments from html field
* @param {Object} data Parsed data object from client
*/
function preprocessAttachments(data) {
let hostname = data.from && data.from.address && typeof data.from.address === 'string' ? data.from.address.split('@').pop() : os.hostname();
if (data.html && typeof data.html === 'string' && data.html.length < 12 * 1024 * 1024) {
let attachments = [];
let cids = new Map();
data.html = data.html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
if (cids.has(dataUri)) {
return prefix + 'cid:' + cids.get(dataUri);
}
let cid = uuid.v4() + '-attachments@' + hostname;
attachments.push(
processDataUrl(
{
path: dataUri,
cid
},
true
)
);
cids.set(dataUri, cid);
return prefix + 'cid:' + cid;
});
if (attachments.length) {
data.attachments = [].concat(data.attachments || []).concat(attachments);
}
}
}
module.exports = {
processDataUrl,
preprocessAttachments
};

View file

@ -19,7 +19,7 @@
"ajv": "6.12.6",
"chai": "4.2.0",
"docsify-cli": "4.4.2",
"eslint": "7.13.0",
"eslint": "7.14.0",
"eslint-config-nodemailer": "1.2.0",
"eslint-config-prettier": "6.15.0",
"grunt": "1.3.0",
@ -41,7 +41,7 @@
"axios": "0.21.0",
"base32.js": "0.1.0",
"bcryptjs": "2.4.3",
"bull": "3.18.1",
"bull": "3.20.0",
"gelf": "2.0.1",
"generate-password": "1.5.1",
"he": "1.2.0",

View file

@ -453,4 +453,74 @@ describe('API tests', function () {
expect(response.body.success).to.be.true;
});
});
describe('message', () => {
before(async () => {
const response = await server.get(`/users/${userId}/mailboxes`).expect(200);
expect(response.body.success).to.be.true;
inbox = response.body.results.find(result => result.path === 'INBOX');
expect(inbox).to.exist;
inbox = inbox.id;
});
it('should POST /users/:user/mailboxes/:mailbox/messages with text and html', async () => {
const message = {
from: {
name: 'test tester',
address: 'testuser@example.com'
},
subject: 'hello world',
text: 'Hello hello world!',
html: '<p>Hello hello world!</p>'
};
const response = await server.post(`/users/${userId}/mailboxes/${inbox}/messages`).send(message).expect(200);
expect(response.body.success).to.be.true;
expect(response.body.message.id).to.be.gt(0);
const messageDataResponse = await server.get(`/users/${userId}/mailboxes/${inbox}/messages/${response.body.message.id}`);
expect(response.body.success).to.be.true;
const messageData = messageDataResponse.body;
expect(messageData.subject).to.equal(message.subject);
expect(messageData.html[0]).to.equal(message.html);
expect(messageData.attachments).to.deep.equal([]);
});
it('should POST /users/:user/mailboxes/:mailbox/messages with embedded attachment', async () => {
const message = {
from: {
name: 'test tester',
address: 'testuser@example.com'
},
subject: 'hello world',
text: 'Hello hello world!',
html:
'<p>Hello hello world! <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" alt="Red dot" /></p>'
};
const response = await server.post(`/users/${userId}/mailboxes/${inbox}/messages`).send(message);
expect(response.body.success).to.be.true;
expect(response.body.message.id).to.be.gt(0);
const messageDataResponse = await server.get(`/users/${userId}/mailboxes/${inbox}/messages/${response.body.message.id}`);
expect(response.body.success).to.be.true;
const messageData = messageDataResponse.body;
expect(messageData.subject).to.equal(message.subject);
expect(messageData.html[0]).to.equal('<p>Hello hello world! <img src="attachment:ATT00001" alt="Red dot" /></p>');
expect(messageData.attachments).to.deep.equal([
{
contentType: 'image/png',
disposition: 'attachment',
filename: 'attachment-1.png',
id: 'ATT00001',
related: true,
size: 118,
sizeKb: 1,
transferEncoding: 'base64'
}
]);
});
});
});