From 8ba81e9954670b5d3c6e8cbc0aec5e8a0313c03a Mon Sep 17 00:00:00 2001 From: Wes Widner Date: Thu, 10 Sep 2026 07:51:03 -0400 Subject: [PATCH 1/4] fix(security): replace Underscore helpers with native ES5 operations --- mandrill.js | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/mandrill.js b/mandrill.js index 12d31a6..4dff922 100644 --- a/mandrill.js +++ b/mandrill.js @@ -1,6 +1,5 @@ exports.call = (function() { var request = require('request'), - _ = require('underscore'); util = require('util'); var _mandrill_api_url = 'https://mandrillapp.com/api/1.0/%s/%s.json'; @@ -15,55 +14,55 @@ exports.call = (function() { 'info' : _key, 'ping' : _key, 'senders' : _key, - 'disable-sender' : _.union(_key, ['domain']), - 'verify-sender' : _.union(_key, ['email']) + 'disable-sender' : _key.concat(['domain']), + 'verify-sender' : _key.concat(['email']) }, /* Messages Calls */ 'messages':{ - 'send' : _.union(_key, ['message']), - 'send-template' : _.union(_key, ['template_name','template_content','message']), - 'search' : _.union(_key, ['query','date_from','date_to','tags','senders','limit']) + 'send' : _key.concat(['message']), + 'send-template' : _key.concat(['template_name','template_content','message']), + 'search' : _key.concat(['query','date_from','date_to','tags','senders','limit']) }, /* Tags Calls */ 'tags':{ 'list' : _key, - 'info' : _.union(_key, ['tag']), - 'time-series' : _.union(_key, ['tag']), + 'info' : _key.concat(['tag']), + 'time-series' : _key.concat(['tag']), 'all-time-series' : _key }, /* Senders Calls */ 'senders':{ 'list' : _key, - 'info' : _.union(_key, ['address']), - 'time-series' : _.union(_key, ['address']) + 'info' : _key.concat(['address']), + 'time-series' : _key.concat(['address']) }, /* Urls Calls */ 'urls':{ 'list' : _key, - 'search' : _.union(_key, ['q']), - 'time-series' : _.union(_key, ['url']) + 'search' : _key.concat(['q']), + 'time-series' : _key.concat(['url']) }, /* Templates Calls */ 'templates':{ - 'add' : _.union(_key, ['name','code']), - 'info' : _.union(_key, ['name']), - 'update' : _.union(_key, ['name','code']), - 'delete' : _.union(_key, ['name']), + 'add' : _key.concat(['name','code']), + 'info' : _key.concat(['name']), + 'update' : _key.concat(['name','code']), + 'delete' : _key.concat(['name']), 'list' : _key }, /* Webhooks Calls */ 'webhooks':{ 'list' : _key, - 'add' : _.union(_key, ['url','events']), - 'info' : _.union(_key, ['id']), - 'update' : _.union(_key, ['id','url','events']), - 'delete' : _.union(_key, ['id']) + 'add' : _key.concat(['url','events']), + 'info' : _key.concat(['id']), + 'update' : _key.concat(['id','url','events']), + 'delete' : _key.concat(['id']) } }; @@ -75,7 +74,10 @@ exports.call = (function() { if(!_api_calls[type][call]) throw "Invalid call"; - if(_.difference(_.keys(opts),_api_calls[type][call]).length > 0) throw "Invalid options passed"; + var allowed = _api_calls[type][call]; + if(Object.keys(opts).some(function(key) { + return allowed.indexOf(key) === -1; + })) throw "Invalid options passed"; } var _callMandrillApi = function(type, call, opts, cb) { From 44354da8652458648b19ea5ae43cccb8a27ff618 Mon Sep 17 00:00:00 2001 From: Wes Widner Date: Thu, 10 Sep 2026 07:51:32 -0400 Subject: [PATCH 2/4] fix(security): remove affected Underscore dependency and expose offline tests --- package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 72e6e64..d914bbc 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description" : "A node.js wrapper for MailChimp's Mandrill API.", "author" : "Wes Widner (https://github.com/kai5263499)", "main" : "mandrill.js", + "scripts": { + "test": "node unit/offline-tests.js" + }, "repository" : { "type" : "git", "url" : "http://github.com/kai5263499/mandrill-node.git" @@ -12,8 +15,7 @@ "node": ">= 0.6.10" }, "dependencies": { - "request": ">= 2.9.100", - "underscore": ">= 1.3.3" + "request": ">= 2.9.100" }, "homepage": "http://github.com/kai5263499/mandrill-node.git", "keywords": ["mandrill", "node mandrill", "mandrill api", "email", "send email", "email api", "mailchimp", "mailchimp api", "email mailchimp"] From 14f282672e7326eec20426ebcc1a39774eb93367 Mon Sep 17 00:00:00 2001 From: Wes Widner Date: Thu, 10 Sep 2026 07:52:06 -0400 Subject: [PATCH 3/4] test: cover Mandrill API behavior without network or external dependencies --- unit/offline-tests.js | 115 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 unit/offline-tests.js diff --git a/unit/offline-tests.js b/unit/offline-tests.js new file mode 100644 index 0000000..400a4ac --- /dev/null +++ b/unit/offline-tests.js @@ -0,0 +1,115 @@ +/* Offline regression tests: no network, API key, or installed dependencies needed. */ +var assert = require('assert'), + fs = require('fs'), + vm = require('vm'), + util = require('util'); + +var source = fs.readFileSync(__dirname + '/../mandrill.js', 'utf8'); +var manifest = JSON.parse(fs.readFileSync(__dirname + '/../package.json', 'utf8')); +var requests = []; +var responseBody = '{"ok":true}'; +var sandbox = { + exports: {}, + console: { log: function() {} }, + require: function(name) { + if(name === 'util') return util; + if(name === 'request') return function(options, callback) { + requests.push(options); + callback(null, { statusCode: 200 }, responseBody); + }; + throw new Error('Unexpected dependency: ' + name); + } +}; + +assert.strictEqual(manifest.dependencies.underscore, undefined); +vm.runInNewContext(source, sandbox, 'mandrill.js'); +assert.strictEqual(sandbox.util, undefined, 'util must not leak into the global scope'); +var call = sandbox.exports.call; +var expected = { + users: { + info: [], ping: [], senders: [], + 'disable-sender': ['domain'], 'verify-sender': ['email'] + }, + messages: { + send: ['message'], + 'send-template': ['template_name', 'template_content', 'message'], + search: ['query', 'date_from', 'date_to', 'tags', 'senders', 'limit'] + }, + tags: { list: [], info: ['tag'], 'time-series': ['tag'], 'all-time-series': [] }, + senders: { list: [], info: ['address'], 'time-series': ['address'] }, + urls: { list: [], search: ['q'], 'time-series': ['url'] }, + templates: { + add: ['name', 'code'], info: ['name'], update: ['name', 'code'], + 'delete': ['name'], list: [] + }, + webhooks: { + list: [], add: ['url', 'events'], info: ['id'], + update: ['id', 'url', 'events'], 'delete': ['id'] + } +}; +var catalog; +call('get_api_calls', function(result) { catalog = JSON.parse(JSON.stringify(result)); }); +assert.deepEqual(Object.keys(catalog).sort(), Object.keys(expected).sort()); + +var count = 0; +Object.keys(expected).forEach(function(type) { + assert.deepEqual(Object.keys(catalog[type]).sort(), Object.keys(expected[type]).sort()); + Object.keys(expected[type]).forEach(function(action) { + assert.deepEqual(catalog[type][action], ['key'].concat(expected[type][action])); + var options = { type: type, call: action, key: 'offline-test-key' }; + var body = { key: 'offline-test-key' }; + expected[type][action].forEach(function(key) { + options[key] = body[key] = 'test-' + key; + }); + var result; + var before = requests.length; + call(options, function(value) { result = value; }); + assert.strictEqual(requests.length, before + 1); + var request = requests[requests.length - 1]; + assert.strictEqual(request.method, 'POST'); + assert.strictEqual(request.uri, 'https://mandrillapp.com/api/1.0/' + type + '/' + action + '.json'); + assert.deepEqual(JSON.parse(request.body), body); + assert.strictEqual(result.ok, true); + count++; + }); +}); +assert.strictEqual(count, 28); + +function rejects(options, message) { + var before = requests.length; + assert.throws(function() { call(options, function() {}); }, function(error) { + return error === message; + }); + assert.strictEqual(requests.length, before, 'invalid input must not make a request'); +} +rejects({ type: 'bad_users', call: 'info' }, 'Invalid type'); +rejects({ type: 'users', call: 'bad_info' }, 'Invalid call'); +rejects({ type: 'users', call: 'info', extra: 'property' }, 'Invalid options passed'); +rejects({ type: 'users', call: 'info', toString: 'not-allowed' }, 'Invalid options passed'); +var nullPrototype = Object.create(null); +nullPrototype.type = 'users'; +nullPrototype.call = 'info'; +nullPrototype.extra = 'property'; +rejects(nullPrototype, 'Invalid options passed'); + +var before = requests.length; +var missing; +assert.strictEqual(call({ type: 'users' }, function(value) { missing = value; }), false); +assert.strictEqual(missing, false); +assert.strictEqual(requests.length, before); + +call({ type: 'users', call: 'ping' }, function() {}); +assert.strictEqual(JSON.parse(requests[requests.length - 1].body).key, 'offline-test-key'); +var inherited = Object.create({ ignored: 'inherited-option' }); +inherited.type = 'users'; +inherited.call = 'ping'; +call(inherited, function() {}); +assert.deepEqual(JSON.parse(requests[requests.length - 1].body), { key: 'offline-test-key' }); + +responseBody = 'invalid JSON'; +var failure; +call({ type: 'users', call: 'ping' }, function(value) { failure = value; }); +assert.strictEqual(failure.status, 'error'); +assert.strictEqual(failure.code, -1); +assert.strictEqual(failure.body, 'invalid JSON'); +console.log('PASS: all 28 API calls, option validation, key caching, error handling, and no Underscore import.'); From 448c14e8d3859c87251a31626745939955512b82 Mon Sep 17 00:00:00 2001 From: Wes Widner Date: Thu, 10 Sep 2026 08:31:11 -0400 Subject: [PATCH 4/4] ci: run offline regression tests on pull requests before merging --- .github/workflows/test.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..60631c5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Tests + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + offline-tests: + name: Offline tests (Node ${{ matrix.node }}) + runs-on: ubuntu-24.04 + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + node: ['22', '24'] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: ${{ matrix.node }} + package-manager-cache: false + - name: Check JavaScript syntax + run: | + node --check mandrill.js + node --check unit/offline-tests.js + - name: Run offline regression suite + run: npm test