diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 85e8420..ed7ccfe 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -2,6 +2,7 @@ env: node: true es6: true mocha: true + es2020: true plugins: - haraka diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml deleted file mode 100644 index 24d5154..0000000 --- a/.github/workflows/ci-test.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Tests - -on: [ push ] - -jobs: - - ci-test: - - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: - - ubuntu-latest - - windows-latest - node-version: - - 14 - - 16 - fail-fast: false - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - name: Node ${{ matrix.node-version }} on ${{ matrix.os }} - with: - node-version: ${{ matrix.node-version }} - - - run: npm install - - run: npm test - env: - CI: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d7386c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: [ push ] + +env: + CI: true + +jobs: + + lint: + uses: haraka/.github/.github/workflows/lint.yml@master + + # coverage: + # uses: haraka/.github/.github/workflows/coverage.yml@master + # secrets: inherit + + ubuntu: + needs: [ lint ] + uses: haraka/.github/.github/workflows/ubuntu.yml@master + + windows: + needs: [ lint ] + uses: haraka/.github/.github/workflows/windows.yml@master diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..383aca2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,13 @@ +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + schedule: + - cron: '18 7 * * 4' + +jobs: + codeql: + uses: haraka/.github/.github/workflows/codeql.yml@master diff --git a/.github/workflows/coveralls.yml b/.github/workflows/coveralls.yml deleted file mode 100644 index 940f9fd..0000000 --- a/.github/workflows/coveralls.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Test Coverage - -# on: [ push ] # use this for non-plugins -# haraka-plugin-*, dummy event that never triggers -on: [ milestone ] - -jobs: - - coverage: - - runs-on: ubuntu-latest - - steps: - - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: 14 - - - name: Install - run: | - npm install - npm install --no-save nyc codecov - - - run: npx nyc --reporter=lcovonly npm test - env: - NODE_ENV: cov - - - name: Submit to Coveralls - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.github_token }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 2207163..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Lint - -on: [ push ] - -jobs: - - lint: - - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: - - 14 - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - name: Node.js ${{ matrix.node-version }} - with: - node-version: ${{ matrix.node-version }} - - - run: npm install - - run: npm run lint - env: - CI: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 822865a..42a9bb9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,36 +1,14 @@ -name: Publish +name: publish on: - release: - types: [ published ] + push: + branches: + - master env: - node_version: 14 + CI: true jobs: - - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: ${{ env.node_version }} - - run: npm install - - run: npm test - - publish-npm: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - uses: actions/setup-node@v2 - with: - node-version: ${{ env.node_version }} - registry-url: https://registry.npmjs.org - - run: npm install - - - run: npm publish --ignore-scripts --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} + publish: + uses: haraka/.github/.github/workflows/publish.yml@master + secrets: inherit \ No newline at end of file diff --git a/.gitignore b/.gitignore index f8faa6b..625981f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,9 @@ jspm_packages .node_repl_history package-lock.json +bower_components +# Optional npm cache directory +.npmrc +.idea +.DS_Store +haraka-update.sh \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a8e94cb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".release"] + path = .release + url = git@github.com:msimerson/.release.git diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..3e8e260 --- /dev/null +++ b/.npmignore @@ -0,0 +1,58 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +package-lock.json +bower_components +# Optional npm cache directory +.npmrc +.idea +.DS_Store +haraka-update.sh + +.github +.release +.codeclimate.yml +.editorconfig +.gitignore +.gitmodules +.lgtm.yml +appveyor.yml +codecov.yml +.travis.yml +.eslintrc.yaml +.eslintrc.json diff --git a/.release b/.release new file mode 160000 index 0000000..9be2b27 --- /dev/null +++ b/.release @@ -0,0 +1 @@ +Subproject commit 9be2b270ef836bcfefda085674bf62e2a91defe8 diff --git a/Changes.md b/Changes.md index fcdfe2f..50cae4e 100644 --- a/Changes.md +++ b/Changes.md @@ -1,4 +1,22 @@ +### Unreleased + + +### [1.0.7] - 2022-06-16 + +#### Added + +- + +#### Fixed + +- + +#### Changed + +- import fresh from Haraka +- convert tests to mocha + ### 1.0.6 - Dec 22, 2021 @@ -19,3 +37,6 @@ - finished converting tests to mocha, tests work again. Yay. - es6 updates - backported changes from haraka/plugins/attachment.js + + +[1.0.7]: https://github.com/haraka/haraka-plugin-attachment/releases/tag/1.0.7 diff --git a/README.md b/README.md index 8654176..2d068cf 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,8 @@ attachment ========== [![Build Status][ci-img]][ci-url] -[![Windows Build Status][ci-win-img]][ci-win-url] [![Code Climate][clim-img]][clim-url] [![NPM][npm-img]][npm-url] - This plugin allows you to reject messages based on Content-Type within the message or any MIME parts or on the filename of any attachments. @@ -82,12 +80,8 @@ Configuration -[ci-img]: https://travis-ci.org/haraka/haraka-plugin-attachment.svg -[ci-url]: https://travis-ci.org/haraka/haraka-plugin-attachment -[ci-win-img]: https://ci.appveyor.com/api/projects/status/u33k3jsuymtaqtfq?svg=true -[ci-win-url]: https://ci.appveyor.com/project/msimerson/haraka-plugin-attachment -[cov-img]: https://codecov.io/github/haraka/haraka-plugin-attachment/coverage.svg -[cov-url]: https://codecov.io/github/haraka/haraka-plugin-attachment +[ci-img]: https://github.com/haraka/haraka-plugin-attachment/actions/workflows/ci.yml/badge.svg +[ci-url]: https://github.com/haraka/haraka-plugin-attachment/actions/workflows/ci.yml [clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-attachment/badges/gpa.svg [clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-attachment [npm-img]: https://nodei.co/npm/haraka-plugin-attachment.png diff --git a/index.js b/index.js index fdb8b6e..aa9e2fd 100644 --- a/index.js +++ b/index.js @@ -1,17 +1,17 @@ 'use strict'; -// node.js builtins const fs = require('fs'); -const { spawn } = require('child_process'); const path = require('path'); +const { spawn } = require('child_process'); const crypto = require('crypto'); -// npm dependencies -const constants = require('haraka-constants'); - let tmp; let archives_disabled = false; +exports.re = { + ct: /^([^/]+\/[^;\r\n ]+)/, // content type +} + exports.register = function () { this.load_tmp_module(); @@ -21,7 +21,6 @@ exports.register = function () { this.load_n_compile_re('ctype', 'attachment.ctype.regex'); this.load_n_compile_re('archive', 'attachment.archive.filename.regex'); - this.register_hook('data', 'init_attachment'); this.register_hook('data_post', 'wait_for_attachment_hooks'); this.register_hook('data_post', 'check_attachments'); } @@ -53,30 +52,25 @@ exports.load_attachment_ini = function () { plugin.cfg.main.archive_extensions : plugin.cfg.main.archive_extns ? // old docs plugin.cfg.main.archive_extns : - ''; + 'zip tar tgz taz z gz rar 7z'; - const maxd = (plugin.cfg.archive && plugin.cfg.archive.max_depth) ? - plugin.cfg.archive.max_depth : // new - plugin.cfg.main.archive_max_depth ? // old - plugin.cfg.main.archive_max_depth : - 5; // default + plugin.cfg.archive.exts = this.options_to_object(extns) - plugin.cfg.archive = { - max_depth: maxd, - exts : plugin.options_to_object(extns) || - plugin.options_to_object('zip tar tgz taz z gz rar 7z'), - }; - - plugin.load_dissallowed_extns(); + plugin.cfg.archive.max_depth = + (plugin.cfg.archive && plugin.cfg.archive.max_depth) ? + plugin.cfg.archive.max_depth : // new + plugin.cfg.main.archive_max_depth ? // old + plugin.cfg.main.archive_max_depth : + 5; } -exports.find_bsdtar_path = function (cb) { +exports.find_bsdtar_path = cb => { let found = false; let i = 0; ['/bin', '/usr/bin', '/usr/local/bin'].forEach((dir) => { if (found) return; i++; - fs.stat(`${dir}/bsdtar`, (err, stats) => { + fs.stat(`${dir}/bsdtar`, (err) => { i--; if (found) return; if (err) { @@ -92,6 +86,7 @@ exports.find_bsdtar_path = function (cb) { exports.hook_init_master = exports.hook_init_child = function (next) { const plugin = this; + plugin.find_bsdtar_path((err, dir) => { if (err) { archives_disabled = true; @@ -155,177 +150,218 @@ exports.options_to_object = function (options) { return false; } -exports.unarchive_recursive = function (connection, f, archive_file_name, cb) { - const plugin = this; - +exports.unarchive_recursive = async function (connection, f, archive_file_name, cb) { if (archives_disabled) { connection.logdebug(this, 'archive support disabled'); return cb(); } - const files = []; + const plugin = this; const tmpfiles = []; - let count = 0; - let done_cb = false; - let timer; - function do_cb (err, files2) { - if (timer) clearTimeout(timer); - if (done_cb) return; + let timeouted = false; + let encrypted = false; + let depthExceeded = false; + + function timeoutedSpawn (cmd_path, args, env, pipe_stdout_ws) { + connection.logdebug(plugin, `running "${cmd_path} ${args.join(' ')}"`); + + return new Promise(function (resolve, reject) { + + let output = ''; + const p = spawn(cmd_path, args, env); + + // Start timer + let timeout = false; + const timer = setTimeout(() => { + timeout = timeouted = true; + p.kill(); + + reject(`command "${cmd_path} ${args}" timed out`); + }, plugin.cfg.timeout); + + + if (pipe_stdout_ws) { + p.stdout.pipe(pipe_stdout_ws); + } + else { + p.stdout.on('data', (data) => output += data); + } + + p.stderr.on('data', (data) => { + + if (data.includes('Incorrect passphrase')) { + encrypted = true; + } + + // it seems that stderr might be sometimes filled after exit so we rather print it out than wait for result + connection.logdebug(plugin, `"${cmd_path} ${args.join(' ')}": ${data}`); + }); + + p.on('exit', (code, signal) => { + if (timeout) return; + clearTimeout(timer); + + if (code && code > 0) { + // Error was returned + return reject(`"${cmd_path} ${args.join(' ')}" returned error code: ${code}}`); + } + + + if (signal) { + // Process terminated due to signal + return reject(`"${cmd_path} ${args.join(' ')}" terminated by signal: ${signal}`); + } + + resolve(output); + }); + }); + } + + function createTmp () { + // might be better to use async version of tmp in future not cb based + return new Promise((resolve, reject) => { + tmp.file((err, tmpfile, fd) => { + if (err) reject(err); - done_cb = true; - deleteTempFiles(); - return cb(err, files2); + const t = {}; + t.name = tmpfile; + t.fd = fd; + + resolve(t); + }); + }); } + async function unpackArchive (in_file, file) { + + const t = await createTmp(); + tmpfiles.push([t.fd, t.name]); + + connection.logdebug(plugin, `created tmp file: ${t.name} (fd=${t.fd}) for file ${file}`); + + const tws = fs.createWriteStream(t.name); + try { + // bsdtar seems to be asking for password if archive is encrypted workaround with --passphrase will end up + // with "Incorrect passphrase" for encrypted archives, but will be ignored with nonencrypted + await timeoutedSpawn(plugin.bsdtar_path, + ['-Oxf', in_file, `--include=${file}`, '--passphrase', 'deliberately_invalid'], + { + 'cwd': '/tmp', + 'env': { + 'LANG': 'C' + }, + }, + tws + ); + } + catch (e) { + connection.logdebug(plugin, e); + } + return t; + } + + async function listArchive (in_file) { + try { + const lines = await timeoutedSpawn(plugin.bsdtar_path, ['-tf', in_file, '--passphrase', 'deliberately_invalid'], { + 'cwd': '/tmp', + 'env': {'LANG': 'C'}, + }); + + // Extract non-empty filenames + return lines.split(/\r?\n/).filter(fl => fl); + } + catch (e) { + connection.logdebug(plugin, e); + return []; + } + } + + function deleteTempFiles () { - tmpfiles.forEach(function (t) { - fs.close(t[0], function () { + tmpfiles.forEach(t => { + fs.close(t[0], () => { connection.logdebug(plugin, `closed fd: ${t[0]}`); - fs.unlink(t[1], function () { + fs.unlink(t[1], () => { connection.logdebug(plugin, `deleted tempfile: ${t[1]}`); }); }); }); } - function listFiles (in_file, prefix, depth) { - if (!depth) depth = 1; - if (depth >= plugin.cfg.archive.max_depth) { - if (count === 0) { - return do_cb(new Error('maximum archive depth exceeded')); - } - return; + async function processFile (in_file, prefix, file, depth) { + let result = [(prefix ? `${prefix}/` : '') + file]; + + connection.logdebug(plugin, `found file: ${prefix ? `${prefix}/` : ''}${file} depth=${depth}`); + + if (!plugin.isArchive(path.extname(file.toLowerCase()))) { + return result; } - count++; - const bsdtar = spawn(plugin.bsdtar_path, [ '-tf', in_file ], { - 'cwd': '/tmp', - 'env': { 'LANG': 'C' }, - }); - // Start timer - let t1_timeout = false; - const t1_timer = setTimeout(() => { - t1_timeout = true; - bsdtar.kill(); - return do_cb(new Error('bsdtar timed out')); - }, plugin.cfg.timeout); + connection.logdebug(plugin, `need to extract file: ${prefix ? `${prefix}/` : ''}${file}`); - let lines = ""; - bsdtar.stdout.on('data', (data) => { lines += data; }); + const t = await unpackArchive(in_file, file); - let stderr = ""; - bsdtar.stderr.on('data', (data) => { stderr += data; }); + // Recurse + try { + result = result.concat(await listFiles(t.name, (prefix ? `${prefix}/` : '') + file, depth + 1)); + } + catch (e) { + connection.logdebug(plugin, e); + } - bsdtar.on('exit', (code, signal) => { - count--; - if (t1_timeout) return; - clearTimeout(t1_timer); + return result; + } - if (code && code > 0) { - // Error was returned - return do_cb(new Error(`bsdtar returned error code: ${code} error=${stderr.replace(/\r?\n/,' ')}`)); - } - if (signal) { - // Process terminated due to signal - return do_cb(new Error(`bsdtar terminated by signal: ${signal}`)); - } + async function listFiles (in_file, prefix, depth) { + const result = []; + depth = depth || 0; - // Process filenames - const fl = lines.split(/\r?\n/); - for (let i=0; i= plugin.cfg.archive.max_depth) { + depthExceeded = true; + connection.logdebug(plugin, `hit maximum depth with ${prefix ? `${prefix}/` : ''}${in_file}`); + return result; + } - const extn = path.extname(file.toLowerCase()); - if (!plugin.cfg.archive.exts[extn] && !plugin.cfg.archive.exts[extn.substring(1)]) { - continue; - } + const fls = await listArchive(in_file); + await Promise.all(fls.map(async (file) => { + const output = await processFile(in_file, prefix, file, depth + 1); + result.push(...output); + })); - connection.logdebug(plugin, `need to extract file: ${file}`); - count++; - depth++; - (function (file2, depth2) { - tmp.file(function (err2, tmpfile, fd) { - count--; - if (err2) return do_cb(err2.message); - connection.logdebug(plugin, `created tmp file: ${tmpfile} (fd=${fd}) for file ${(prefix ? prefix + '/' : '')} ${file2}`); - // Extract this file from the archive - const cmd2 = `LANG=C bsdtar -Oxf ${in_file} --include="${file2}" > ${tmpfile}`; - tmpfiles.push([fd, tmpfile]); - connection.logdebug(plugin, `running command: ${cmd2}`); - count++; - - const cmd = spawn(plugin.bsdtar_path, - [ '-Oxf', in_file, '--include=' + file2 ], - { - 'cwd': '/tmp', - 'env': { - 'LANG': 'C' - }, - } - ); - // Start timer - let t2_timeout = false; - const t2_timer = setTimeout(() => { - t2_timeout = true; - return do_cb(new Error(`bsdtar timed out extracting file ${file2}`)); - }, plugin.cfg.timeout); - - // Create WriteStream for this file - const tws = fs.createWriteStream(tmpfile, { 'fd': fd }); - let stderr2 = ''; - - cmd.stderr.on('data', (data) => { stderr2 += data; }); - - cmd.on('exit', (code2, signal2) => { - count--; - if (t2_timeout) return; - clearTimeout(t2_timer); - if (code2 && code2 > 0) { - // Error was returned - return do_cb(new Error(`bsdtar returned error code: ${code2} error=${stderr2.replace(/\r?\n/,' ')}`)); - } - if (signal) { - // Process terminated due to signal - return do_cb(new Error(`bsdtar terminated by signal: ${signal}`)); - } - // Recurse - return listFiles(tmpfile, (prefix ? prefix + '/' : '') + file2, depth); - }); - cmd.stdout.pipe(tws); - }); - })(file, depth); - } - if (depth > 0) depth--; - connection.logdebug(plugin, `finish: count=${count} depth=${depth}`); - if (count === 0) { - return do_cb(null, files); - } - }); + connection.loginfo(plugin, `finish (${prefix ? `${prefix}/` : ''}${in_file}): count=${result.length} depth=${depth}`); + return result; } - timer = setTimeout(function () { - return do_cb(new Error('timeout unpacking attachments')); + setTimeout(() => { + timeouted = true; }, plugin.cfg.timeout); - listFiles(f, archive_file_name); -} + const files = await listFiles(f, archive_file_name); + deleteTempFiles(); -function attachments_still_processing (txn) { - if (txn.notes.attachment.todo_count > 0) return true; - if (!txn.notes.attachment.next) return true; - return false; + if (timeouted) { + cb(new Error("archive extraction timeouted"), files); + } + else if (depthExceeded) { + cb(new Error("maximum archive depth exceeded"), files); + } + else if (encrypted) { + cb(new Error("archive encrypted"), files); + } + else { + cb(null, files); + } } exports.compute_and_log_md5sum = function (connection, ctype, filename, stream) { const plugin = this; const md5 = crypto.createHash('md5'); - let digest; let bytes = 0; stream.on('data', (data) => { @@ -334,16 +370,27 @@ exports.compute_and_log_md5sum = function (connection, ctype, filename, stream) }) stream.once('end', () => { - digest = md5.digest('hex'); - const ca = ctype.match(/^(.*)?;\s+name="(.*)?"/); - connection.transaction.results.push(plugin, { attach: - { + stream.pause(); + + const digest = md5.digest('hex') || ''; + const ct = plugin.content_type(connection, ctype) + + connection.transaction.notes.attachments.push({ + ctype: ct, + filename, + extension: plugin.file_extension(filename), + md5: digest, + }); + + connection.transaction.results.push(plugin, { + attach: { file: filename, - ctype: (ca && ca[2] === filename) ? ca[1] : ctype, + ctype: ct, md5: digest, - bytes: bytes, + bytes, }, - }); + emit: true, + }) connection.loginfo(plugin, `file="${filename}" ctype="${ctype}" md5=${digest} bytes=${bytes}`); }) } @@ -352,8 +399,7 @@ exports.file_extension = function (filename) { if (!filename) return ''; const ext_match = filename.match(/\.([^. ]+)$/); - if (!ext_match) return ''; - if (!ext_match[1]) return ''; + if (!ext_match || !ext_match[1]) return ''; return ext_match[1].toLowerCase(); } @@ -361,78 +407,70 @@ exports.file_extension = function (filename) { exports.content_type = function (connection, ctype) { const plugin = this; - const ct_match = ctype.match(/^([^/]+\/[^;\r\n ]+)/); - if (!ct_match) return ''; - if (!ct_match[1]) return ''; + const ct_match = ctype.match(plugin.re.ct); + if (!ct_match || !ct_match[1]) return 'unknown/unknown'; connection.logdebug(plugin, `found content type: ct_match[1]`); - connection.transaction.notes.attachment.ctypes.push(ct_match[1]); - return ct_match[1]; + connection.transaction.notes.attachment_ctypes.push(ct_match[1]); + return ct_match[1].toLowerCase(); } -exports.has_archive_extension = function (file_ext) { - const plugin = this; +exports.isArchive = function (file_ext) { // check with and without the dot prefixed - if (plugin.cfg.archive.exts[file_ext]) return true; - if (file_ext[0] === '.' && plugin.cfg.archive.exts[file_ext.substring(1)]) return true; + if (this.cfg.archive.exts[file_ext]) return true; + if (file_ext[0] === '.' && this.cfg.archive.exts[file_ext.substring(1)]) return true; return false; } exports.start_attachment = function (connection, ctype, filename, body, stream) { + const plugin = this; - const txn = connection.transaction; + const txn = connection?.transaction; function next () { - if (attachments_still_processing(txn)) return; - txn.notes.attachment.next(); + if (txn?.notes?.attachment_next && txn.notes.attachment_count === 0) { + return txn.notes.attachment_next(); + } } - plugin.compute_and_log_md5sum(connection, ctype, filename, stream); + let file_ext = '.unknown' - const content_type = plugin.content_type(connection, ctype); - const file_ext = plugin.file_extension(filename); + if (filename) { + file_ext = plugin.file_extension(filename); + txn.notes.attachment_files.push(filename); + } - txn.notes.attachments.push({ - ctype: content_type || 'unknown/unknown', - filename: (filename ? filename : ''), - extension: `.${file_ext}`, - }); + plugin.compute_and_log_md5sum(connection, ctype, filename, stream); if (!filename) return; connection.logdebug(plugin, `found attachment file: ${filename}`); - txn.notes.attachment.files.push(filename); - - // Start archive processing - if (archives_disabled) return; - if (!plugin.has_archive_extension(file_ext)) return; + // See if filename extension matches archive extension list + if (archives_disabled || !plugin.isArchive(file_ext)) return; connection.logdebug(plugin, `found ${file_ext} on archive list`); - txn.notes.attachment.todo_count++; + txn.notes.attachment_count++; stream.connection = connection; stream.pause(); tmp.file((err, fn, fd) => { function cleanup () { - fs.close(fd, function () { + fs.close(fd, () => { connection.logdebug(plugin, `closed fd: ${fd}`); - fs.unlink(fn, function () { + fs.unlink(fn, () => { connection.logdebug(plugin, `unlinked: ${fn}`); }); }); - } - function save_archive_error (deny_msg, log_msg) { - txn.notes.attachment.result = [ constants.DENYSOFT, deny_msg ]; - txn.notes.attachment.todo_count--; - connection.logerror(plugin, log_msg); - cleanup(); - next(); + stream.resume(); } if (err) { - save_archive_error(err.message, `Error writing tempfile: ${err.message}`); + txn.notes.attachment_result = [ DENYSOFT, err.message ]; + connection.logerror(plugin, `Error writing tempfile: ${err.message}`); + txn.notes.attachment_count--; + cleanup(); stream.resume(); - return; + return next(); } connection.logdebug(plugin, `Got tmpfile: attachment="${filename}" tmpfile="${fn}" fd={fd}`); @@ -440,63 +478,61 @@ exports.start_attachment = function (connection, ctype, filename, body, stream) stream.pipe(ws); stream.resume(); - ws.on('error', function (error) { - save_archive_error(error.message, `stream error: ${error.message}`); + ws.on('error', (error) => { + txn.notes.attachment_count--; + txn.notes.attachment_result = [ DENYSOFT, error.message ]; + connection.logerror(plugin, `stream error: ${error.message}`); + cleanup(); + next(); }); - ws.on('close', function () { + ws.on('close', () => { connection.logdebug(plugin, 'end of stream reached'); connection.pause(); - plugin.expand_tmpfile(connection, fn, filename, cleanup, next); + plugin.unarchive_recursive(connection, fn, filename, (error, files) => { + txn.notes.attachment_count--; + cleanup(); + if (err) { + connection.logerror(plugin, error.message); + if (err.message === 'maximum archive depth exceeded') { + txn.notes.attachment_result = [ DENY, 'Message contains nested archives exceeding the maximum depth' ]; + } + else if (/Encrypted file is unsupported/i.test(error.message)) { + if (!plugin.cfg.main.allow_encrypted_archives) { + txn.notes.attachment_result = [ DENY, 'Message contains encrypted archive' ]; + } + } + else if (/Mac metadata is too large/i.test(error.message)) { + // Skip this error + } + else { + if (!connection.relaying) { + txn.notes.attachment_result = [ DENYSOFT, 'Error unpacking archive' ]; + } + } + } + + txn.notes.attachment_archive_files = txn.notes.attachment_archive_files.concat(files); + connection.resume(); + next(); + }); }); }); } -exports.expand_tmpfile = function (connection, fn, filename, cleanup, done) { - const plugin = this; - const txn = connection.transaction; - plugin.unarchive_recursive(connection, fn, filename, function (err, files) { - txn.notes.attachment.todo_count--; - cleanup(); - if (err) { - connection.logerror(plugin, err.message); - if (err.message === 'maximum archive depth exceeded') { - txn.notes.attachment.result = [ constants.DENY, 'Message contains nested archives exceeding the maximum depth' ]; - } - else if (/Encrypted file is unsupported/i.test(err.message)) { - if (!plugin.cfg.archive.allow_encrypted) { - txn.notes.attachment.result = [ constants.DENY, 'Message contains encrypted archive' ]; - } - } - else { - txn.notes.attachment.result = [ constants.DENYSOFT, 'Error unpacking archive' ]; - } - } - else { - txn.notes.attachment.archive_files = txn.notes.attachment.archive_files.concat(files); - } - return done(); - }); -} - -exports.init_attachment = function (next, connection) { +exports.hook_data = function (next, connection) { const plugin = this; - const txn = connection.transaction; - txn.parse_body = 1; + if (!connection?.transaction) return next(); + const txn = connection?.transaction; - txn.notes.attachment = { - todo_count: 0, - ctypes: [], - files: [], - archive_files: [], - } + txn.parse_body = 1; + txn.notes.attachment_count = 0; txn.notes.attachments = []; - - txn.notes.attachment.files = []; - txn.notes.attachment.archive_files = []; - - txn.attachment_hooks(function (ctype, filename, body, stream) { + txn.notes.attachment_ctypes = []; + txn.notes.attachment_files = []; + txn.notes.attachment_archive_files = []; + txn.attachment_hooks((ctype, filename, body, stream) => { plugin.start_attachment(connection, ctype, filename, body, stream); }); next(); @@ -507,101 +543,92 @@ exports.disallowed_extensions = function (txn) { if (!plugin.re.bad_extn) return false; let bad = false; - [ txn.notes.attachment.files, txn.notes.attachment.archive_files ] - .forEach(function (items) { - if (bad) return; - if (!items || !Array.isArray(items)) return; - for (let i=0; i < items.length; i++) { - if (!plugin.re.bad_extn.test(items[i])) continue; - bad = items[i].split('.').slice(0).pop(); - break; - } - }); + [ txn.notes.attachment_files, txn.notes.attachment_archive_files ].forEach(items => { + if (bad) return; + if (!items || !Array.isArray(items)) return; + for (const extn of items) { + if (!plugin.re.bad_extn.test(extn)) continue; + bad = extn.split('.').slice(0).pop(); + break; + } + }) return bad; } exports.check_attachments = function (next, connection) { - const plugin = this; - const txn = connection.transaction; + const txn = connection?.transaction; + if (!txn) return next(); // Check for any stored errors from the attachment hooks - if (txn.notes.attachment.result) { - const result = txn.notes.attachment.result; + if (txn.notes.attachment_result) { + const result = txn.notes.attachment_result; return next(result[0], result[1]); } - const ctypes = txn.notes.attachment.ctypes; + const ctypes = txn.notes.attachment_ctypes; // Add in any content type from message body - const ct_re = /^([^/]+\/[^;\r\n ]+)/; const body = txn.body; - if (body) { - const body_ct = ct_re.exec(body.header.get('content-type')); - if (body_ct) { - connection.logdebug(this, `found content type: body_ct[1]`); - ctypes.push(body_ct[1]); - } + let body_ct; + if (body && (body_ct = this.re.ct.exec(body.header.get('content-type')))) { + connection.logdebug(this, `found content type: ${body_ct[1]}`); + ctypes.push(body_ct[1]); } // MIME parts if (body && body.children) { for (let c=0; c 0) { - // We still have attachment hooks running - connection.transaction.notes.attachment.next = next; +exports.wait_for_attachment_hooks = (next, connection) => { + if (connection?.transaction?.notes?.attachment_count > 0) { + connection.transaction.notes.attachment_next = next; } else { next(); diff --git a/package.json b/package.json index d118f7d..e5d1c57 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "name": "haraka-plugin-attachment", "license": "MIT", "description": "A message attachment scanning plugin for Haraka", - "version": "1.0.6", + "version": "1.0.7", "homepage": "https://github.com/haraka/haraka-plugin-attachment", "repository": { "type": "git", @@ -14,25 +14,24 @@ "node": ">= 12" }, "scripts": { - "test": "npx mocha", - "lint": "npx eslint *.js test/*.js", - "lintfix": "npx eslint --fix *.js test/*.js", - "cover": "NODE_ENV=cov npx istanbul cover ./node_modules/.bin/_mocha" + "test": "npx mocha --exit", + "lint": "npx eslint *.js test", + "lintfix": "npx eslint --fix *.js test" }, "dependencies": { "tmp": "0.2.1", - "haraka-constants": "*" + "haraka-constants": "*", + "haraka-utils": "*" }, "optionalDependencies": {}, "devDependencies": { "eslint": ">=8", "eslint-plugin-haraka": "*", "haraka-config": "*", - "haraka-test-fixtures": "*", - "mocha": "*" + "haraka-test-fixtures": ">=1.2.0", + "mocha": ">=9" }, "bugs": { - "mail": "helpme@gmail.com", "url": "https://github.com/haraka/haraka-plugin-attachment/issues" } } diff --git a/test/fixtures/3layer.zip b/test/fixtures/3layer.zip new file mode 100644 index 0000000..39a00ce Binary files /dev/null and b/test/fixtures/3layer.zip differ diff --git a/test/fixtures/empty.gz b/test/fixtures/empty.gz new file mode 100644 index 0000000..02f3b82 Binary files /dev/null and b/test/fixtures/empty.gz differ diff --git a/test/fixtures/encrypt-recursive.zip b/test/fixtures/encrypt-recursive.zip new file mode 100644 index 0000000..3e2bc71 Binary files /dev/null and b/test/fixtures/encrypt-recursive.zip differ diff --git a/test/fixtures/encrypt.zip b/test/fixtures/encrypt.zip new file mode 100644 index 0000000..a02b1fc Binary files /dev/null and b/test/fixtures/encrypt.zip differ diff --git a/test/fixtures/gz-in-zip.zip b/test/fixtures/gz-in-zip.zip new file mode 100644 index 0000000..2e7f67e Binary files /dev/null and b/test/fixtures/gz-in-zip.zip differ diff --git a/test/fixtures/haraka-icon-attach.eml b/test/fixtures/haraka-icon-attach.eml new file mode 100644 index 0000000..44f406e --- /dev/null +++ b/test/fixtures/haraka-icon-attach.eml @@ -0,0 +1,86 @@ +Delivered-To: haraka.mail@gmail.com +Received: by 2002:ab3:6f8e:0:0:0:0:0 with SMTP id d14csp1994692ltq; + Thu, 16 Jun 2022 22:39:28 -0700 (PDT) +X-Google-Smtp-Source: ABdhPJw16YN9P7Vs8oIiHL7G+DNj0rd/RFuy9/l/Wicmod36VZVTS/puw3+7DlWo8U7IluNFgj44 +X-Received: by 2002:a05:6808:3082:b0:32f:14df:d56 with SMTP id bl2-20020a056808308200b0032f14df0d56mr9453315oib.36.1655444367728; + Thu, 16 Jun 2022 22:39:27 -0700 (PDT) +ARC-Seal: i=1; a=rsa-sha256; t=1655444367; cv=none; + d=google.com; s=arc-20160816; + b=kLGjp2ljdxPlDesfsMC9ePo3qqq6iTFoUk5OnGmOCoJROlsh9ughdJ/zDYEQDloorv + srZUVv8qtkQ6viejNdG4ZAOdQHfPBzVJQnwa5EJOcLv1uHRESDzWEBNVYpkgY6vxASe9 + voQh0bbnCfyJ3t1zS2T8YwAi3CUJjIAxnh74rM4T0ifiExrijJ3CsByYbW2EdicE7OCu + 5PMShKrEej1e14ERnAiQDPt8vPRI6ajDm0fvTUKZ0EeeXsMToOZIOJvwE5mYrWfBY/Lg + WR5eneLbfFFndqnme67Tk0oqXEfR4xqaWaKJmNWwpQp5Oxnfam/h0WHGRedFwA/4Q6tB + Tx/Q== +ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816; + h=dkim-signature:to:date:message-id:subject:mime-version:from; + bh=RYNKaQ8pV1V8ZzGHBBOfhMiozhScoqKiZgquGlE1Jfk=; + b=S2vxlxDMWnM5gzsQ2NC+MwF2QSijWMBjAgMwqGjWJhEOhN3TkmdNE5QMV83mDwJvPG + EybtmIxuSw9fFzBBlR52bq9z0LKXKq+GTmQEk8AqorsC1DiHalPxeSf9zpFCm8Fmtc8c + BR/Lr7cAzTWoxa23bNe+YVc/98ph3uS6FkayA+NAbrDuYscpNfFkajZnVbulzL5XxE/O + UtEAnyXaQAi3v5Kk2WqxewarYCvhUVREhWlOJwV4+4KF1a7XR2Loig4L8hvqgEb6ZXwW + dpnRwIMBWDOns4EH8kMhmTpWIyAJiIjLAzYkIFo+gA3XEWhVXuIfFRsZQ3onDKvohJWy + q56w== +ARC-Authentication-Results: i=1; mx.google.com; + dkim=pass header.i=@tnpi.net header.s=mar2013 header.b=TkkvpKMK; + spf=pass (google.com: domain of matt@tnpi.net designates 66.128.51.165 as permitted sender) smtp.mailfrom=matt@tnpi.net; + dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=tnpi.net +Return-Path: +Received: from mail.theartfarm.com (mail.theartfarm.com. [66.128.51.165]) + by mx.google.com with ESMTPS id i6-20020a9d1706000000b0060c4e96a70fsi3484305ota.173.2022.06.16.22.39.26 + for + (version=TLS1_2 cipher=ECDHE-ECDSA-CHACHA20-POLY1305 bits=256/256); + Thu, 16 Jun 2022 22:39:27 -0700 (PDT) +Received-SPF: pass (google.com: domain of matt@tnpi.net designates 66.128.51.165 as permitted sender) client-ip=66.128.51.165; +Authentication-Results: mx.google.com; + dkim=pass header.i=@tnpi.net header.s=mar2013 header.b=TkkvpKMK; + spf=pass (google.com: domain of matt@tnpi.net designates 66.128.51.165 as permitted sender) smtp.mailfrom=matt@tnpi.net; + dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=tnpi.net +Received: (Haraka outbound); Thu, 16 Jun 2022 20:39:25 -0900 +Authentication-Results: mail.theartfarm.com; auth=pass (plain); spf=pass smtp.mailfrom=tnpi.net +Received-SPF: Pass (mail.theartfarm.com: domain of tnpi.net designates 2602:47:d497:1a00:95df:28b7:e166:b8f3 as permitted sender) receiver=mail.theartfarm.com; identity=mailfrom; client-ip=66.128.51.165 helo=smtpclient.apple; envelope-from= +Received-SPF: None (mail.theartfarm.com: domain of smtpclient.apple does not designate 2602:47:d497:1a00:95df:28b7:e166:b8f3 as permitted sender) receiver=mail.theartfarm.com; identity=helo; client-ip=2602:47:d497:1a00:95df:28b7:e166:b8f3 helo=smtpclient.apple; envelope-from= +Received: from smtpclient.apple ([2602:47:d497:1a00:95df:28b7:e166:b8f3]) by mail.theartfarm.com (Haraka/2.8.28) with ESMTPSA id 87C954DA-1B27-441F-9C4B-589EDAF586B1.1 envelope-from tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (authenticated bits=0); Thu, 16 Jun 2022 20:39:25 -0900 +From: Matt Simerson +Content-Type: multipart/alternative; boundary="Apple-Mail=_65C16661-5FA8-4757-B627-13E55C40C8D7" +Mime-Version: 1.0 (Mac OS X Mail 16.0 \(3696.100.31\)) +Subject: sample email w/attachment +Message-Id: +Date: Thu, 16 Jun 2022 22:39:20 -0700 +To: haraka.mail@gmail.com +X-Mailer: Apple Mail (2.3696.100.31) +X-Spam-Status: No, score=-0.8 required=5.0 autolearn=no autolearn_force=no +X-Spam-DCC: : dcc 1102; Body=1 Fuz1=1 +X-Spam-Checker-Version: SpamAssassin 3.4.5 (2021-03-20) on spamassassin +X-Spam-Tests: ALL_TRUSTED,BAYES_00,HTML_IMAGE_ONLY_04,HTML_MESSAGE, MIME_HTML_MOSTLY,MPART_ALT_DIFF,SPF_HELO_NONE,SPF_PASS,TVD_SPACE_RATIO +X-Haraka-Karma: score: 22, good: 7, connections: 7, history: 7, awards: 162,182, pass:relaying +X-Haraka-ASN: 209 +X-Haraka-ASN-Org: CENTURYLINK-US-LEGACY-QWEST +X-Haraka-GeoIP: NA, US, FL, Ocala, 1428km +X-Haraka-GeoIP-Received: 2602:47:d497:1a00:95df:28b7:e166:b8f3:US +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=tnpi.net; s=mar2013; h=from:subject:date:message-id:to:mime-version; bh=o+lGZj+WlxYYl3NX8ghSTkSEvNbeSTpPdNdeEllwQmA=; b=TkkvpKMKj1LdTEUp3K3s/ZdAVzv3ao1EDubkcSkRRdpSCxXIyRJLDEpTuvqHWyKZ0nrkG3pCdf XClyHXvKRRplRpLIKRMC6HXg32awG4c37fMdhrzBEV9P1W0dsdZPrdODWBo/UXuKjHhHxhn8ASzG kr0i/iPSxKkbEHcb+RibaBletQjXh1ULLBMjP0wuOxsxG5hRh61UFvGFlAVkXLYyC0N1dt+emDoO nkl7Y5k4tLsuRSWDWMnwqvggJztjcRXZq8fx4Pt2XOLPRbF60hSI+H8WW0JnMSkg+NE/wy5Fzmy7 ersTykFjVG6JuhGCD8g7CSyF8VY3ffNdUaE/vnAg== + +--Apple-Mail=_65C16661-5FA8-4757-B627-13E55C40C8D7 +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; charset=us-ascii + + + + +--Apple-Mail=_65C16661-5FA8-4757-B627-13E55C40C8D7 +Content-Type: multipart/related; type="text/html"; boundary="Apple-Mail=_E112021D-E491-4E1D-8526-77623DD2EBE1" + +--Apple-Mail=_E112021D-E491-4E1D-8526-77623DD2EBE1 +Content-Transfer-Encoding: 7bit +Content-Type: text/html; charset=us-ascii + +

