From 39749215bd0c7e73d7d1b8ac2e0a65907840632e Mon Sep 17 00:00:00 2001 From: Ugaitz Urien Date: Wed, 24 Jul 2024 09:22:51 +0200 Subject: [PATCH] Exploit prevention ssrf blocking (#4372) --------- Co-authored-by: Igor Unanua Co-authored-by: simon-id --- .github/workflows/appsec.yml | 10 + integration-tests/appsec/index.spec.js | 342 ++++++++++++++++++ integration-tests/appsec/rasp/index.js | 210 +++++++++++ integration-tests/appsec/rasp/rasp_rules.json | 59 +++ integration-tests/appsec/rasp/streamtest.txt | 1 + integration-tests/helpers.js | 5 +- package.json | 1 + .../src/helpers/register.js | 4 + .../src/http/client.js | 8 +- .../src/http/server.js | 63 +++- .../datadog-instrumentations/src/process.js | 29 ++ .../test/http.spec.js | 184 ++++++++++ packages/dd-trace/src/appsec/blocking.js | 11 +- packages/dd-trace/src/appsec/channels.js | 5 +- packages/dd-trace/src/appsec/index.js | 19 +- packages/dd-trace/src/appsec/rasp.js | 128 ++++++- packages/dd-trace/test/appsec/index.spec.js | 34 +- .../test/appsec/rasp.express.plugin.spec.js | 211 +++++++++-- packages/dd-trace/test/appsec/rasp.spec.js | 10 + packages/dd-trace/test/appsec/rasp_rules.json | 3 + packages/dd-trace/test/plugins/externals.json | 4 + 21 files changed, 1277 insertions(+), 64 deletions(-) create mode 100644 integration-tests/appsec/index.spec.js create mode 100644 integration-tests/appsec/rasp/index.js create mode 100644 integration-tests/appsec/rasp/rasp_rules.json create mode 100644 integration-tests/appsec/rasp/streamtest.txt create mode 100644 packages/datadog-instrumentations/src/process.js create mode 100644 packages/datadog-instrumentations/test/http.spec.js diff --git a/.github/workflows/appsec.yml b/.github/workflows/appsec.yml index 9f89c0c6357..ea14dda525c 100644 --- a/.github/workflows/appsec.yml +++ b/.github/workflows/appsec.yml @@ -240,3 +240,13 @@ jobs: - uses: ./.github/actions/node/latest - run: yarn test:appsec:plugins:ci - uses: codecov/codecov-action@v3 + + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: yarn install + - uses: ./.github/actions/node/oldest + - run: yarn test:integration:appsec + - uses: ./.github/actions/node/latest + - run: yarn test:integration:appsec diff --git a/integration-tests/appsec/index.spec.js b/integration-tests/appsec/index.spec.js new file mode 100644 index 00000000000..a7c65b3932d --- /dev/null +++ b/integration-tests/appsec/index.spec.js @@ -0,0 +1,342 @@ +'use strict' + +const getPort = require('get-port') +const path = require('path') +const Axios = require('axios') +const { assert } = require('chai') +const { createSandbox, FakeAgent, spawnProc } = require('../helpers') + +describe('RASP', () => { + let axios, sandbox, cwd, appPort, appFile, agent, proc, stdioHandler + + function stdOutputHandler (data) { + stdioHandler && stdioHandler(data) + } + + before(async () => { + sandbox = await createSandbox(['express', 'axios']) + appPort = await getPort() + cwd = sandbox.folder + appFile = path.join(cwd, 'appsec/rasp/index.js') + axios = Axios.create({ + baseURL: `http://localhost:${appPort}` + }) + }) + + after(async () => { + await sandbox.remove() + }) + + function startServer (abortOnUncaughtException) { + beforeEach(async () => { + let execArgv = process.execArgv + if (abortOnUncaughtException) { + execArgv = ['--abort-on-uncaught-exception', ...execArgv] + } + agent = await new FakeAgent().start() + proc = await spawnProc(appFile, { + cwd, + execArgv, + env: { + DD_TRACE_AGENT_PORT: agent.port, + APP_PORT: appPort, + DD_APPSEC_ENABLED: true, + DD_APPSEC_RASP_ENABLED: true, + DD_APPSEC_RULES: path.join(cwd, 'appsec/rasp/rasp_rules.json') + } + }, stdOutputHandler, stdOutputHandler) + }) + + afterEach(async () => { + proc.kill() + await agent.stop() + }) + } + + async function assertExploitDetected () { + await agent.assertMessageReceived(({ headers, payload }) => { + assert.property(payload[0][0].meta, '_dd.appsec.json') + assert.include(payload[0][0].meta['_dd.appsec.json'], '"test-rule-id-2"') + }) + } + + describe('--abort-on-uncaught-exception is not configured', () => { + startServer(false) + + async function testNotCrashedAfterBlocking (path) { + let hasOutput = false + stdioHandler = () => { + hasOutput = true + } + + try { + await axios.get(`${path}?host=localhost/ifconfig.pro`) + + assert.fail('Request should have failed') + } catch (e) { + if (!e.response) { + throw e + } + + assert.strictEqual(e.response.status, 403) + await assertExploitDetected() + } + + return new Promise((resolve, reject) => { + setTimeout(() => { + if (hasOutput) { + reject(new Error('Unexpected output in stdout/stderr after blocking request')) + } else { + resolve() + } + }, 50) + }) + } + + async function testCustomErrorHandlerIsNotExecuted (path) { + let hasOutput = false + try { + stdioHandler = () => { + hasOutput = true + } + + await axios.get(`${path}?host=localhost/ifconfig.pro`) + + assert.fail('Request should have failed') + } catch (e) { + if (!e.response) { + throw e + } + + assert.strictEqual(e.response.status, 403) + await assertExploitDetected() + + return new Promise((resolve, reject) => { + setTimeout(() => { + if (hasOutput) { + reject(new Error('uncaughtExceptionCaptureCallback executed')) + } else { + resolve() + } + }, 10) + }) + } + } + + async function testAppCrashesAsExpected () { + let hasOutput = false + stdioHandler = () => { + hasOutput = true + } + + try { + await axios.get('/crash') + } catch (e) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (hasOutput) { + resolve() + } else { + reject(new Error('Output expected after crash')) + } + }, 50) + }) + } + + assert.fail('Request should have failed') + } + + describe('ssrf', () => { + it('should crash when error is not an AbortError', async () => { + await testAppCrashesAsExpected() + }) + + it('should not crash when customer has his own setUncaughtExceptionCaptureCallback', async () => { + let hasOutput = false + stdioHandler = () => { + hasOutput = true + } + + try { + await axios.get('/crash-and-recovery-A') + } catch (e) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (hasOutput) { + reject(new Error('Unexpected output in stdout/stderr after blocking request')) + } else { + resolve() + } + }, 50) + }) + } + + assert.fail('Request should have failed') + }) + + it('should not crash when customer has his own uncaughtException', async () => { + let hasOutput = false + stdioHandler = () => { + hasOutput = true + } + + try { + await axios.get('/crash-and-recovery-B') + } catch (e) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (hasOutput) { + reject(new Error('Unexpected output in stdout/stderr after blocking request')) + } else { + resolve() + } + }, 50) + }) + } + + assert.fail('Request should have failed') + }) + + it('should block manually', async () => { + let response + + try { + response = await axios.get('/ssrf/http/manual-blocking?host=localhost/ifconfig.pro') + } catch (e) { + if (!e.response) { + throw e + } + response = e.response + assert.strictEqual(response.status, 418) + return await assertExploitDetected() + } + + assert.fail('Request should have failed') + }) + + it('should block in a domain', async () => { + let response + + try { + response = await axios.get('/ssrf/http/should-block-in-domain?host=localhost/ifconfig.pro') + } catch (e) { + if (!e.response) { + throw e + } + response = e.response + assert.strictEqual(response.status, 403) + return await assertExploitDetected() + } + + assert.fail('Request should have failed') + }) + + it('should crash as expected after block in domain request', async () => { + try { + await axios.get('/ssrf/http/should-block-in-domain?host=localhost/ifconfig.pro') + } catch (e) { + return await testAppCrashesAsExpected() + } + + assert.fail('Request should have failed') + }) + + it('should block when error is unhandled', async () => { + try { + await axios.get('/ssrf/http/unhandled-error?host=localhost/ifconfig.pro') + } catch (e) { + if (!e.response) { + throw e + } + + assert.strictEqual(e.response.status, 403) + return await assertExploitDetected() + } + + assert.fail('Request should have failed') + }) + + it('should crash as expected after a requiest block when error is unhandled', async () => { + try { + await axios.get('/ssrf/http/unhandled-error?host=localhost/ifconfig.pro') + } catch (e) { + return await testAppCrashesAsExpected() + } + + assert.fail('Request should have failed') + }) + + it('should not execute custom uncaughtExceptionCaptureCallback when it is blocked', async () => { + return testCustomErrorHandlerIsNotExecuted('/ssrf/http/custom-uncaught-exception-capture-callback') + }) + + it('should not execute custom uncaughtException listener', async () => { + return testCustomErrorHandlerIsNotExecuted('/ssrf/http/custom-uncaughtException-listener') + }) + + it('should not crash when app send data after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-A') + }) + + it('should not crash when app stream data after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-B') + }) + + it('should not crash when setHeader, writeHead or end after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-C') + }) + + it('should not crash when appendHeader, flushHeaders, removeHeader after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-D') + }) + + it('should not crash when writeContinue after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-E') + }) + + it('should not crash when writeProcessing after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-F') + }) + + it('should not crash when writeEarlyHints after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-G') + }) + + it('should not crash when res.json after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-H') + }) + + it('should not crash when is blocked using axios', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-axios') + }) + + it('should not crash when is blocked with unhandled rejection', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-promise') + }) + }) + }) + + describe('--abort-on-uncaught-exception is configured', () => { + startServer(true) + + describe('ssrf', () => { + it('should not block', async () => { + let response + + try { + response = await axios.get('/ssrf/http/manual-blocking?host=localhost/ifconfig.pro') + } catch (e) { + if (!e.response) { + throw e + } + response = e.response + } + + // not blocked + assert.notEqual(response.status, 418) + assert.notEqual(response.status, 403) + await assertExploitDetected() + }) + }) + }) +}) diff --git a/integration-tests/appsec/rasp/index.js b/integration-tests/appsec/rasp/index.js new file mode 100644 index 00000000000..a2035c6c3b4 --- /dev/null +++ b/integration-tests/appsec/rasp/index.js @@ -0,0 +1,210 @@ +'use strict' +const tracer = require('dd-trace') +tracer.init({ + flushInterval: 0 +}) + +const path = require('path') +const fs = require('fs') +const http = require('https') +const express = require('express') +const axios = require('axios') + +const app = express() +const port = process.env.APP_PORT || 3000 + +function makeOutgoingRequestAndCbAfterTimeout (req, res, cb) { + let finished = false + setTimeout(() => { + if (!finished && cb) { + cb() + } + }, 10) + + http.get(`https://${req.query.host}`, () => { + finished = true + res.send('end') + }) +} + +function streamFile (res) { + const stream = fs.createReadStream(path.join(__dirname, 'streamtest.txt'), { encoding: 'utf8' }) + stream.pipe(res, { end: false }) + stream.on('end', () => res.end('end')) +} + +function httpGetPromise (host) { + return new Promise((resolve, reject) => { + const clientRequest = http.get(`https://${host}`, () => { + resolve() + }) + clientRequest.on('error', reject) + }) +} + +app.get('/crash', () => { + process.nextTick(() => { + throw new Error('Crash') + }) +}) + +app.get('/crash-and-recovery-A', (req, res) => { + process.setUncaughtExceptionCaptureCallback(() => { + res.writeHead(500) + res.end('error') + + process.setUncaughtExceptionCaptureCallback(null) + }) + + process.nextTick(() => { + throw new Error('Crash') + }) +}) + +app.get('/crash-and-recovery-B', (req, res) => { + function exceptionHandler () { + res.writeHead(500) + res.end('error') + + process.off('uncaughtException', exceptionHandler) + } + + process.on('uncaughtException', exceptionHandler) + + process.nextTick(() => { + throw new Error('Crash') + }) +}) + +app.get('/ssrf/http/manual-blocking', (req, res) => { + const clientRequest = http.get(`https://${req.query.host}`, () => { + res.send('end') + }) + + clientRequest.on('error', (err) => { + if (err.name === 'DatadogRaspAbortError') { + res.writeHead(418) + res.end('aborted') + } else { + res.writeHead(500) + res.end('error') + } + }) +}) + +app.get('/ssrf/http/custom-uncaught-exception-capture-callback', (req, res) => { + process.setUncaughtExceptionCaptureCallback(() => { + // wanted a log to force error on tests + // eslint-disable-next-line no-console + console.log('Custom uncaught exception capture callback') + res.writeHead(500) + res.end('error') + }) + + http.get(`https://${req.query.host}`, () => { + res.send('end') + }) +}) + +app.get('/ssrf/http/should-block-in-domain', (req, res) => { + // eslint-disable-next-line n/no-deprecated-api + const d = require('node:domain').create() + d.run(() => { + http.get(`https://${req.query.host}`, () => { + res.send('end') + }) + }) +}) + +app.get('/ssrf/http/custom-uncaughtException-listener', (req, res) => { + process.on('uncaughtException', () => { + // wanted a log to force error on tests + // eslint-disable-next-line no-console + console.log('Custom uncaught exception capture callback') + res.writeHead(500) + res.end('error') + }) + + http.get(`https://${req.query.host}`, () => { + res.send('end') + }) +}) + +app.get('/ssrf/http/unhandled-error', (req, res) => { + makeOutgoingRequestAndCbAfterTimeout(req, res) +}) + +app.get('/ssrf/http/unhandled-async-write-A', (req, res) => { + makeOutgoingRequestAndCbAfterTimeout(req, res, () => { + res.send('Late end') + }) +}) + +app.get('/ssrf/http/unhandled-async-write-B', (req, res) => { + makeOutgoingRequestAndCbAfterTimeout(req, res, () => { + streamFile(res) + }) +}) + +app.get('/ssrf/http/unhandled-async-write-C', (req, res) => { + makeOutgoingRequestAndCbAfterTimeout(req, res, () => { + res.setHeader('key', 'value') + res.writeHead(200, 'OK', ['key2', 'value2']) + res.write('test\n') + res.end('end') + }) +}) + +app.get('/ssrf/http/unhandled-async-write-D', (req, res) => { + makeOutgoingRequestAndCbAfterTimeout(req, res, () => { + res.setHeader('key', 'value') + res.appendHeader?.('key2', 'value2') + res.removeHeader('key') + res.flushHeaders() + res.end('end') + }) +}) + +app.get('/ssrf/http/unhandled-async-write-E', (req, res) => { + makeOutgoingRequestAndCbAfterTimeout(req, res, () => { + res.writeContinue() + res.end() + }) +}) + +app.get('/ssrf/http/unhandled-async-write-F', (req, res) => { + makeOutgoingRequestAndCbAfterTimeout(req, res, () => { + res.writeProcessing() + res.end() + }) +}) + +app.get('/ssrf/http/unhandled-async-write-G', (req, res) => { + makeOutgoingRequestAndCbAfterTimeout(req, res, () => { + const earlyHintsLink = '; rel=preload; as=style' + res.writeEarlyHints?.({ + link: earlyHintsLink + }) + res.end() + }) +}) + +app.get('/ssrf/http/unhandled-async-write-H', (req, res) => { + makeOutgoingRequestAndCbAfterTimeout(req, res, () => { + res.json({ key: 'value' }) + }) +}) + +app.get('/ssrf/http/unhandled-axios', (req, res) => { + axios.get(`https://${req.query.host}`) + .then(() => res.end('end')) +}) + +app.get('/ssrf/http/unhandled-promise', (req, res) => { + httpGetPromise(req.query.host) + .then(() => res.end('end')) +}) + +app.listen(port, () => { + process.send({ port }) +}) diff --git a/integration-tests/appsec/rasp/rasp_rules.json b/integration-tests/appsec/rasp/rasp_rules.json new file mode 100644 index 00000000000..6e6913b0311 --- /dev/null +++ b/integration-tests/appsec/rasp/rasp_rules.json @@ -0,0 +1,59 @@ +{ + "version": "2.2", + "metadata": { + "rules_version": "1.99.0" + }, + "rules": [ + { + "id": "test-rule-id-2", + "name": "Server-side request forgery exploit", + "enabled": true, + "tags": { + "type": "ssrf", + "category": "vulnerability_trigger", + "cwe": "918", + "capec": "1000/225/115/664", + "confidence": "0", + "module": "rasp" + }, + "conditions": [ + { + "parameters": { + "resource": [ + { + "address": "server.io.net.url" + } + ], + "params": [ + { + "address": "server.request.query" + }, + { + "address": "server.request.body" + }, + { + "address": "server.request.path_params" + }, + { + "address": "grpc.server.request.message" + }, + { + "address": "graphql.server.all_resolvers" + }, + { + "address": "graphql.server.resolver" + } + ] + }, + "operator": "ssrf_detector" + } + ], + "transformers": [], + "on_match": [ + "block", + "stack_trace" + ] + } + ] +} + diff --git a/integration-tests/appsec/rasp/streamtest.txt b/integration-tests/appsec/rasp/streamtest.txt new file mode 100644 index 00000000000..9daeafb9864 --- /dev/null +++ b/integration-tests/appsec/rasp/streamtest.txt @@ -0,0 +1 @@ +test diff --git a/integration-tests/helpers.js b/integration-tests/helpers.js index d9911a6f851..ae24451bea3 100644 --- a/integration-tests/helpers.js +++ b/integration-tests/helpers.js @@ -252,7 +252,7 @@ async function runAndCheckWithTelemetry (filename, expectedOut, ...expectedTelem } } -function spawnProc (filename, options = {}, stdioHandler) { +function spawnProc (filename, options = {}, stdioHandler, stderrHandler) { const proc = fork(filename, { ...options, stdio: 'pipe' }) return new Promise((resolve, reject) => { proc @@ -277,6 +277,9 @@ function spawnProc (filename, options = {}, stdioHandler) { }) proc.stderr.on('data', data => { + if (stderrHandler) { + stderrHandler(data) + } // eslint-disable-next-line no-console if (!options.silent) console.error(data.toString()) }) diff --git a/package.json b/package.json index f1abfaa41c5..e7d2861216d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "test:profiler": "tap \"packages/dd-trace/test/profiling/**/*.spec.js\"", "test:profiler:ci": "npm run test:profiler -- --coverage --nyc-arg=--include=\"packages/dd-trace/src/profiling/**/*.js\"", "test:integration": "mocha --timeout 60000 -r \"packages/dd-trace/test/setup/core.js\" \"integration-tests/*.spec.js\"", + "test:integration:appsec": "mocha --timeout 60000 -r \"packages/dd-trace/test/setup/core.js\" \"integration-tests/appsec/*.spec.js\"", "test:integration:cucumber": "mocha --timeout 60000 -r \"packages/dd-trace/test/setup/core.js\" \"integration-tests/cucumber/*.spec.js\"", "test:integration:cypress": "mocha --timeout 60000 -r \"packages/dd-trace/test/setup/core.js\" \"integration-tests/cypress/*.spec.js\"", "test:integration:jest": "mocha --timeout 60000 -r \"packages/dd-trace/test/setup/core.js\" \"integration-tests/jest/*.spec.js\"", diff --git a/packages/datadog-instrumentations/src/helpers/register.js b/packages/datadog-instrumentations/src/helpers/register.js index c410d724731..8444dd93e4e 100644 --- a/packages/datadog-instrumentations/src/helpers/register.js +++ b/packages/datadog-instrumentations/src/helpers/register.js @@ -29,6 +29,10 @@ if (!disabledInstrumentations.has('fetch')) { require('../fetch') } +if (!disabledInstrumentations.has('process')) { + require('../process') +} + const HOOK_SYMBOL = Symbol('hookExportsMap') if (DD_TRACE_DEBUG && DD_TRACE_DEBUG.toLowerCase() !== 'false') { diff --git a/packages/datadog-instrumentations/src/http/client.js b/packages/datadog-instrumentations/src/http/client.js index 213e27a62a8..6bbb8ee411e 100644 --- a/packages/datadog-instrumentations/src/http/client.js +++ b/packages/datadog-instrumentations/src/http/client.js @@ -43,7 +43,9 @@ function patch (http, methodName) { return request.apply(this, arguments) } - const ctx = { args, http } + const abortController = new AbortController() + + const ctx = { args, http, abortController } return startChannel.runStores(ctx, () => { let finished = false @@ -107,6 +109,10 @@ function patch (http, methodName) { return emit.apply(this, arguments) } + if (abortController.signal.aborted) { + req.destroy(abortController.signal.reason || new Error('Aborted')) + } + return req } catch (e) { ctx.error = e diff --git a/packages/datadog-instrumentations/src/http/server.js b/packages/datadog-instrumentations/src/http/server.js index 18a00ac2789..0624c886787 100644 --- a/packages/datadog-instrumentations/src/http/server.js +++ b/packages/datadog-instrumentations/src/http/server.js @@ -12,6 +12,7 @@ const errorServerCh = channel('apm:http:server:request:error') const finishServerCh = channel('apm:http:server:request:finish') const startWriteHeadCh = channel('apm:http:server:response:writeHead:start') const finishSetHeaderCh = channel('datadog:http:server:response:set-header:finish') +const startSetHeaderCh = channel('datadog:http:server:response:set-header:start') const requestFinishedSet = new WeakSet() @@ -24,6 +25,12 @@ addHook({ name: httpNames }, http => { shimmer.wrap(http.ServerResponse.prototype, 'writeHead', wrapWriteHead) shimmer.wrap(http.ServerResponse.prototype, 'write', wrapWrite) shimmer.wrap(http.ServerResponse.prototype, 'end', wrapEnd) + shimmer.wrap(http.ServerResponse.prototype, 'setHeader', wrapSetHeader) + shimmer.wrap(http.ServerResponse.prototype, 'removeHeader', wrapAppendOrRemoveHeader) + // Added in node v16.17.0 + if (http.ServerResponse.prototype.appendHeader) { + shimmer.wrap(http.ServerResponse.prototype, 'appendHeader', wrapAppendOrRemoveHeader) + } return http }) @@ -65,9 +72,7 @@ function wrapEmit (emit) { // TODO: should this always return true ? return this.listenerCount(eventName) > 0 } - if (finishSetHeaderCh.hasSubscribers) { - wrapSetHeader(res) - } + return emit.apply(this, arguments) } catch (err) { errorServerCh.publish(err) @@ -81,16 +86,6 @@ function wrapEmit (emit) { } } -function wrapSetHeader (res) { - shimmer.wrap(res, 'setHeader', setHeader => { - return function (name, value) { - const setHeaderResult = setHeader.apply(this, arguments) - finishSetHeaderCh.publish({ name, value, res }) - return setHeaderResult - } - }) -} - function wrapWriteHead (writeHead) { return function wrappedWriteHead (statusCode, reason, obj) { if (!startWriteHeadCh.hasSubscribers) { @@ -159,6 +154,48 @@ function wrapWrite (write) { } } +function wrapSetHeader (setHeader) { + return function wrappedSetHeader (name, value) { + if (!startSetHeaderCh.hasSubscribers && !finishSetHeaderCh.hasSubscribers) { + return setHeader.apply(this, arguments) + } + + if (startSetHeaderCh.hasSubscribers) { + const abortController = new AbortController() + startSetHeaderCh.publish({ res: this, abortController }) + + if (abortController.signal.aborted) { + return + } + } + + const setHeaderResult = setHeader.apply(this, arguments) + + if (finishSetHeaderCh.hasSubscribers) { + finishSetHeaderCh.publish({ name, value, res: this }) + } + + return setHeaderResult + } +} + +function wrapAppendOrRemoveHeader (originalMethod) { + return function wrappedAppendOrRemoveHeader () { + if (!startSetHeaderCh.hasSubscribers) { + return originalMethod.apply(this, arguments) + } + + const abortController = new AbortController() + startSetHeaderCh.publish({ res: this, abortController }) + + if (abortController.signal.aborted) { + return this + } + + return originalMethod.apply(this, arguments) + } +} + function wrapEnd (end) { return function wrappedEnd () { if (!startWriteHeadCh.hasSubscribers) { diff --git a/packages/datadog-instrumentations/src/process.js b/packages/datadog-instrumentations/src/process.js new file mode 100644 index 00000000000..429b0d8f574 --- /dev/null +++ b/packages/datadog-instrumentations/src/process.js @@ -0,0 +1,29 @@ +'use strict' + +const shimmer = require('../../datadog-shimmer') +const { channel } = require('dc-polyfill') + +const startSetUncaughtExceptionCaptureCallback = channel('datadog:process:setUncaughtExceptionCaptureCallback:start') + +if (process.setUncaughtExceptionCaptureCallback) { + let currentCallback + + shimmer.wrap(process, 'setUncaughtExceptionCaptureCallback', + function wrapSetUncaughtExceptionCaptureCallback (originalSetUncaughtExceptionCaptureCallback) { + return function setUncaughtExceptionCaptureCallback (newCallback) { + if (startSetUncaughtExceptionCaptureCallback.hasSubscribers) { + const abortController = new AbortController() + startSetUncaughtExceptionCaptureCallback.publish({ newCallback, currentCallback, abortController }) + if (abortController.signal.aborted) { + return + } + } + + const result = originalSetUncaughtExceptionCaptureCallback.apply(this, arguments) + + currentCallback = newCallback + + return result + } + }) +} diff --git a/packages/datadog-instrumentations/test/http.spec.js b/packages/datadog-instrumentations/test/http.spec.js new file mode 100644 index 00000000000..ec4e989876f --- /dev/null +++ b/packages/datadog-instrumentations/test/http.spec.js @@ -0,0 +1,184 @@ +'use strict' + +const { assert } = require('chai') +const dc = require('dc-polyfill') + +const agent = require('../../dd-trace/test/plugins/agent') +describe('client', () => { + let url, http, startChannelCb, endChannelCb, asyncStartChannelCb, errorChannelCb + + const startChannel = dc.channel('apm:http:client:request:start') + const endChannel = dc.channel('apm:http:client:request:end') + const asyncStartChannel = dc.channel('apm:http:client:request:asyncStart') + const errorChannel = dc.channel('apm:http:client:request:error') + + before(async () => { + await agent.load('http') + }) + + after(() => { + return agent.close() + }) + + beforeEach(() => { + startChannelCb = sinon.stub() + endChannelCb = sinon.stub() + asyncStartChannelCb = sinon.stub() + errorChannelCb = sinon.stub() + + startChannel.subscribe(startChannelCb) + endChannel.subscribe(endChannelCb) + asyncStartChannel.subscribe(asyncStartChannelCb) + errorChannel.subscribe(errorChannelCb) + }) + + afterEach(() => { + startChannel.unsubscribe(startChannelCb) + endChannel.unsubscribe(endChannelCb) + asyncStartChannel.unsubscribe(asyncStartChannelCb) + errorChannel.unsubscribe(errorChannelCb) + }) + + /* + * Necessary because the tracer makes extra requests to the agent + * and the same stub could be called multiple times + */ + function getContextFromStubByUrl (url, stub) { + for (const args of stub.args) { + const arg = args[0] + if (arg.args?.originalUrl === url) { + return arg + } + } + return null + } + + ['http', 'https'].forEach((httpSchema) => { + describe(`using ${httpSchema}`, () => { + describe('abort controller', () => { + function abortCallback (ctx) { + if (ctx.args.originalUrl === url) { + ctx.abortController.abort() + } + } + + before(() => { + http = require(httpSchema) + url = `${httpSchema}://www.datadoghq.com` + }) + + it('abortController is sent on startChannel', (done) => { + http.get(url, (res) => { + res.on('data', () => {}) + res.on('end', () => { + done() + }) + }) + + sinon.assert.called(startChannelCb) + const ctx = getContextFromStubByUrl(url, startChannelCb) + assert.isNotNull(ctx) + assert.instanceOf(ctx.abortController, AbortController) + }) + + it('Request is aborted', (done) => { + startChannelCb.callsFake(abortCallback) + + const cr = http.get(url, () => { + done(new Error('Request should be blocked')) + }) + + cr.on('error', () => { + done() + }) + }) + + it('Request is aborted with custom error', (done) => { + class CustomError extends Error { } + + startChannelCb.callsFake((ctx) => { + if (ctx.args.originalUrl === url) { + ctx.abortController.abort(new CustomError('Custom error')) + } + }) + + const cr = http.get(url, () => { + done(new Error('Request should be blocked')) + }) + + cr.on('error', (e) => { + try { + assert.instanceOf(e, CustomError) + assert.strictEqual(e.message, 'Custom error') + + done() + } catch (e) { + done(e) + } + }) + }) + + it('Error is sent on errorChannel on abort', (done) => { + startChannelCb.callsFake(abortCallback) + + const cr = http.get(url, () => { + done(new Error('Request should be blocked')) + }) + + cr.on('error', () => { + try { + sinon.assert.calledOnce(errorChannelCb) + assert.instanceOf(errorChannelCb.firstCall.args[0].error, Error) + + done() + } catch (e) { + done(e) + } + }) + }) + + it('endChannel is called on abort', (done) => { + startChannelCb.callsFake(abortCallback) + + const cr = http.get(url, () => { + done(new Error('Request should be blocked')) + }) + + cr.on('error', () => { + try { + sinon.assert.called(endChannelCb) + const ctx = getContextFromStubByUrl(url, endChannelCb) + assert.strictEqual(ctx.args.originalUrl, url) + + done() + } catch (e) { + done(e) + } + }) + }) + + it('asyncStartChannel is not called on abort', (done) => { + startChannelCb.callsFake(abortCallback) + + const cr = http.get(url, () => { + done(new Error('Request should be blocked')) + }) + + cr.on('error', () => { + try { + // Necessary because the tracer makes extra requests to the agent + if (asyncStartChannelCb.called) { + const ctx = getContextFromStubByUrl(url, asyncStartChannelCb) + assert.isNull(ctx) + } + + done() + } catch (e) { + done(e.message) + } + }) + }) + }) + }) + }) +}) diff --git a/packages/dd-trace/src/appsec/blocking.js b/packages/dd-trace/src/appsec/blocking.js index f3d2103b59e..43ef9799c10 100644 --- a/packages/dd-trace/src/appsec/blocking.js +++ b/packages/dd-trace/src/appsec/blocking.js @@ -9,6 +9,8 @@ let templateHtml = blockedTemplates.html let templateJson = blockedTemplates.json let templateGraphqlJson = blockedTemplates.graphqlJson +const responseBlockedSet = new WeakSet() + const specificBlockingTypes = { GRAPHQL: 'graphql' } @@ -117,6 +119,8 @@ function block (req, res, rootSpan, abortController, actionParameters) { res.writeHead(statusCode, headers).end(body) + responseBlockedSet.add(res) + abortController?.abort() } @@ -144,11 +148,16 @@ function setTemplates (config) { } } +function isBlocked (res) { + return responseBlockedSet.has(res) +} + module.exports = { addSpecificEndpoint, block, specificBlockingTypes, getBlockingData, getBlockingAction, - setTemplates + setTemplates, + isBlocked } diff --git a/packages/dd-trace/src/appsec/channels.js b/packages/dd-trace/src/appsec/channels.js index 82e7eb5f6f7..66781d88821 100644 --- a/packages/dd-trace/src/appsec/channels.js +++ b/packages/dd-trace/src/appsec/channels.js @@ -19,5 +19,8 @@ module.exports = { nextQueryParsed: dc.channel('apm:next:query-parsed'), responseBody: dc.channel('datadog:express:response:json:start'), responseWriteHead: dc.channel('apm:http:server:response:writeHead:start'), - httpClientRequestStart: dc.channel('apm:http:client:request:start') + httpClientRequestStart: dc.channel('apm:http:client:request:start'), + responseSetHeader: dc.channel('datadog:http:server:response:set-header:start'), + setUncaughtExceptionCaptureCallbackStart: dc.channel('datadog:process:setUncaughtExceptionCaptureCallback:start') + } diff --git a/packages/dd-trace/src/appsec/index.js b/packages/dd-trace/src/appsec/index.js index d3f156c274f..10e63ebd2de 100644 --- a/packages/dd-trace/src/appsec/index.js +++ b/packages/dd-trace/src/appsec/index.js @@ -13,7 +13,8 @@ const { nextBodyParsed, nextQueryParsed, responseBody, - responseWriteHead + responseWriteHead, + responseSetHeader } = require('./channels') const waf = require('./waf') const addresses = require('./addresses') @@ -23,7 +24,7 @@ const apiSecuritySampler = require('./api_security_sampler') const web = require('../plugins/util/web') const { extractIp } = require('../plugins/util/ip_extractor') const { HTTP_CLIENT_IP } = require('../../../../ext/tags') -const { block, setTemplates, getBlockingAction } = require('./blocking') +const { isBlocked, block, setTemplates, getBlockingAction } = require('./blocking') const { passportTrackEvent } = require('./passport') const { storage } = require('../../../datadog-core') const graphql = require('./graphql') @@ -62,6 +63,7 @@ function enable (_config) { cookieParser.subscribe(onRequestCookieParser) responseBody.subscribe(onResponseBody) responseWriteHead.subscribe(onResponseWriteHead) + responseSetHeader.subscribe(onResponseSetHeader) if (_config.appsec.eventTracking.enabled) { passportVerify.subscribe(onPassportVerify) @@ -223,11 +225,10 @@ function onPassportVerify ({ credentials, user }) { } const responseAnalyzedSet = new WeakSet() -const responseBlockedSet = new WeakSet() function onResponseWriteHead ({ req, res, abortController, statusCode, responseHeaders }) { // avoid "write after end" error - if (responseBlockedSet.has(res)) { + if (isBlocked(res)) { abortController?.abort() return } @@ -255,15 +256,18 @@ function onResponseWriteHead ({ req, res, abortController, statusCode, responseH handleResults(results, req, res, rootSpan, abortController) } +function onResponseSetHeader ({ res, abortController }) { + if (isBlocked(res)) { + abortController?.abort() + } +} + function handleResults (actions, req, res, rootSpan, abortController) { if (!actions || !req || !res || !rootSpan || !abortController) return const blockingAction = getBlockingAction(actions) if (blockingAction) { block(req, res, rootSpan, abortController, blockingAction) - if (!abortController.signal || abortController.signal.aborted) { - responseBlockedSet.add(res) - } } } @@ -290,6 +294,7 @@ function disable () { if (responseBody.hasSubscribers) responseBody.unsubscribe(onResponseBody) if (passportVerify.hasSubscribers) passportVerify.unsubscribe(onPassportVerify) if (responseWriteHead.hasSubscribers) responseWriteHead.unsubscribe(onResponseWriteHead) + if (responseSetHeader.hasSubscribers) responseSetHeader.unsubscribe(onResponseSetHeader) } module.exports = { diff --git a/packages/dd-trace/src/appsec/rasp.js b/packages/dd-trace/src/appsec/rasp.js index cede7f3355c..de13c33e4e9 100644 --- a/packages/dd-trace/src/appsec/rasp.js +++ b/packages/dd-trace/src/appsec/rasp.js @@ -3,23 +3,118 @@ const { storage } = require('../../../datadog-core') const web = require('./../plugins/util/web') const addresses = require('./addresses') -const { httpClientRequestStart } = require('./channels') +const { httpClientRequestStart, setUncaughtExceptionCaptureCallbackStart } = require('./channels') const { reportStackTrace } = require('./stack_trace') const waf = require('./waf') +const { getBlockingAction, block } = require('./blocking') +const log = require('../log') const RULE_TYPES = { SSRF: 'ssrf' } -let config +class DatadogRaspAbortError extends Error { + constructor (req, res, blockingAction) { + super('DatadogRaspAbortError') + this.name = 'DatadogRaspAbortError' + this.req = req + this.res = res + this.blockingAction = blockingAction + } +} + +let config, abortOnUncaughtException + +function removeAllListeners (emitter, event) { + const listeners = emitter.listeners(event) + emitter.removeAllListeners(event) + + let cleaned = false + return function () { + if (cleaned === true) { + return + } + cleaned = true + + for (let i = 0; i < listeners.length; ++i) { + emitter.on(event, listeners[i]) + } + } +} + +function findDatadogRaspAbortError (err, deep = 10) { + if (err instanceof DatadogRaspAbortError) { + return err + } + + if (err.cause && deep > 0) { + return findDatadogRaspAbortError(err.cause, deep - 1) + } +} + +function handleUncaughtExceptionMonitor (err) { + const abortError = findDatadogRaspAbortError(err) + if (!abortError) return + + const { req, res, blockingAction } = abortError + block(req, res, web.root(req), null, blockingAction) + + if (!process.hasUncaughtExceptionCaptureCallback()) { + const cleanUp = removeAllListeners(process, 'uncaughtException') + const handler = () => { + process.removeListener('uncaughtException', handler) + } + + setTimeout(() => { + process.removeListener('uncaughtException', handler) + cleanUp() + }) + + process.on('uncaughtException', handler) + } else { + // uncaughtException event is not executed when hasUncaughtExceptionCaptureCallback is true + let previousCb + const cb = ({ currentCallback, abortController }) => { + setUncaughtExceptionCaptureCallbackStart.unsubscribe(cb) + if (!currentCallback) { + abortController.abort() + return + } + + previousCb = currentCallback + } + + setUncaughtExceptionCaptureCallbackStart.subscribe(cb) + + process.setUncaughtExceptionCaptureCallback(null) + + // For some reason, previous callback was defined before the instrumentation + // We can not restore it, so we let the app decide + if (previousCb) { + process.setUncaughtExceptionCaptureCallback(() => { + process.setUncaughtExceptionCaptureCallback(null) + process.setUncaughtExceptionCaptureCallback(previousCb) + }) + } + } +} function enable (_config) { config = _config httpClientRequestStart.subscribe(analyzeSsrf) + + process.on('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor) + abortOnUncaughtException = process.execArgv?.includes('--abort-on-uncaught-exception') + + if (abortOnUncaughtException) { + log.warn('The --abort-on-uncaught-exception flag is enabled. The RASP module will not block operations.') + } } function disable () { if (httpClientRequestStart.hasSubscribers) httpClientRequestStart.unsubscribe(analyzeSsrf) + + process.off('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor) } function analyzeSsrf (ctx) { @@ -32,17 +127,18 @@ function analyzeSsrf (ctx) { const persistent = { [addresses.HTTP_OUTGOING_URL]: url } - // TODO: Currently this is only monitoring, we should - // block the request if SSRF attempt + const result = waf.run({ persistent }, req, RULE_TYPES.SSRF) - handleResult(result, req) + + const res = store?.res + handleResult(result, req, res, ctx.abortController) } function getGenerateStackTraceAction (actions) { return actions?.generate_stack } -function handleResult (actions, req) { +function handleResult (actions, req, res, abortController) { const generateStackTraceAction = getGenerateStackTraceAction(actions) if (generateStackTraceAction && config.appsec.stackTrace.enabled) { const rootSpan = web.root(req) @@ -53,10 +149,28 @@ function handleResult (actions, req) { config.appsec.stackTrace.maxStackTraces ) } + + if (!abortController || abortOnUncaughtException) return + + const blockingAction = getBlockingAction(actions) + if (blockingAction) { + const rootSpan = web.root(req) + // Should block only in express + if (rootSpan?.context()._name === 'express.request') { + const abortError = new DatadogRaspAbortError(req, res, blockingAction) + abortController.abort(abortError) + + // TODO Delete this when support for node 16 is removed + if (!abortController.signal.reason) { + abortController.signal.reason = abortError + } + } + } } module.exports = { enable, disable, - handleResult + handleResult, + handleUncaughtExceptionMonitor // exported only for testing purpose } diff --git a/packages/dd-trace/test/appsec/index.spec.js b/packages/dd-trace/test/appsec/index.spec.js index f8ed077c703..f3d68c4a1bf 100644 --- a/packages/dd-trace/test/appsec/index.spec.js +++ b/packages/dd-trace/test/appsec/index.spec.js @@ -13,7 +13,8 @@ const { queryParser, passportVerify, responseBody, - responseWriteHead + responseWriteHead, + responseSetHeader } = require('../../src/appsec/channels') const Reporter = require('../../src/appsec/reporter') const agent = require('../plugins/agent') @@ -169,6 +170,7 @@ describe('AppSec Index', function () { expect(queryParser.hasSubscribers).to.be.false expect(passportVerify.hasSubscribers).to.be.false expect(responseWriteHead.hasSubscribers).to.be.false + expect(responseSetHeader.hasSubscribers).to.be.false AppSec.enable(config) @@ -177,6 +179,7 @@ describe('AppSec Index', function () { expect(queryParser.hasSubscribers).to.be.true expect(passportVerify.hasSubscribers).to.be.true expect(responseWriteHead.hasSubscribers).to.be.true + expect(responseSetHeader.hasSubscribers).to.be.true }) it('should not subscribe to passportVerify if eventTracking is disabled', () => { @@ -254,6 +257,7 @@ describe('AppSec Index', function () { expect(queryParser.hasSubscribers).to.be.false expect(passportVerify.hasSubscribers).to.be.false expect(responseWriteHead.hasSubscribers).to.be.false + expect(responseSetHeader.hasSubscribers).to.be.false }) it('should call appsec telemetry disable', () => { @@ -917,6 +921,34 @@ describe('AppSec Index', function () { expect(res.end).to.have.been.calledOnce }) }) + + describe('onResponseSetHeader', () => { + it('should call abortController if response was already blocked', () => { + // First block the request + sinon.stub(waf, 'run').returns(resultActions) + + const responseHeaders = { + 'content-type': 'application/json', + 'content-lenght': 42, + 'set-cookie': 'a=1;b=2' + } + responseWriteHead.publish({ req, res, abortController, statusCode: 404, responseHeaders }) + + expect(abortController.abort).to.have.been.calledOnce + + abortController.abort.reset() + + responseSetHeader.publish({ res, abortController }) + + expect(abortController.abort).to.have.been.calledOnce + }) + + it('should not call abortController if response was not blocked', () => { + responseSetHeader.publish({ res, abortController }) + + expect(abortController.abort).to.have.not.been.calledOnce + }) + }) }) describe('Metrics', () => { diff --git a/packages/dd-trace/test/appsec/rasp.express.plugin.spec.js b/packages/dd-trace/test/appsec/rasp.express.plugin.spec.js index 0229984b6fa..75924c88283 100644 --- a/packages/dd-trace/test/appsec/rasp.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp.express.plugin.spec.js @@ -9,12 +9,23 @@ const { assert } = require('chai') function noop () {} -withVersions('express', 'express', expressVersion => { - describe('RASP', () => { +describe('RASP', () => { + function getWebSpan (traces) { + for (const trace of traces) { + for (const span of trace) { + if (span.type === 'web') { + return span + } + } + } + throw new Error('web span not found') + } + + withVersions('express', 'express', expressVersion => { let app, server, axios before(() => { - return agent.load(['http'], { client: false }) + return agent.load(['express', 'http'], { client: false }) }) before((done) => { @@ -48,18 +59,28 @@ withVersions('express', 'express', expressVersion => { return agent.close({ ritmReset: false }) }) - function getWebSpan (traces) { - for (const trace of traces) { - for (const span of trace) { - if (span.type === 'web') { - return span + describe('ssrf', () => { + async function testBlockingRequest () { + try { + await axios.get('/?host=localhost/ifconfig.pro') + } catch (e) { + if (!e.response) { + throw e } + return await agent.use((traces) => { + const span = getWebSpan(traces) + assert.property(span.meta, '_dd.appsec.json') + assert(span.meta['_dd.appsec.json'].includes('rasp-ssrf-rule-id-1')) + assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1) + assert(span.metrics['_dd.appsec.rasp.duration'] > 0) + assert(span.metrics['_dd.appsec.rasp.duration_ext'] > 0) + assert.property(span.meta_struct, '_dd.stack') + }) } + + assert.fail('Request should be blocked') } - throw new Error('web span not found') - } - describe('ssrf', () => { ['http', 'https'].forEach(protocol => { describe(`Test using ${protocol}`, () => { it('Should not detect threat', async () => { @@ -81,47 +102,173 @@ withVersions('express', 'express', expressVersion => { it('Should detect threat doing a GET request', async () => { app = (req, res) => { const clientRequest = require(protocol).get(`${protocol}://${req.query.host}`) - clientRequest.on('error', noop) - res.end('end') + clientRequest.on('error', (e) => { + if (e.message === 'DatadogRaspAbortError') { + res.writeHead(500) + } + res.end('end') + }) } - axios.get('/?host=localhost/ifconfig.pro') - - await agent.use((traces) => { - const span = getWebSpan(traces) - assert.property(span.meta, '_dd.appsec.json') - assert(span.meta['_dd.appsec.json'].includes('rasp-ssrf-rule-id-1')) - assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1) - assert(span.metrics['_dd.appsec.rasp.duration'] > 0) - assert(span.metrics['_dd.appsec.rasp.duration_ext'] > 0) - assert.property(span.meta_struct, '_dd.stack') - }) + await testBlockingRequest() }) it('Should detect threat doing a POST request', async () => { app = (req, res) => { const clientRequest = require(protocol) .request(`${protocol}://${req.query.host}`, { method: 'POST' }) - clientRequest.on('error', noop) clientRequest.write('dummy_post_data') clientRequest.end() + clientRequest.on('error', (e) => { + if (e.message === 'DatadogRaspAbortError') { + res.writeHead(500) + } + res.end('end') + }) + } + + await testBlockingRequest() + }) + }) + }) + + describe('Test using axios', () => { + withVersions('express', 'axios', axiosVersion => { + let axiosToTest + + beforeEach(() => { + axiosToTest = require(`../../../../versions/axios@${axiosVersion}`).get() + }) + + it('Should not detect threat', async () => { + app = (req, res) => { + axiosToTest.get(`https://${req.query.host}`) res.end('end') } - axios.get('/?host=localhost/ifconfig.pro') + axios.get('/?host=www.datadoghq.com') await agent.use((traces) => { const span = getWebSpan(traces) - assert.property(span.meta, '_dd.appsec.json') - assert(span.meta['_dd.appsec.json'].includes('rasp-ssrf-rule-id-1')) - assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1) - assert(span.metrics['_dd.appsec.rasp.duration'] > 0) - assert(span.metrics['_dd.appsec.rasp.duration_ext'] > 0) - assert.property(span.meta_struct, '_dd.stack') + assert.notProperty(span.meta, '_dd.appsec.json') }) }) + + it('Should detect threat doing a GET request', async () => { + app = async (req, res) => { + try { + await axiosToTest.get(`https://${req.query.host}`) + res.end('end') + } catch (e) { + if (e.cause.message === 'DatadogRaspAbortError') { + res.writeHead(500) + } + res.end('end') + } + } + + await testBlockingRequest() + }) + + it('Should detect threat doing a POST request', async () => { + app = async (req, res) => { + try { + await axiosToTest.post(`https://${req.query.host}`, { key: 'value' }) + } catch (e) { + if (e.cause.message === 'DatadogRaspAbortError') { + res.writeHead(500) + } + res.end('end') + } + } + + await testBlockingRequest() + }) }) }) }) }) + + describe('without express', () => { + let app, server, axios + + before(() => { + return agent.load(['http'], { client: false }) + }) + + before((done) => { + const http = require('http') + server = http.createServer((req, res) => { + if (app) { + app(req, res) + } else { + res.end('end') + } + }) + + appsec.enable(new Config({ + appsec: { + enabled: true, + rules: path.join(__dirname, 'rasp_rules.json'), + rasp: { enabled: true } + } + })) + + server.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 }) + }) + + it('Should detect threat without blocking doing a GET request', async () => { + app = (req, res) => { + const clientRequest = require('http').get(`http://${req.headers.host}`, { timeout: 10 }, function () { + res.end('end') + }) + + clientRequest.on('timeout', () => { + res.writeHead(200) + res.end('timeout') + }) + + clientRequest.on('error', (e) => { + if (e.name !== 'DatadogRaspAbortError') { + res.writeHead(200) + res.end('not-blocking-error') + } else { + res.writeHead(500) + res.end('unexpected-blocking-error') + } + }) + } + + const response = await axios.get('/', { + headers: { + host: 'localhost/ifconfig.pro' + } + }) + + assert.equal(response.status, 200) + + await agent.use((traces) => { + const span = getWebSpan(traces) + assert.property(span.meta, '_dd.appsec.json') + assert(span.meta['_dd.appsec.json'].includes('rasp-ssrf-rule-id-1')) + assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1) + assert(span.metrics['_dd.appsec.rasp.duration'] > 0) + assert(span.metrics['_dd.appsec.rasp.duration_ext'] > 0) + assert.property(span.meta_struct, '_dd.stack') + }) + }) + }) }) diff --git a/packages/dd-trace/test/appsec/rasp.spec.js b/packages/dd-trace/test/appsec/rasp.spec.js index f4ccc7aaf30..c594a2a98c2 100644 --- a/packages/dd-trace/test/appsec/rasp.spec.js +++ b/packages/dd-trace/test/appsec/rasp.spec.js @@ -3,6 +3,7 @@ const proxyquire = require('proxyquire') const { httpClientRequestStart } = require('../../src/appsec/channels') const addresses = require('../../src/appsec/addresses') +const { handleUncaughtExceptionMonitor } = require('../../src/appsec/rasp') describe('RASP', () => { let waf, rasp, datadogCore, stackTrace, web @@ -167,4 +168,13 @@ describe('RASP', () => { sinon.assert.notCalled(waf.run) }) }) + + describe('handleUncaughtExceptionMonitor', () => { + it('should not break with infinite loop of cause', () => { + const err = new Error() + err.cause = err + + handleUncaughtExceptionMonitor(err) + }) + }) }) diff --git a/packages/dd-trace/test/appsec/rasp_rules.json b/packages/dd-trace/test/appsec/rasp_rules.json index 7b01675dcaa..28930412b9a 100644 --- a/packages/dd-trace/test/appsec/rasp_rules.json +++ b/packages/dd-trace/test/appsec/rasp_rules.json @@ -34,6 +34,9 @@ { "address": "server.request.path_params" }, + { + "address": "server.request.headers.no_cookies" + }, { "address": "grpc.server.request.message" }, diff --git a/packages/dd-trace/test/plugins/externals.json b/packages/dd-trace/test/plugins/externals.json index be17310a924..80b3b2147f2 100644 --- a/packages/dd-trace/test/plugins/externals.json +++ b/packages/dd-trace/test/plugins/externals.json @@ -54,6 +54,10 @@ } ], "express": [ + { + "name": "axios", + "versions": [">=1.0.0"] + }, { "name": "loopback", "versions": [">=2.38.1"]