diff --git a/integration-tests/appsec/index.spec.js b/integration-tests/appsec/index.spec.js new file mode 100644 index 00000000000..bd38eb0468d --- /dev/null +++ b/integration-tests/appsec/index.spec.js @@ -0,0 +1,113 @@ +'use strict' + +const getPort = require('get-port') +const { createSandbox, FakeAgent, spawnProc } = require('../helpers') +const path = require('path') +const Axios = require('axios') +const { assert } = require('chai') + +describe('RASP', () => { + let axios, sandbox, cwd, appPort, appFile, agent, proc, stdioHandler + + function stdOutputHandler (data) { + stdioHandler && stdioHandler(data) + } + + before(async () => { + sandbox = await createSandbox(['express']) + appPort = await getPort() + cwd = sandbox.folder + appFile = path.join(cwd, 'appsec/rasp/index.js') + axios = Axios.create({ + baseURL: `http://localhost:${appPort}` + }) + }) + + beforeEach(async () => { + agent = await new FakeAgent().start() + proc = await spawnProc(appFile, { + cwd, + env: { + 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() + }) + + after(async () => { + await sandbox.remove() + }) + + async function testNotCrashedAfterBlocking (path) { + let hasOutput = false + stdioHandler = () => { + hasOutput = true + } + try { + await axios.get(`${path}?host=ifconfig.pro`) + assert.fail('Request should have failed') + } catch (e) { + if (!e.response) { + throw e + } + assert.strictEqual(e.response.status, 403) + } + return new Promise((resolve, reject) => { + setTimeout(() => { + if (hasOutput) { + reject(new Error('Unexpected output in stdout/stderr after blocking request')) + } else { + resolve() + } + }, 50) + }) + } + + describe('ssrf', () => { + it('should block when error is unhandled', async () => { + try { + await axios.get('/ssrf/http/unhandled-error?host=ifconfig.pro') + assert.fail('Request should have failed') + } catch (e) { + assert.strictEqual(e.response.status, 403) + } + }) + + // Not implemented yet + it('should not crash the app when app send data after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-A') + }) + + it('should not crash the app when app stream data after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-B') + }) + + it('should not crash the app when setHeader, writeHead or end after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-C') + }) + + it('should not crash the app when appendHeader, flushHeaders, removeHeader after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-D') + }) + + it('should not crash the app when writeContinue after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-E') + }) + + it('should not crash the app when writeProcessing after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-F') + }) + + it('should not crash the app when writeEarlyHints after blocking', () => { + return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-G') + }) + }) +}) diff --git a/integration-tests/appsec/rasp/index.js b/integration-tests/appsec/rasp/index.js new file mode 100644 index 00000000000..e2912a08688 --- /dev/null +++ b/integration-tests/appsec/rasp/index.js @@ -0,0 +1,97 @@ +'use strict' + +const path = require('path') +const fs = require('fs') +require('dd-trace').init() + +const http = require('https') +const express = require('express') + +const app = express() +const port = process.env.APP_PORT || 3000 + +app.get('/ping', (req, res) => { + res.end('pong') +}) + +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') + }) +} + +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() + }) +}) + +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')) +} + +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 b8972540e1f..9fdb01cbf9d 100644 --- a/integration-tests/helpers.js +++ b/integration-tests/helpers.js @@ -162,7 +162,7 @@ class FakeAgent extends EventEmitter { } } -function spawnProc (filename, options = {}, stdioHandler) { +function spawnProc (filename, options = {}, stdioHandler, stderrHandler) { const proc = fork(filename, { ...options, stdio: 'pipe' }) return new Promise((resolve, reject) => { proc @@ -187,6 +187,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/packages/dd-trace/src/appsec/blocking.js b/packages/dd-trace/src/appsec/blocking.js index f3d2103b59e..6c14a44c63c 100644 --- a/packages/dd-trace/src/appsec/blocking.js +++ b/packages/dd-trace/src/appsec/blocking.js @@ -118,6 +118,8 @@ function block (req, res, rootSpan, abortController, actionParameters) { res.writeHead(statusCode, headers).end(body) abortController?.abort() + + // TODO add res in blocked weak set when response blocking is merged } function getBlockingAction (actions) { diff --git a/packages/dd-trace/src/appsec/rasp.js b/packages/dd-trace/src/appsec/rasp.js index 268a0a5bd82..940f0aec1ad 100644 --- a/packages/dd-trace/src/appsec/rasp.js +++ b/packages/dd-trace/src/appsec/rasp.js @@ -3,7 +3,28 @@ const { storage } = require('../../../datadog-core') const addresses = require('./addresses') const { httpClientRequestStart } = require('./channels') +const web = require('../plugins/util/web') const waf = require('./waf') +const { getBlockingAction, block } = require('./blocking') + +class AbortError extends Error { + constructor (req, res, blockingAction) { + super('AbortError') + this.name = 'AbortError' + this.req = req + this.res = res + this.blockingAction = blockingAction + } +} + +function handleUncaughtException (err) { + if (err instanceof AbortError) { + const { req, res, blockingAction } = err + block(req, res, web.root(req), null, blockingAction) + } else { + throw err + } +} const RULE_TYPES = { SSRF: 'ssrf' @@ -11,10 +32,14 @@ const RULE_TYPES = { function enable () { httpClientRequestStart.subscribe(analyzeSsrf) + + process.on('uncaughtException', handleUncaughtException) } function disable () { if (httpClientRequestStart.hasSubscribers) httpClientRequestStart.unsubscribe(analyzeSsrf) + + process.off('uncaughtException', handleUncaughtException) } function analyzeSsrf (ctx) { @@ -27,10 +52,20 @@ function analyzeSsrf (ctx) { const persistent = { [addresses.HTTP_OUTGOING_URL]: url } - // TODO: Currently this is only monitoring, we should - // block the request if SSRF attempt and - // generate stack traces - waf.run({ persistent }, req, RULE_TYPES.SSRF) + // TODO: Currently this is monitoring/blocking, we should + // generate stack traces if SSRF attempts + const actions = waf.run({ persistent }, req, RULE_TYPES.SSRF) + + const res = store?.res + handleWafResults(actions, ctx.abortData, req, res) +} + +function handleWafResults (actions, abortData, req, res) { + const blockingAction = getBlockingAction(actions) + if (blockingAction && abortData) { + abortData.abortController.abort() + abortData.error = new AbortError(req, res, blockingAction) + } } module.exports = { 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 23960db1aa3..b588f2b8916 100644 --- a/packages/dd-trace/test/appsec/rasp.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp.express.plugin.spec.js @@ -61,6 +61,26 @@ withVersions('express', 'express', expressVersion => { } describe('ssrf', () => { + async function testBlockingRequest () { + try { + await axios.get('/?host=ifconfig.pro') + assert.fail('Request should be blocked') + } catch (e) { + if (!e.response) { + throw e + } + } + + 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) + }) + } + ['http', 'https'].forEach(protocol => { describe(`Test using ${protocol}`, () => { it('Should not detect threat', async () => { @@ -79,20 +99,16 @@ withVersions('express', 'express', expressVersion => { it('Should detect threat doing a GET request', async () => { app = (req, res) => { - require(protocol).get(`${protocol}://${req.query.host}`) - res.end('end') + const clientRequest = require(protocol).get(`${protocol}://${req.query.host}`) + clientRequest.on('error', (e) => { + if (e.message === 'AbortError') { + res.writeHead(500) + } + res.end('end') + }) } - axios.get('/?host=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) - }) + await testBlockingRequest() }) it('Should detect threat doing a POST request', async () => { @@ -101,19 +117,15 @@ withVersions('express', 'express', expressVersion => { .request(`${protocol}://${req.query.host}`, { method: 'POST' }) clientRequest.write('dummy_post_data') clientRequest.end() - res.end('end') + clientRequest.on('error', (e) => { + if (e.message === 'AbortError') { + res.writeHead(500) + } + res.end('end') + }) } - axios.get('/?host=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) - }) + await testBlockingRequest() }) }) })