+--Apple-Mail=_E112021D-E491-4E1D-8526-77623DD2EBE1 +Content-Transfer-Encoding: base64 +Content-Disposition: inline; filename=1111229.png +Content-Type: image/png; x-unix-mode=0644; name="1111229.png" +Content-Id: + + +--Apple-Mail=_E112021D-E491-4E1D-8526-77623DD2EBE1-- +--Apple-Mail=_65C16661-5FA8-4757-B627-13E55C40C8D7-- \ No newline at end of file diff --git a/test/fixtures/invalid-in-valid.zip b/test/fixtures/invalid-in-valid.zip new file mode 100644 index 0000000..a35a4a3 Binary files /dev/null and b/test/fixtures/invalid-in-valid.zip differ diff --git a/test/fixtures/invalid.zip b/test/fixtures/invalid.zip new file mode 100644 index 0000000..9977a28 --- /dev/null +++ b/test/fixtures/invalid.zip @@ -0,0 +1 @@ +invalid diff --git a/test/fixtures/password.zip b/test/fixtures/password.zip new file mode 100644 index 0000000..c6c812b Binary files /dev/null and b/test/fixtures/password.zip differ diff --git a/test/fixtures/valid.zip b/test/fixtures/valid.zip new file mode 100644 index 0000000..b20a813 Binary files /dev/null and b/test/fixtures/valid.zip differ diff --git a/test/index.js b/test/index.js index d8ce781..61cd559 100644 --- a/test/index.js +++ b/test/index.js @@ -1,117 +1,65 @@ 'use strict'; -process.env.NODE_ENV = 'test'; +const assert = require('assert') +const fs = require('fs') +const path = require('path'); -const assert = require('assert'); const fixtures = require('haraka-test-fixtures'); const attach = new fixtures.plugin('index'); -// console.log(attach); - -describe('find_bsdtar_path', function () { - it('finds the bsdtar binary', function (done) { - attach.find_bsdtar_path((err, dir) => { - if (dir) { - assert.ifError(err); - assert.ok(dir); - } - else { - // fails on Travis - console.error('test error: unable to find bsdtar'); - } - done(); - }); - }) -}) -describe('register', function () { - it('is a function', function (done) { - assert.equal('function', typeof attach.register); - done(); - }); +function _set_up (done) { - it('runs', function (done) { - attach.register(); - // console.log(attach.cfg); - done(); - }); + this.plugin = new fixtures.plugin('attachment'); + this.plugin.cfg = {}; + this.plugin.cfg.timeout = 10; - it('loads the config', function (done) { - attach.register(); - // console.log(attach.cfg.archive); - // assert.deepEqual(attach.cfg.main, {}); - // assert.deepEqual(attach.cfg.filename, { 'resume.zip': undefined }); - assert.deepEqual(attach.cfg, { - main: { - "disallowed_extensions": "ade,adp,bat,chm,cmd,com,cpl,exe,hta,ins,isp,jar,jse,lib,lnk,mde,msc,msp,mst,pif,scr,sct,shb,sys,vb,vbe,vbs,vxd,wsc,wsf,wsh,dll,zip", - timeout: 30 - }, - timeout: 30000, - archive: { - max_depth: 10, - exts: { - 'zip': true, - 'tar': true, - 'tgz': true, - 'taz': true, - 'z': true, - 'gz': true, - 'rar': true, - '7z': true - } - } }); - done(); - }); -}) + this.connection = fixtures.connection.createConnection() + this.connection.init_transaction(); -describe('config', function () { - it('has archive section', function (done) { - attach.register(); - // console.log(attach.cfg); - assert.equal(attach.cfg.archive.max_depth, 10); - assert.ok(attach.cfg.archive.exts) - done(); - }); -}) + this.connection.logdebug = function (where, message) { if (process.env.DEBUG) console.log(message); }; + this.connection.loginfo = function (where, message) { console.log(message); }; + + this.directory = path.resolve(__dirname, 'fixtures'); + + // finds bsdtar + this.plugin.register(); + this.plugin.hook_init_master(done); +} describe('options_to_object', function () { - it('converts string to object', function (done) { + it('converts string to object', function () { const expected = {'gz': true, 'zip': true}; assert.deepEqual(expected, attach.options_to_object('gz zip')); assert.deepEqual(expected, attach.options_to_object('gz,zip')); assert.deepEqual(expected, attach.options_to_object(' gz , zip ')); - done(); - }); + }) }) describe('load_dissallowed_extns', function () { - it('loads comma separated options', function (done) { + it('loads comma separated options', function () { attach.cfg = { main: { disallowed_extensions: 'exe,scr' } }; attach.load_dissallowed_extns(); assert.ok(attach.re.bad_extn); assert.ok(attach.re.bad_extn.test('bad.scr')); - done(); - }); + }) - it('loads space separated options', function (done) { + it('loads space separated options', function () { attach.cfg = { main: { disallowed_extensions: 'dll tnef' } }; attach.load_dissallowed_extns(); assert.ok(attach.re.bad_extn); assert.ok(attach.re.bad_extn.test('bad.dll')); - done(); - }); + }) }) describe('file_extension', function () { - it('returns a file extension from a filename', function (done) { + it('returns a file extension from a filename', function () { assert.equal('ext', attach.file_extension('file.ext')); - done(); }) - it('returns empty string for no extension', function (done) { + it('returns empty string for no extension', function () { assert.equal('', attach.file_extension('file')); - done(); }) }) @@ -124,12 +72,11 @@ describe('disallowed_extensions', function () { const connection = fixtures.connection.createConnection(); connection.init_transaction(); const txn = connection.transaction; - txn.notes.attachment = {}; - txn.notes.attachment.files = ['naughty.exe']; + txn.notes.attachment_files = ['naughty.exe']; assert.equal('exe', attach.disallowed_extensions(txn)); - txn.notes.attachment.files = ['good.pdf', 'naughty.exe']; + txn.notes.attachment_files = ['good.pdf', 'naughty.exe']; assert.equal('exe', attach.disallowed_extensions(txn)); done(); }) @@ -144,13 +91,13 @@ describe('disallowed_extensions', function () { const txn = connection.transaction; txn.notes.attachment = {}; - txn.notes.attachment.archive_files = ['icky.tnef']; + txn.notes.attachment_archive_files = ['icky.tnef']; assert.equal('tnef', attach.disallowed_extensions(txn)); - txn.notes.attachment.archive_files = ['good.pdf', 'naughty.dll']; + txn.notes.attachment_archive_files = ['good.pdf', 'naughty.dll']; assert.equal('dll', attach.disallowed_extensions(txn)); - txn.notes.attachment.archive_files = ['good.pdf', 'better.png']; + txn.notes.attachment_archive_files = ['good.pdf', 'better.png']; assert.equal(false, attach.disallowed_extensions(txn)); done(); @@ -158,42 +105,171 @@ describe('disallowed_extensions', function () { }) describe('load_n_compile_re', function () { - it('loads regex lines from file, compiles to array', function (done) { - + it('loads regex lines from file, compiles to array', function () { attach.load_n_compile_re('test', 'attachment.filename.regex'); assert.ok(attach.re.test); assert.ok(attach.re.test[0].test('foo.exe')); - - done(); }) }) describe('check_items_against_regexps', function () { - it('positive', function (done) { + it('positive', function () { attach.load_n_compile_re('test', 'attachment.filename.regex'); assert.ok(attach.check_items_against_regexps(['file.exe'], attach.re.test)); assert.ok(attach.check_items_against_regexps(['fine.pdf','awful.exe'], attach.re.test)); - - done(); }) - it('negative', function (done) { + it('negative', function () { attach.load_n_compile_re('test', 'attachment.filename.regex'); assert.ok(!attach.check_items_against_regexps(['file.png'], attach.re.test)); assert.ok(!attach.check_items_against_regexps(['fine.pdf','godiva.chocolate'], attach.re.test)); - - done(); }) }) -describe('has_archive_extension', function () { - it('returns true for zip', function (done) { +describe('isArchive', function () { + it('zip', function () { attach.load_attachment_ini(); // console.log(attach.cfg.archive); - assert.equal(true, attach.has_archive_extension('.zip')); - assert.equal(true, attach.has_archive_extension('zip')); - done(); + assert.equal(true, attach.isArchive('.zip')); + assert.equal(true, attach.isArchive('zip')); + }) + + it('png', function () { + attach.load_attachment_ini(); + assert.equal(false, attach.isArchive('.png')); + assert.equal(false, attach.isArchive('png')); }) }) + +describe('unarchive_recursive', function () { + beforeEach(_set_up) + + it('3layers', function (done) { + if (!this.plugin.bsdtar_path) return done() + this.plugin.unarchive_recursive(this.connection, `${this.directory}/3layer.zip`, '3layer.zip', (e, files) => { + assert.equal(e, null); + assert.equal(files.length, 3); + + done() + }); + }) + + it('empty.gz', function (done) { + if (!this.plugin.bsdtar_path) return done() + this.plugin.unarchive_recursive(this.connection, `${this.directory}/empty.gz`, 'empty.gz', (e, files) => { + assert.equal(e, null); + assert.equal(files.length, 0); + done() + }); + }) + + it('encrypt.zip', function (done) { + if (!this.plugin.bsdtar_path) return done() + this.plugin.unarchive_recursive(this.connection, `${this.directory}/encrypt.zip`, 'encrypt.zip', (e, files) => { + // we see files list in encrypted zip, but we can't extract so no error here + assert.equal(e, null); + assert.equal(files?.length, 1); + done() + }); + }) + + it('encrypt-recursive.zip', function (done) { + if (!this.plugin.bsdtar_path) return done() + this.plugin.unarchive_recursive(this.connection, `${this.directory}/encrypt-recursive.zip`, 'encrypt-recursive.zip', (e, files) => { + // we can't extract encrypted file in encrypted zip so error here + assert.equal(true, e.message.includes('encrypted')); + assert.equal(files.length, 1); + done() + }); + }) + + it('gz-in-zip.zip', function (done) { + if (!this.plugin.bsdtar_path) return done() + + this.plugin.unarchive_recursive(this.connection, `${this.directory}/gz-in-zip.zip`, 'gz-in-zip.zip', (e, files) => { + // gz is not listable in bsdtar + assert.equal(e, null); + assert.equal(files.length, 1); + done() + }); + }) + + it('invalid.zip', function (done) { + if (!this.plugin.bsdtar_path) return done() + this.plugin.unarchive_recursive(this.connection, `${this.directory}/invalid.zip`, 'invalid.zip', (e, files) => { + // invalid zip is assumed to be just file, so error of bsdtar is ignored + assert.equal(e, null); + assert.equal(files.length, 0); + done() + }); + }) + + it('invalid-in-valid.zip', function (done) { + if (!this.plugin.bsdtar_path) return done() + this.plugin.unarchive_recursive(this.connection, `${this.directory}/invalid-in-valid.zip`, 'invalid-in-valid.zip', (e, files) => { + assert.equal(e, null); + assert.equal(files.length, 1); + done() + }); + }) + + it('password.zip', function (done) { + if (!this.plugin.bsdtar_path) return done() + this.plugin.unarchive_recursive(this.connection, `${this.directory}/password.zip`, 'password.zip', (e, files) => { + // we see files list in encrypted zip, but we can't extract so no error here + assert.equal(e, null); + assert.equal(files.length, 1); + done() + }); + }) + + it('valid.zip', function (done) { + if (!this.plugin.bsdtar_path) return done() + this.plugin.unarchive_recursive(this.connection, `${this.directory}/valid.zip`, 'valid.zip', (e, files) => { + assert.equal(e, null); + assert.equal(files.length, 1); + done() + }); + }) + + it('timeout', function (done) { + if (!this.plugin.bsdtar_path) return done() + this.plugin.cfg.timeout = 0; + this.plugin.unarchive_recursive(this.connection, `${this.directory}/encrypt-recursive.zip`, 'encrypt-recursive.zip', (e, files) => { + assert.ok(true, e.message.includes('timeout')); + assert.equal(files.length, 0); + done() + }); + }) +}) + +describe('start_attachment', function () { + beforeEach(_set_up) + + it('finds an message attachment', function (done) { + // const pi = this.plugin + const txn = this.connection.transaction + + this.plugin.hook_data(function () { + + // console.log(pi) + const msgPath = path.join(__dirname, 'fixtures', 'haraka-icon-attach.eml') + // console.log(`msgPath: ${msgPath}`) + const specimen = fs.readFileSync(msgPath, 'utf8'); + + for (const line of specimen.split(/\r?\n/g)) { + txn.add_data(`${line}\r\n`); + } + + txn.end_data(); + txn.ensure_body() + + // console.dir(txn.message_stream) + assert.deepEqual(txn.message_stream.idx['Apple-Mail=_65C16661-5FA8-4757-B627-13E55C40C8D7'], { start: 5232, end: 6384 }) + done() + }, + this.connection) + }) +}) \ No newline at end of file