From cd62f0a9b1822fc386e9ad910e4b53873221704a Mon Sep 17 00:00:00 2001 From: Ugaitz Urien Date: Fri, 4 Oct 2024 09:44:27 +0200 Subject: [PATCH] Sql injection Exploit Prevention implementation for mysql2 library (#4712) --- .../datadog-instrumentations/src/mysql2.js | 221 +++++- .../test/mysql2.spec.js | 718 ++++++++++++++++++ packages/dd-trace/src/appsec/channels.js | 1 + .../dd-trace/src/appsec/rasp/sql_injection.js | 28 +- .../appsec/index.sequelize.plugin.spec.js | 2 +- .../rasp/sql_injection.mysql2.plugin.spec.js | 229 ++++++ .../test/appsec/rasp/sql_injection.spec.js | 67 +- packages/dd-trace/test/plugins/externals.json | 10 + 8 files changed, 1269 insertions(+), 7 deletions(-) create mode 100644 packages/datadog-instrumentations/test/mysql2.spec.js create mode 100644 packages/dd-trace/test/appsec/rasp/sql_injection.mysql2.plugin.spec.js diff --git a/packages/datadog-instrumentations/src/mysql2.js b/packages/datadog-instrumentations/src/mysql2.js index 0077b6b9dda..096eec0e80e 100644 --- a/packages/datadog-instrumentations/src/mysql2.js +++ b/packages/datadog-instrumentations/src/mysql2.js @@ -6,11 +6,14 @@ const { AsyncResource } = require('./helpers/instrument') const shimmer = require('../../datadog-shimmer') +const semver = require('semver') -addHook({ name: 'mysql2', file: 'lib/connection.js', versions: ['>=1'] }, Connection => { +addHook({ name: 'mysql2', file: 'lib/connection.js', versions: ['>=1'] }, (Connection, version) => { const startCh = channel('apm:mysql2:query:start') const finishCh = channel('apm:mysql2:query:finish') const errorCh = channel('apm:mysql2:query:error') + const startOuterQueryCh = channel('datadog:mysql2:outerquery:start') + const shouldEmitEndAfterQueryAbort = semver.intersects(version, '>=1.3.3') shimmer.wrap(Connection.prototype, 'addCommand', addCommand => function (cmd) { if (!startCh.hasSubscribers) return addCommand.apply(this, arguments) @@ -28,6 +31,76 @@ addHook({ name: 'mysql2', file: 'lib/connection.js', versions: ['>=1'] }, Connec return asyncResource.bind(addCommand, this).apply(this, arguments) }) + shimmer.wrap(Connection.prototype, 'query', query => function (sql, values, cb) { + if (!startOuterQueryCh.hasSubscribers) return query.apply(this, arguments) + + if (typeof sql === 'object') sql = sql?.sql + + if (!sql) return query.apply(this, arguments) + + const abortController = new AbortController() + startOuterQueryCh.publish({ sql, abortController }) + + if (abortController.signal.aborted) { + const addCommand = this.addCommand + this.addCommand = function (cmd) { return cmd } + + let queryCommand + try { + queryCommand = query.apply(this, arguments) + } finally { + this.addCommand = addCommand + } + + cb = queryCommand.onResult + + process.nextTick(() => { + if (cb) { + cb(abortController.signal.reason) + } else { + queryCommand.emit('error', abortController.signal.reason) + } + + if (shouldEmitEndAfterQueryAbort) { + queryCommand.emit('end') + } + }) + + return queryCommand + } + + return query.apply(this, arguments) + }) + + shimmer.wrap(Connection.prototype, 'execute', execute => function (sql, values, cb) { + if (!startOuterQueryCh.hasSubscribers) return execute.apply(this, arguments) + + if (typeof sql === 'object') sql = sql?.sql + + if (!sql) return execute.apply(this, arguments) + + const abortController = new AbortController() + startOuterQueryCh.publish({ sql, abortController }) + + if (abortController.signal.aborted) { + const addCommand = this.addCommand + this.addCommand = function (cmd) { return cmd } + + let result + try { + result = execute.apply(this, arguments) + } finally { + this.addCommand = addCommand + } + + result?.onResult(abortController.signal.reason) + + return result + } + + return execute.apply(this, arguments) + }) + return Connection function bindExecute (cmd, execute, asyncResource) { @@ -79,3 +152,149 @@ addHook({ name: 'mysql2', file: 'lib/connection.js', versions: ['>=1'] }, Connec }, cmd)) } }) + +addHook({ name: 'mysql2', file: 'lib/pool.js', versions: ['>=1'] }, (Pool, version) => { + const startOuterQueryCh = channel('datadog:mysql2:outerquery:start') + const shouldEmitEndAfterQueryAbort = semver.intersects(version, '>=1.3.3') + + shimmer.wrap(Pool.prototype, 'query', query => function (sql, values, cb) { + if (!startOuterQueryCh.hasSubscribers) return query.apply(this, arguments) + + if (typeof sql === 'object') sql = sql?.sql + + if (!sql) return query.apply(this, arguments) + + const abortController = new AbortController() + startOuterQueryCh.publish({ sql, abortController }) + + if (abortController.signal.aborted) { + const getConnection = this.getConnection + this.getConnection = function () {} + + let queryCommand + try { + queryCommand = query.apply(this, arguments) + } finally { + this.getConnection = getConnection + } + + process.nextTick(() => { + if (queryCommand.onResult) { + queryCommand.onResult(abortController.signal.reason) + } else { + queryCommand.emit('error', abortController.signal.reason) + } + + if (shouldEmitEndAfterQueryAbort) { + queryCommand.emit('end') + } + }) + + return queryCommand + } + + return query.apply(this, arguments) + }) + + shimmer.wrap(Pool.prototype, 'execute', execute => function (sql, values, cb) { + if (!startOuterQueryCh.hasSubscribers) return execute.apply(this, arguments) + + if (typeof sql === 'object') sql = sql?.sql + + if (!sql) return execute.apply(this, arguments) + + const abortController = new AbortController() + startOuterQueryCh.publish({ sql, abortController }) + + if (abortController.signal.aborted) { + if (typeof values === 'function') { + cb = values + } + + process.nextTick(() => { + cb(abortController.signal.reason) + }) + return + } + + return execute.apply(this, arguments) + }) + + return Pool +}) + +// PoolNamespace.prototype.query does not exist in mysql2<2.3.0 +addHook({ name: 'mysql2', file: 'lib/pool_cluster.js', versions: ['>=2.3.0'] }, PoolCluster => { + const startOuterQueryCh = channel('datadog:mysql2:outerquery:start') + const wrappedPoolNamespaces = new WeakSet() + + shimmer.wrap(PoolCluster.prototype, 'of', of => function () { + const poolNamespace = of.apply(this, arguments) + + if (startOuterQueryCh.hasSubscribers && !wrappedPoolNamespaces.has(poolNamespace)) { + shimmer.wrap(poolNamespace, 'query', query => function (sql, values, cb) { + if (typeof sql === 'object') sql = sql?.sql + + if (!sql) return query.apply(this, arguments) + + const abortController = new AbortController() + startOuterQueryCh.publish({ sql, abortController }) + + if (abortController.signal.aborted) { + const getConnection = this.getConnection + this.getConnection = function () {} + + let queryCommand + try { + queryCommand = query.apply(this, arguments) + } finally { + this.getConnection = getConnection + } + + process.nextTick(() => { + if (queryCommand.onResult) { + queryCommand.onResult(abortController.signal.reason) + } else { + queryCommand.emit('error', abortController.signal.reason) + } + + queryCommand.emit('end') + }) + + return queryCommand + } + + return query.apply(this, arguments) + }) + + shimmer.wrap(poolNamespace, 'execute', execute => function (sql, values, cb) { + if (typeof sql === 'object') sql = sql?.sql + + if (!sql) return execute.apply(this, arguments) + + const abortController = new AbortController() + startOuterQueryCh.publish({ sql, abortController }) + + if (abortController.signal.aborted) { + if (typeof values === 'function') { + cb = values + } + + process.nextTick(() => { + cb(abortController.signal.reason) + }) + + return + } + + return execute.apply(this, arguments) + }) + + wrappedPoolNamespaces.add(poolNamespace) + } + + return poolNamespace + }) + + return PoolCluster +}) diff --git a/packages/datadog-instrumentations/test/mysql2.spec.js b/packages/datadog-instrumentations/test/mysql2.spec.js new file mode 100644 index 00000000000..89e35f2a1f7 --- /dev/null +++ b/packages/datadog-instrumentations/test/mysql2.spec.js @@ -0,0 +1,718 @@ +'use strict' + +const { channel } = require('../src/helpers/instrument') +const agent = require('../../dd-trace/test/plugins/agent') +const { assert } = require('chai') +const semver = require('semver') + +describe('mysql2 instrumentation', () => { + withVersions('mysql2', 'mysql2', version => { + function abort ({ sql, abortController }) { + assert.isString(sql) + const error = new Error('Test') + abortController.abort(error) + + if (!abortController.signal.reason) { + abortController.signal.reason = error + } + } + + function noop () {} + + const config = { + host: '127.0.0.1', + user: 'root', + database: 'db' + } + + const sql = 'SELECT 1' + let startCh, mysql2, shouldEmitEndAfterQueryAbort + let apmQueryStartChannel, apmQueryStart, mysql2Version + + before(() => { + startCh = channel('datadog:mysql2:outerquery:start') + return agent.load(['mysql2']) + }) + + before(() => { + const mysql2Require = require(`../../../versions/mysql2@${version}`) + mysql2Version = mysql2Require.version() + // in v1.3.3 CommandQuery started to emit 'end' after 'error' event + shouldEmitEndAfterQueryAbort = semver.intersects(mysql2Version, '>=1.3.3') + mysql2 = mysql2Require.get() + apmQueryStartChannel = channel('apm:mysql2:query:start') + }) + + beforeEach(() => { + apmQueryStart = sinon.stub() + apmQueryStartChannel.subscribe(apmQueryStart) + }) + + afterEach(() => { + if (startCh?.hasSubscribers) { + startCh.unsubscribe(abort) + startCh.unsubscribe(noop) + } + apmQueryStartChannel.unsubscribe(apmQueryStart) + }) + + describe('lib/connection.js', () => { + let connection + + beforeEach(() => { + connection = mysql2.createConnection(config) + + connection.connect() + }) + + afterEach((done) => { + connection.end(() => done()) + }) + + describe('Connection.prototype.query', () => { + describe('with string as query', () => { + describe('with callback', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + const query = connection.query(sql, (err) => { + assert.propertyVal(err, 'message', 'Test') + sinon.assert.notCalled(apmQueryStart) + + if (!shouldEmitEndAfterQueryAbort) done() + }) + + query.on('end', () => done()) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + connection.query(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + connection.query(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + + describe('without callback', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + + const query = connection.query(sql) + + query.on('error', (err) => { + assert.propertyVal(err, 'message', 'Test') + sinon.assert.notCalled(apmQueryStart) + if (!shouldEmitEndAfterQueryAbort) done() + }) + + query.on('end', () => done()) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + const query = connection.query(sql) + + query.on('error', (err) => done(err)) + query.on('end', () => { + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + const query = connection.query(sql) + + query.on('error', (err) => done(err)) + query.on('end', () => { + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + }) + + describe('with object as query', () => { + describe('with callback', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + const query = mysql2.Connection.createQuery(sql, (err) => { + assert.propertyVal(err, 'message', 'Test') + sinon.assert.notCalled(apmQueryStart) + + if (!shouldEmitEndAfterQueryAbort) done() + }, null, {}) + connection.query(query) + + query.on('end', () => done()) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + const query = mysql2.Connection.createQuery(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }, null, {}) + + connection.query(query) + }) + + it('should work without subscriptions', (done) => { + const query = mysql2.Connection.createQuery(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }, null, {}) + + connection.query(query) + }) + }) + + describe('without callback', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + + const query = mysql2.Connection.createQuery(sql, null, null, {}) + query.on('error', (err) => { + assert.propertyVal(err, 'message', 'Test') + sinon.assert.notCalled(apmQueryStart) + + if (!shouldEmitEndAfterQueryAbort) done() + }) + + connection.query(query) + + query.on('end', () => done()) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + const query = mysql2.Connection.createQuery(sql, null, null, {}) + query.on('error', (err) => done(err)) + query.on('end', () => { + sinon.assert.called(apmQueryStart) + + done() + }) + + connection.query(query) + }) + + it('should work without subscriptions', (done) => { + const query = mysql2.Connection.createQuery(sql, null, null, {}) + query.on('error', (err) => done(err)) + query.on('end', () => { + sinon.assert.called(apmQueryStart) + + done() + }) + + connection.query(query) + }) + }) + }) + }) + + describe('Connection.prototype.execute', () => { + describe('with the query in options', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + + const options = { sql } + const commandExecute = connection.execute(options, (err) => { + assert.propertyVal(err, 'message', 'Test') + sinon.assert.notCalled(apmQueryStart) + + done() + }) + + assert.equal(commandExecute.sql, options.sql) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + const options = { sql } + + connection.execute(options, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + const options = { sql } + + connection.execute(options, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + + describe('with sql as string', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + + connection.execute(sql, (err) => { + assert.propertyVal(err, 'message', 'Test') + sinon.assert.notCalled(apmQueryStart) + done() + }) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + connection.execute(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + const options = { sql } + + connection.execute(options, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + }) + }) + + describe('lib/pool.js', () => { + let pool + + before(() => { + pool = mysql2.createPool(config) + }) + + describe('Pool.prototype.query', () => { + describe('with object as query', () => { + describe('with callback', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + const query = pool.query({ sql }, (err) => { + assert.propertyVal(err, 'message', 'Test') + sinon.assert.notCalled(apmQueryStart) + + if (!shouldEmitEndAfterQueryAbort) done() + }) + + query.on('end', () => done()) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + pool.query({ sql }, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + pool.query({ sql }, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + + describe('without callback', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + const query = pool.query({ sql }) + query.on('error', err => { + assert.propertyVal(err, 'message', 'Test') + sinon.assert.notCalled(apmQueryStart) + if (!shouldEmitEndAfterQueryAbort) done() + }) + + query.on('end', () => done()) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + const query = pool.query({ sql }) + + query.on('error', err => done(err)) + query.on('end', () => { + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + pool.query({ sql }, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + }) + + describe('with string as query', () => { + describe('with callback', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + const query = pool.query(sql, (err) => { + assert.propertyVal(err, 'message', 'Test') + sinon.assert.notCalled(apmQueryStart) + + if (!shouldEmitEndAfterQueryAbort) done() + }) + + query.on('end', () => done()) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + pool.query(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + pool.query(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + + describe('without callback', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + const query = pool.query(sql) + query.on('error', err => { + assert.propertyVal(err, 'message', 'Test') + sinon.assert.notCalled(apmQueryStart) + if (!shouldEmitEndAfterQueryAbort) done() + }) + + query.on('end', () => done()) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + const query = pool.query(sql) + + query.on('error', err => done(err)) + query.on('end', () => { + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + pool.query(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + }) + }) + + describe('Pool.prototype.execute', () => { + describe('with object as query', () => { + describe('with callback', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + pool.execute({ sql }, (err) => { + assert.propertyVal(err, 'message', 'Test') + + setTimeout(() => { + sinon.assert.notCalled(apmQueryStart) + done() + }, 100) + }) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + pool.execute({ sql }, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + pool.execute({ sql }, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + }) + + describe('with string as query', () => { + describe('with callback', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + pool.execute(sql, (err) => { + assert.propertyVal(err, 'message', 'Test') + + setTimeout(() => { + sinon.assert.notCalled(apmQueryStart) + done() + }, 100) + }) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + pool.execute(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + pool.execute(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + }) + }) + }) + + describe('lib/pool_cluster.js', () => { + let poolCluster, connection + + before(function () { + if (!semver.satisfies(mysql2Version, '>=2.3.0')) this.skip() + poolCluster = mysql2.createPoolCluster() + poolCluster.add('clusterA', config) + }) + + beforeEach((done) => { + poolCluster.getConnection('clusterA', function (err, _connection) { + if (err) { + done(err) + return + } + + connection = _connection + + done() + }) + }) + + afterEach(() => { + connection?.release() + }) + + describe('PoolNamespace.prototype.query', () => { + describe('with string as query', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + const namespace = poolCluster.of() + namespace.query(sql, (err) => { + assert.propertyVal(err, 'message', 'Test') + + setTimeout(() => { + sinon.assert.notCalled(apmQueryStart) + done() + }, 100) + }) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + const namespace = poolCluster.of() + namespace.query(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + const namespace = poolCluster.of() + namespace.query(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + + describe('with object as query', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + const namespace = poolCluster.of() + namespace.query({ sql }, (err) => { + assert.propertyVal(err, 'message', 'Test') + + setTimeout(() => { + sinon.assert.notCalled(apmQueryStart) + done() + }, 100) + }) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + const namespace = poolCluster.of() + namespace.query({ sql }, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + const namespace = poolCluster.of() + namespace.query({ sql }, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + }) + + describe('PoolNamespace.prototype.execute', () => { + describe('with string as query', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + + const namespace = poolCluster.of() + namespace.execute(sql, (err) => { + assert.propertyVal(err, 'message', 'Test') + + setTimeout(() => { + sinon.assert.notCalled(apmQueryStart) + done() + }, 100) + }) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + const namespace = poolCluster.of() + namespace.execute(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + const namespace = poolCluster.of() + namespace.execute(sql, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + + describe('with object as query', () => { + it('should abort the query on abortController.abort()', (done) => { + startCh.subscribe(abort) + + const namespace = poolCluster.of() + namespace.execute({ sql }, (err) => { + assert.propertyVal(err, 'message', 'Test') + + setTimeout(() => { + sinon.assert.notCalled(apmQueryStart) + done() + }, 100) + }) + }) + + it('should work without abortController.abort()', (done) => { + startCh.subscribe(noop) + + const namespace = poolCluster.of() + namespace.execute({ sql }, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + + it('should work without subscriptions', (done) => { + const namespace = poolCluster.of() + namespace.execute({ sql }, (err) => { + assert.isNull(err) + sinon.assert.called(apmQueryStart) + + done() + }) + }) + }) + }) + }) + }) +}) diff --git a/packages/dd-trace/src/appsec/channels.js b/packages/dd-trace/src/appsec/channels.js index c098efd5538..a451b9ce145 100644 --- a/packages/dd-trace/src/appsec/channels.js +++ b/packages/dd-trace/src/appsec/channels.js @@ -24,5 +24,6 @@ module.exports = { setUncaughtExceptionCaptureCallbackStart: dc.channel('datadog:process:setUncaughtExceptionCaptureCallback:start'), pgQueryStart: dc.channel('apm:pg:query:start'), pgPoolQueryStart: dc.channel('datadog:pg:pool:query:start'), + mysql2OuterQueryStart: dc.channel('datadog:mysql2:outerquery:start'), wafRunFinished: dc.channel('datadog:waf:run:finish') } diff --git a/packages/dd-trace/src/appsec/rasp/sql_injection.js b/packages/dd-trace/src/appsec/rasp/sql_injection.js index b942dd82be5..d4a165d8615 100644 --- a/packages/dd-trace/src/appsec/rasp/sql_injection.js +++ b/packages/dd-trace/src/appsec/rasp/sql_injection.js @@ -1,12 +1,18 @@ 'use strict' -const { pgQueryStart, pgPoolQueryStart, wafRunFinished } = require('../channels') +const { + pgQueryStart, + pgPoolQueryStart, + wafRunFinished, + mysql2OuterQueryStart +} = require('../channels') const { storage } = require('../../../../datadog-core') const addresses = require('../addresses') const waf = require('../waf') const { RULE_TYPES, handleResult } = require('./utils') const DB_SYSTEM_POSTGRES = 'postgresql' +const DB_SYSTEM_MYSQL = 'mysql' const reqQueryMap = new WeakMap() // WeakMap> let config @@ -17,18 +23,32 @@ function enable (_config) { pgQueryStart.subscribe(analyzePgSqlInjection) pgPoolQueryStart.subscribe(analyzePgSqlInjection) wafRunFinished.subscribe(clearQuerySet) + + mysql2OuterQueryStart.subscribe(analyzeMysql2SqlInjection) } function disable () { if (pgQueryStart.hasSubscribers) pgQueryStart.unsubscribe(analyzePgSqlInjection) if (pgPoolQueryStart.hasSubscribers) pgPoolQueryStart.unsubscribe(analyzePgSqlInjection) if (wafRunFinished.hasSubscribers) wafRunFinished.unsubscribe(clearQuerySet) + if (mysql2OuterQueryStart.hasSubscribers) mysql2OuterQueryStart.unsubscribe(analyzeMysql2SqlInjection) +} + +function analyzeMysql2SqlInjection (ctx) { + const query = ctx.sql + if (!query) return + + analyzeSqlInjection(query, DB_SYSTEM_MYSQL, ctx.abortController) } function analyzePgSqlInjection (ctx) { const query = ctx.query?.text if (!query) return + analyzeSqlInjection(query, DB_SYSTEM_POSTGRES, ctx.abortController) +} + +function analyzeSqlInjection (query, dbSystem, abortController) { const store = storage.getStore() if (!store) return @@ -39,7 +59,7 @@ function analyzePgSqlInjection (ctx) { let executedQueries = reqQueryMap.get(req) if (executedQueries?.has(query)) return - // Do not waste time executing same query twice + // Do not waste time checking same query twice // This also will prevent double calls in pg.Pool internal queries if (!executedQueries) { executedQueries = new Set() @@ -49,12 +69,12 @@ function analyzePgSqlInjection (ctx) { const persistent = { [addresses.DB_STATEMENT]: query, - [addresses.DB_SYSTEM]: DB_SYSTEM_POSTGRES + [addresses.DB_SYSTEM]: dbSystem } const result = waf.run({ persistent }, req, RULE_TYPES.SQL_INJECTION) - handleResult(result, req, res, ctx.abortController, config) + handleResult(result, req, res, abortController, config) } function hasInputAddress (payload) { diff --git a/packages/dd-trace/test/appsec/index.sequelize.plugin.spec.js b/packages/dd-trace/test/appsec/index.sequelize.plugin.spec.js index 07013a570d2..d444b82ec5e 100644 --- a/packages/dd-trace/test/appsec/index.sequelize.plugin.spec.js +++ b/packages/dd-trace/test/appsec/index.sequelize.plugin.spec.js @@ -30,7 +30,7 @@ describe('sequelize', () => { // close agent after(() => { appsec.disable() - return agent.close() + return agent.close({ ritmReset: false }) }) // init database diff --git a/packages/dd-trace/test/appsec/rasp/sql_injection.mysql2.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/sql_injection.mysql2.plugin.spec.js new file mode 100644 index 00000000000..2fe74e9f262 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/sql_injection.mysql2.plugin.spec.js @@ -0,0 +1,229 @@ +'use strict' + +const agent = require('../../plugins/agent') +const appsec = require('../../../src/appsec') +const Config = require('../../../src/config') +const path = require('path') +const Axios = require('axios') +const { assert } = require('chai') +const { checkRaspExecutedAndNotThreat, checkRaspExecutedAndHasThreat } = require('./utils') + +describe('RASP - sql_injection', () => { + withVersions('mysql2', 'express', expressVersion => { + withVersions('mysql2', 'mysql2', mysql2Version => { + describe('sql injection with mysql2', () => { + const connectionData = { + host: '127.0.0.1', + user: 'root', + database: 'db' + } + let server, axios, app, mysql2 + + before(() => { + return agent.load(['express', 'http', 'mysql2'], { client: false }) + }) + + before(done => { + const express = require(`../../../../../versions/express@${expressVersion}`).get() + mysql2 = require(`../../../../../versions/mysql2@${mysql2Version}`).get() + const expressApp = express() + + expressApp.get('/', (req, res) => { + app(req, res) + }) + + appsec.enable(new Config({ + appsec: { + enabled: true, + rules: path.join(__dirname, 'resources', 'rasp_rules.json'), + rasp: { enabled: true } + } + })) + + server = expressApp.listen(0, () => { + const port = server.address().port + axios = Axios.create({ + baseURL: `http://localhost:${port}` + }) + done() + }) + }) + + after(() => { + appsec.disable() + server.close() + return agent.close({ ritmReset: false }) + }) + + describe('Test using Connection', () => { + let connection + + beforeEach(() => { + connection = mysql2.createConnection(connectionData) + connection.connect() + }) + + afterEach((done) => { + connection.end(() => done()) + }) + + describe('query', () => { + it('Should not detect threat', async () => { + app = (req, res) => { + connection.query('SELECT ' + req.query.param, (err) => { + if (err) { + res.statusCode = 500 + } + + res.end() + }) + } + + axios.get('/?param=1') + + await checkRaspExecutedAndNotThreat(agent) + }) + + it('Should block query with callback', async () => { + app = (req, res) => { + connection.query(`SELECT * FROM users WHERE id='${req.query.param}'`, (err) => { + if (err?.name === 'DatadogRaspAbortError') { + res.statusCode = 500 + } + res.end() + }) + } + + try { + await axios.get('/?param=\' OR 1 = 1 --') + } catch (e) { + return await checkRaspExecutedAndHasThreat(agent, 'rasp-sqli-rule-id-2') + } + + assert.fail('Request should be blocked') + }) + }) + + describe('execute', () => { + it('Should not detect threat', async () => { + app = (req, res) => { + connection.execute('SELECT ' + req.query.param, (err) => { + if (err) { + res.statusCode = 500 + } + + res.end() + }) + } + + axios.get('/?param=1') + + await checkRaspExecutedAndNotThreat(agent) + }) + + it('Should block query with callback', async () => { + app = (req, res) => { + connection.execute(`SELECT * FROM users WHERE id='${req.query.param}'`, (err) => { + if (err?.name === 'DatadogRaspAbortError') { + res.statusCode = 500 + } + res.end() + }) + } + + try { + await axios.get('/?param=\' OR 1 = 1 --') + } catch (e) { + return await checkRaspExecutedAndHasThreat(agent, 'rasp-sqli-rule-id-2') + } + + assert.fail('Request should be blocked') + }) + }) + }) + + describe('Test using Pool', () => { + let pool + + beforeEach(() => { + pool = mysql2.createPool(connectionData) + }) + + describe('query', () => { + it('Should not detect threat', async () => { + app = (req, res) => { + pool.query('SELECT ' + req.query.param, (err) => { + if (err) { + res.statusCode = 500 + } + + res.end() + }) + } + + axios.get('/?param=1') + + await checkRaspExecutedAndNotThreat(agent) + }) + + it('Should block query with callback', async () => { + app = (req, res) => { + pool.query(`SELECT * FROM users WHERE id='${req.query.param}'`, (err) => { + if (err?.name === 'DatadogRaspAbortError') { + res.statusCode = 500 + } + res.end() + }) + } + + try { + await axios.get('/?param=\' OR 1 = 1 --') + } catch (e) { + return await checkRaspExecutedAndHasThreat(agent, 'rasp-sqli-rule-id-2') + } + + assert.fail('Request should be blocked') + }) + }) + + describe('execute', () => { + it('Should not detect threat', async () => { + app = (req, res) => { + pool.execute('SELECT ' + req.query.param, (err) => { + if (err) { + res.statusCode = 500 + } + + res.end() + }) + } + + axios.get('/?param=1') + + await checkRaspExecutedAndNotThreat(agent) + }) + + it('Should block query with callback', async () => { + app = (req, res) => { + pool.execute(`SELECT * FROM users WHERE id='${req.query.param}'`, (err) => { + if (err?.name === 'DatadogRaspAbortError') { + res.statusCode = 500 + } + res.end() + }) + } + + try { + await axios.get('/?param=\' OR 1 = 1 --') + } catch (e) { + return await checkRaspExecutedAndHasThreat(agent, 'rasp-sqli-rule-id-2') + } + + assert.fail('Request should be blocked') + }) + }) + }) + }) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/sql_injection.spec.js b/packages/dd-trace/test/appsec/rasp/sql_injection.spec.js index 5467f7ef150..d713521e986 100644 --- a/packages/dd-trace/test/appsec/rasp/sql_injection.spec.js +++ b/packages/dd-trace/test/appsec/rasp/sql_injection.spec.js @@ -1,6 +1,6 @@ 'use strict' -const { pgQueryStart } = require('../../../src/appsec/channels') +const { pgQueryStart, mysql2OuterQueryStart } = require('../../../src/appsec/channels') const addresses = require('../../../src/appsec/addresses') const proxyquire = require('proxyquire') @@ -113,4 +113,69 @@ describe('RASP - sql_injection', () => { sinon.assert.notCalled(waf.run) }) }) + + describe('analyzeMysql2SqlInjection', () => { + it('should analyze sql injection', () => { + const ctx = { + sql: 'SELECT 1' + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + mysql2OuterQueryStart.publish(ctx) + + const persistent = { + [addresses.DB_STATEMENT]: 'SELECT 1', + [addresses.DB_SYSTEM]: 'mysql' + } + sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, 'sql_injection') + }) + + it('should not analyze sql injection if rasp is disabled', () => { + sqli.disable() + + const ctx = { + sql: 'SELECT 1' + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + mysql2OuterQueryStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze sql injection if no store', () => { + const ctx = { + sql: 'SELECT 1' + } + datadogCore.storage.getStore.returns(undefined) + + mysql2OuterQueryStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze sql injection if no req', () => { + const ctx = { + sql: 'SELECT 1' + } + datadogCore.storage.getStore.returns({}) + + mysql2OuterQueryStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze sql injection if no query', () => { + const ctx = { + sql: 'SELECT 1' + } + datadogCore.storage.getStore.returns({}) + + mysql2OuterQueryStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + }) }) diff --git a/packages/dd-trace/test/plugins/externals.json b/packages/dd-trace/test/plugins/externals.json index eddbe0f887c..e0216047fa4 100644 --- a/packages/dd-trace/test/plugins/externals.json +++ b/packages/dd-trace/test/plugins/externals.json @@ -95,6 +95,16 @@ "versions": ["5", ">=6"] } ], + "mysql2": [ + { + "name": "mysql2", + "versions": ["1.3.3"] + }, + { + "name": "express", + "versions": [">=4"] + } + ], "fastify": [ { "name": "fastify",