diff --git a/packages/datadog-instrumentations/src/fastify.js b/packages/datadog-instrumentations/src/fastify.js index 8fb772d00a6..7949e332609 100644 --- a/packages/datadog-instrumentations/src/fastify.js +++ b/packages/datadog-instrumentations/src/fastify.js @@ -11,6 +11,7 @@ const queryParamsReadCh = channel('datadog:fastify:query-params:finish') const cookieParserReadCh = channel('datadog:fastify-cookie:read:finish') const responsePayloadReadCh = channel('datadog:fastify:response:finish') const pathParamsReadCh = channel('datadog:fastify:path-params:finish') +const finishSetHeaderCh = channel('datadog:fastify:set-header:finish') const parsingResources = new WeakMap() const cookiesPublished = new WeakSet() @@ -275,3 +276,23 @@ addHook({ name: 'fastify', versions: ['2'] }, fastify => { addHook({ name: 'fastify', versions: ['1'] }, fastify => { return shimmer.wrapFunction(fastify, fastify => wrapFastify(fastify, false)) }) + +function wrapReplyHeader (Reply) { + shimmer.wrap(Reply.prototype, 'header', header => function (key, value) { + const result = header.apply(this, arguments) + + if (finishSetHeaderCh.hasSubscribers && key && value) { + finishSetHeaderCh.publish({ name: key, value, res: getRes(this) }) + } + + return result + }) + + return Reply +} + +addHook({ name: 'fastify', file: 'lib/reply.js', versions: ['1'] }, wrapReplyHeader) + +addHook({ name: 'fastify', file: 'lib/reply.js', versions: ['2'] }, wrapReplyHeader) + +addHook({ name: 'fastify', file: 'lib/reply.js', versions: ['>=3'] }, wrapReplyHeader) diff --git a/packages/dd-trace/src/appsec/iast/analyzers/cookie-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/cookie-analyzer.js index f422e5b363c..9cc9e86e9be 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/cookie-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/cookie-analyzer.js @@ -3,7 +3,14 @@ const Analyzer = require('./vulnerability-analyzer') const { getNodeModulesPaths } = require('../path-line') -const EXCLUDED_PATHS = getNodeModulesPaths('express/lib/response.js') +const EXCLUDED_PATHS = [ + // Express + getNodeModulesPaths('express/lib/response.js'), + // Fastify + getNodeModulesPaths('fastify/lib/reply.js'), + getNodeModulesPaths('fastify/lib/hooks.js'), + getNodeModulesPaths('@fastify/cookie/plugin.js') +] class CookieAnalyzer extends Analyzer { constructor (type, propertyToBeSafe) { diff --git a/packages/dd-trace/src/appsec/iast/analyzers/hsts-header-missing-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/hsts-header-missing-analyzer.js index da1d1a8fa49..fa7efb4f227 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/hsts-header-missing-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/hsts-header-missing-analyzer.js @@ -10,8 +10,8 @@ class HstsHeaderMissingAnalyzer extends MissingHeaderAnalyzer { super(HSTS_HEADER_MISSING, HSTS_HEADER_NAME) } - _isVulnerableFromRequestAndResponse (req, res) { - const headerValues = this._getHeaderValues(res, HSTS_HEADER_NAME) + _isVulnerableFromRequestAndResponse (req, res, storedHeaders) { + const headerValues = this._getHeaderValues(res, storedHeaders, HSTS_HEADER_NAME) return this._isHttpsProtocol(req) && ( headerValues.length === 0 || headerValues.some(headerValue => !this._isHeaderValid(headerValue)) diff --git a/packages/dd-trace/src/appsec/iast/analyzers/missing-header-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/missing-header-analyzer.js index d8a847a4042..8d832e6ba8b 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/missing-header-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/missing-header-analyzer.js @@ -28,8 +28,9 @@ class MissingHeaderAnalyzer extends Analyzer { }, (data) => this.analyze(data)) } - _getHeaderValues (res, headerName) { - const headerValue = res.getHeader(headerName) + _getHeaderValues (res, storedHeaders, headerName) { + headerName = headerName.toLowerCase() + const headerValue = res.getHeader(headerName) || storedHeaders[headerName] if (Array.isArray(headerValue)) { return headerValue } @@ -46,8 +47,8 @@ class MissingHeaderAnalyzer extends Analyzer { return `${type}:${this.config.tracerConfig.service}` } - _getEvidence ({ res }) { - const headerValues = this._getHeaderValues(res, this.headerName) + _getEvidence ({ res, storedHeaders }) { + const headerValues = this._getHeaderValues(res, storedHeaders, this.headerName) let value if (headerValues.length === 1) { value = headerValues[0] @@ -57,19 +58,19 @@ class MissingHeaderAnalyzer extends Analyzer { return { value } } - _isVulnerable ({ req, res }, context) { - if (!IGNORED_RESPONSE_STATUS_LIST.has(res.statusCode) && this._isResponseHtml(res)) { - return this._isVulnerableFromRequestAndResponse(req, res) + _isVulnerable ({ req, res, storedHeaders }, context) { + if (!IGNORED_RESPONSE_STATUS_LIST.has(res.statusCode) && this._isResponseHtml(res, storedHeaders)) { + return this._isVulnerableFromRequestAndResponse(req, res, storedHeaders) } return false } - _isVulnerableFromRequestAndResponse (req, res) { + _isVulnerableFromRequestAndResponse (req, res, storedHeaders) { return false } - _isResponseHtml (res) { - const contentTypes = this._getHeaderValues(res, 'content-type') + _isResponseHtml (res, storedHeaders) { + const contentTypes = this._getHeaderValues(res, storedHeaders, 'content-type') return contentTypes.some(contentType => { return contentType && HTML_CONTENT_TYPES.some(htmlContentType => { return htmlContentType === contentType || contentType.startsWith(htmlContentType + ';') diff --git a/packages/dd-trace/src/appsec/iast/analyzers/set-cookies-header-interceptor.js b/packages/dd-trace/src/appsec/iast/analyzers/set-cookies-header-interceptor.js index c7de02c6e53..a880a4cd84f 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/set-cookies-header-interceptor.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/set-cookies-header-interceptor.js @@ -7,25 +7,32 @@ class SetCookiesHeaderInterceptor extends Plugin { constructor () { super() this.cookiesInRequest = new WeakMap() - this.addSub('datadog:http:server:response:set-header:finish', ({ name, value, res }) => { - if (name.toLowerCase() === 'set-cookie') { - let allCookies = value - if (typeof value === 'string') { - allCookies = [value] - } - const alreadyCheckedCookies = this._getAlreadyCheckedCookiesInResponse(res) - - let location - allCookies.forEach(cookieString => { - if (!alreadyCheckedCookies.includes(cookieString)) { - alreadyCheckedCookies.push(cookieString) - const parsedCookie = this._parseCookie(cookieString, location) - setCookieChannel.publish(parsedCookie) - location = parsedCookie.location - } - }) + + this.addSub('datadog:http:server:response:set-header:finish', + ({ name, value, res }) => this._handleCookies(name, value, res)) + + this.addSub('datadog:fastify:set-header:finish', + ({ name, value, res }) => this._handleCookies(name, value, res)) + } + + _handleCookies (name, value, res) { + if (name.toLowerCase() === 'set-cookie') { + let allCookies = value + if (typeof value === 'string') { + allCookies = [value] } - }) + const alreadyCheckedCookies = this._getAlreadyCheckedCookiesInResponse(res) + + let location + allCookies.forEach(cookieString => { + if (!alreadyCheckedCookies.includes(cookieString)) { + alreadyCheckedCookies.push(cookieString) + const parsedCookie = this._parseCookie(cookieString, location) + setCookieChannel.publish(parsedCookie) + location = parsedCookie.location + } + }) + } } _parseCookie (cookieString, location) { diff --git a/packages/dd-trace/src/appsec/iast/analyzers/unvalidated-redirect-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/unvalidated-redirect-analyzer.js index c2e485c1c9a..67057dddeef 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/unvalidated-redirect-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/unvalidated-redirect-analyzer.js @@ -9,7 +9,10 @@ const { HTTP_REQUEST_PARAMETER } = require('../taint-tracking/source-types') -const EXCLUDED_PATHS = getNodeModulesPaths('express/lib/response.js') +const EXCLUDED_PATHS = [ + getNodeModulesPaths('express/lib/response.js'), + getNodeModulesPaths('fastify/lib/reply.js'), +] const VULNERABLE_SOURCE_TYPES = new Set([ HTTP_REQUEST_BODY, @@ -23,6 +26,7 @@ class UnvalidatedRedirectAnalyzer extends InjectionAnalyzer { onConfigure () { this.addSub('datadog:http:server:response:set-header:finish', ({ name, value }) => this.analyze(name, value)) + this.addSub('datadog:fastify:set-header:finish', ({ name, value }) => this.analyze(name, value)) } analyze (name, value) { diff --git a/packages/dd-trace/src/appsec/iast/analyzers/xcontenttype-header-missing-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/xcontenttype-header-missing-analyzer.js index 6d114d0b168..665249bfabb 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/xcontenttype-header-missing-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/xcontenttype-header-missing-analyzer.js @@ -10,8 +10,8 @@ class XcontenttypeHeaderMissingAnalyzer extends MissingHeaderAnalyzer { super(XCONTENTTYPE_HEADER_MISSING, XCONTENTTYPEOPTIONS_HEADER_NAME) } - _isVulnerableFromRequestAndResponse (req, res) { - const headerValues = this._getHeaderValues(res, XCONTENTTYPEOPTIONS_HEADER_NAME) + _isVulnerableFromRequestAndResponse (req, res, storedHeaders) { + const headerValues = this._getHeaderValues(res, storedHeaders, XCONTENTTYPEOPTIONS_HEADER_NAME) return headerValues.length === 0 || headerValues.some(headerValue => headerValue.trim().toLowerCase() !== 'nosniff') } } diff --git a/packages/dd-trace/src/appsec/iast/iast-plugin.js b/packages/dd-trace/src/appsec/iast/iast-plugin.js index 2ea51e3f4a2..9c6dc57c921 100644 --- a/packages/dd-trace/src/appsec/iast/iast-plugin.js +++ b/packages/dd-trace/src/appsec/iast/iast-plugin.js @@ -47,6 +47,10 @@ class IastPluginSubscription { } matchesModuleInstrumented (name) { + // Remove node: prefix if present + if (name.startsWith('node:')) { + name = name.slice(5) + } // https module is a special case because it's events are published as http name = name === 'https' ? 'http' : name return this.moduleName === name diff --git a/packages/dd-trace/src/appsec/iast/index.js b/packages/dd-trace/src/appsec/iast/index.js index 1f16df27eea..6c80f19be6b 100644 --- a/packages/dd-trace/src/appsec/iast/index.js +++ b/packages/dd-trace/src/appsec/iast/index.js @@ -18,11 +18,12 @@ const { IAST_ENABLED_TAG_KEY } = require('./tags') const iastTelemetry = require('./telemetry') const { enable: enableFsPlugin, disable: disableFsPlugin, IAST_MODULE } = require('../rasp/fs-plugin') const securityControls = require('./security-controls') +const { incomingHttpRequestStart, incomingHttpRequestEnd, responseWriteHead } = require('../channels') + +const collectedResponseHeaders = new WeakMap() // TODO Change to `apm:http:server:request:[start|close]` when the subscription // order of the callbacks can be enforce -const requestStart = dc.channel('dd-trace:incomingHttpRequestStart') -const requestClose = dc.channel('dd-trace:incomingHttpRequestEnd') const iastResponseEnd = dc.channel('datadog:iast:response-end') let isEnabled = false @@ -33,8 +34,9 @@ function enable (config, _tracer) { enableFsPlugin(IAST_MODULE) enableAllAnalyzers(config) enableTaintTracking(config.iast, iastTelemetry.verbosity) - requestStart.subscribe(onIncomingHttpRequestStart) - requestClose.subscribe(onIncomingHttpRequestEnd) + incomingHttpRequestStart.subscribe(onIncomingHttpRequestStart) + incomingHttpRequestEnd.subscribe(onIncomingHttpRequestEnd) + responseWriteHead.subscribe(onResponseWriteHeadCollect) overheadController.configure(config.iast) overheadController.startGlobalContext() securityControls.configure(config.iast) @@ -53,8 +55,9 @@ function disable () { disableAllAnalyzers() disableTaintTracking() overheadController.finishGlobalContext() - if (requestStart.hasSubscribers) requestStart.unsubscribe(onIncomingHttpRequestStart) - if (requestClose.hasSubscribers) requestClose.unsubscribe(onIncomingHttpRequestEnd) + if (incomingHttpRequestStart.hasSubscribers) incomingHttpRequestStart.unsubscribe(onIncomingHttpRequestStart) + if (incomingHttpRequestEnd.hasSubscribers) incomingHttpRequestEnd.unsubscribe(onIncomingHttpRequestEnd) + if (responseWriteHead.hasSubscribers) responseWriteHead.unsubscribe(onResponseWriteHeadCollect) vulnerabilityReporter.stop() } @@ -89,7 +92,13 @@ function onIncomingHttpRequestEnd (data) { const topContext = web.getContext(data.req) const iastContext = iastContextFunctions.getIastContext(store, topContext) if (iastContext?.rootSpan) { - iastResponseEnd.publish(data) + const storedHeaders = collectedResponseHeaders.get(data.res) || {} + + iastResponseEnd.publish({ ...data, storedHeaders }) + + if (storedHeaders) { + collectedResponseHeaders.delete(data.res) + } const vulnerabilities = iastContext.vulnerabilities const rootSpan = iastContext.rootSpan @@ -105,4 +114,13 @@ function onIncomingHttpRequestEnd (data) { } } +// Response headers are collected here because they are not available in the onIncomingHttpRequestEnd when using Fastify +function onResponseWriteHeadCollect ({ res, responseHeaders = {} }) { + if (!res) return + + if (Object.keys(responseHeaders).length) { + collectedResponseHeaders.set(res, responseHeaders) + } +} + module.exports = { enable, disable, onIncomingHttpRequestEnd, onIncomingHttpRequestStart } diff --git a/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js b/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js index c106ec9a6a3..36725d754a5 100644 --- a/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js +++ b/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js @@ -38,6 +38,25 @@ class TaintTrackingPlugin extends SourceIastPlugin { } onConfigure () { + this.addBodyParsingSubscriptions() + + this.addQueryParameterSubscriptions() + + this.addCookieSubscriptions() + + this.addDatabaseSubscriptions() + + this.addPathParameterSubscriptions() + + this.addGraphQLSubscriptions() + + this.addURLParsingSubscriptions() + + // this is a special case to increment INSTRUMENTED_SOURCE metric for header + this.addInstrumentedSource('http', [HTTP_REQUEST_HEADER_VALUE, HTTP_REQUEST_HEADER_NAME]) + } + + addBodyParsingSubscriptions () { const onRequestBody = ({ req }) => { const iastContext = getIastContext(storage('legacy').getStore()) if (iastContext && iastContext.body !== req.body) { @@ -57,17 +76,13 @@ class TaintTrackingPlugin extends SourceIastPlugin { ) this.addSub( - { channelName: 'datadog:query:read:finish', tag: HTTP_REQUEST_PARAMETER }, - ({ query }) => this._taintTrackingHandler(HTTP_REQUEST_PARAMETER, query) - ) - - this.addSub( - { channelName: 'datadog:express:query:finish', tag: HTTP_REQUEST_PARAMETER }, - ({ query }) => { + { channelName: 'datadog:fastify:body-parser:finish', tag: HTTP_REQUEST_BODY }, + ({ req, body }) => { const iastContext = getIastContext(storage('legacy').getStore()) - if (!iastContext || !query) return - - taintQueryWithCache(iastContext, query) + if (iastContext && iastContext.body !== body) { + this._taintTrackingHandler(HTTP_REQUEST_BODY, body) + iastContext.body = body + } } ) @@ -83,12 +98,45 @@ class TaintTrackingPlugin extends SourceIastPlugin { } } ) + } + + addQueryParameterSubscriptions () { + this.addSub( + { channelName: 'datadog:query:read:finish', tag: HTTP_REQUEST_PARAMETER }, + ({ query }) => this._taintTrackingHandler(HTTP_REQUEST_PARAMETER, query) + ) + + this.addSub( + { channelName: 'datadog:fastify:query-params:finish', tag: HTTP_REQUEST_PARAMETER }, + ({ query }) => { + this._taintTrackingHandler(HTTP_REQUEST_PARAMETER, query) + } + ) + this.addSub( + { channelName: 'datadog:express:query:finish', tag: HTTP_REQUEST_PARAMETER }, + ({ query }) => { + const iastContext = getIastContext(storage('legacy').getStore()) + if (!iastContext || !query) return + + taintQueryWithCache(iastContext, query) + } + ) + } + + addCookieSubscriptions () { this.addSub( { channelName: 'datadog:cookie:parse:finish', tag: [HTTP_REQUEST_COOKIE_VALUE, HTTP_REQUEST_COOKIE_NAME] }, ({ cookies }) => this._cookiesTaintTrackingHandler(cookies) ) + this.addSub( + { channelName: 'datadog:fastify-cookie:read:finish', tag: [HTTP_REQUEST_COOKIE_VALUE, HTTP_REQUEST_COOKIE_NAME] }, + ({ cookies }) => this._cookiesTaintTrackingHandler(cookies) + ) + } + + addDatabaseSubscriptions () { this.addSub( { channelName: 'datadog:sequelize:query:finish', tag: SQL_ROW_VALUE }, ({ result }) => this._taintDatabaseResult(result, 'sequelize') @@ -98,25 +146,36 @@ class TaintTrackingPlugin extends SourceIastPlugin { { channelName: 'apm:pg:query:finish', tag: SQL_ROW_VALUE }, ({ result }) => this._taintDatabaseResult(result, 'pg') ) + } + + addPathParameterSubscriptions () { + const pathParamHandler = ({ req }) => { + if (req && req.params !== null && typeof req.params === 'object') { + this._taintTrackingHandler(HTTP_REQUEST_PATH_PARAM, req, 'params') + } + } this.addSub( { channelName: 'datadog:express:process_params:start', tag: HTTP_REQUEST_PATH_PARAM }, - ({ req }) => { - if (req && req.params !== null && typeof req.params === 'object') { - this._taintTrackingHandler(HTTP_REQUEST_PATH_PARAM, req, 'params') - } - } + pathParamHandler ) this.addSub( { channelName: 'datadog:router:param:start', tag: HTTP_REQUEST_PATH_PARAM }, - ({ req }) => { - if (req && req.params !== null && typeof req.params === 'object') { - this._taintTrackingHandler(HTTP_REQUEST_PATH_PARAM, req, 'params') + pathParamHandler + ) + + this.addSub( + { channelName: 'datadog:fastify:path-params:finish', tag: HTTP_REQUEST_PATH_PARAM }, + ({ req, params }) => { + if (req) { + this._taintTrackingHandler(HTTP_REQUEST_PATH_PARAM, params) } } ) + } + addGraphQLSubscriptions () { this.addSub( { channelName: 'apm:graphql:resolve:start', tag: HTTP_REQUEST_BODY }, (data) => { @@ -128,7 +187,9 @@ class TaintTrackingPlugin extends SourceIastPlugin { } } ) + } + addURLParsingSubscriptions () { const urlResultTaintedProperties = ['host', 'origin', 'hostname'] this.addSub( { channelName: 'datadog:url:parse:finish' }, @@ -162,9 +223,6 @@ class TaintTrackingPlugin extends SourceIastPlugin { context.result = newTaintedString(iastContext, context.result, origRange.iinfo.parameterName, origRange.iinfo.type) }) - - // this is a special case to increment INSTRUMENTED_SOURCE metric for header - this.addInstrumentedSource('http', [HTTP_REQUEST_HEADER_VALUE, HTTP_REQUEST_HEADER_NAME]) } _taintTrackingHandler (type, target, property, iastContext = getIastContext(storage('legacy').getStore())) { diff --git a/packages/dd-trace/test/appsec/iast/analyzers/unvalidated-redirect-analyzer.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/unvalidated-redirect-analyzer.spec.js index d038f7d023b..ebab45cbbb6 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/unvalidated-redirect-analyzer.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/unvalidated-redirect-analyzer.spec.js @@ -97,9 +97,11 @@ describe('unvalidated-redirect-analyzer', () => { unvalidatedRedirectAnalyzer.configure(true) it('should subscribe to set-header:finish channel', () => { - expect(unvalidatedRedirectAnalyzer._subscriptions).to.have.lengthOf(1) + expect(unvalidatedRedirectAnalyzer._subscriptions).to.have.lengthOf(2) expect(unvalidatedRedirectAnalyzer._subscriptions[0]._channel.name).to .equals('datadog:http:server:response:set-header:finish') + expect(unvalidatedRedirectAnalyzer._subscriptions[1]._channel.name).to + .equals('datadog:fastify:set-header:finish') }) it('should not report headers other than Location', () => { diff --git a/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.fastify.plugin.spec.js b/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.fastify.plugin.spec.js new file mode 100644 index 00000000000..26d0b94bcdf --- /dev/null +++ b/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.fastify.plugin.spec.js @@ -0,0 +1,154 @@ +'use strict' + +const { prepareTestServerForIastInFastify } = require('../utils') +const axios = require('axios') +const { URL } = require('url') + +function noop () {} + +describe('Taint tracking plugin sources fastify tests', () => { + withVersions('fastify', 'fastify', '>=2', version => { + prepareTestServerForIastInFastify('in fastify', version, + (testThatRequestHasVulnerability, _, config) => { + describe('tainted body', () => { + function makePostRequest (done) { + axios.post(`http://localhost:${config.port}/`, { + command: 'echo 1' + }).catch(done) + } + + testThatRequestHasVulnerability((req) => { + const childProcess = require('child_process') + childProcess.exec(req.body.command, noop) + }, 'COMMAND_INJECTION', 1, noop, makePostRequest) + }) + + describe('tainted query param', () => { + function makeRequestWithQueryParam (done) { + axios.get(`http://localhost:${config.port}/?command=echo`).catch(done) + } + + testThatRequestHasVulnerability((req) => { + const childProcess = require('child_process') + childProcess.exec(req.query.command, noop) + }, 'COMMAND_INJECTION', 1, noop, makeRequestWithQueryParam) + }) + + describe('tainted header', () => { + function makeRequestWithHeader (done) { + axios.get(`http://localhost:${config.port}/`, { + headers: { + 'x-iast-test-command': 'echo 1' + } + }).catch(done) + } + + testThatRequestHasVulnerability((req) => { + const childProcess = require('child_process') + childProcess.exec(req.headers['x-iast-test-command'], noop) + }, 'COMMAND_INJECTION', 1, noop, makeRequestWithHeader) + }) + + describe('url parse taint tracking', () => { + function makePostRequest (done) { + axios.post(`http://localhost:${config.port}/`, { + url: 'http://www.datadoghq.com/' + }).catch(done) + } + + testThatRequestHasVulnerability( + { + fn: (req) => { + // eslint-disable-next-line n/no-deprecated-api + const { parse } = require('url') + const url = parse(req.body.url) + + const childProcess = require('child_process') + childProcess.exec(url.host, noop) + }, + vulnerability: 'COMMAND_INJECTION', + occurrences: 1, + cb: noop, + makeRequest: makePostRequest, + testDescription: 'should detect vulnerability when tainted is coming from url.parse' + }) + + testThatRequestHasVulnerability( + { + fn: (req) => { + const { URL } = require('url') + const url = new URL(req.body.url) + + const childProcess = require('child_process') + childProcess.exec(url.host, noop) + }, + vulnerability: 'COMMAND_INJECTION', + occurrences: 1, + cb: noop, + makeRequest: makePostRequest, + testDescription: 'should detect vulnerability when tainted is coming from new url.URL input' + }) + + testThatRequestHasVulnerability( + { + fn: (req) => { + const { URL } = require('url') + const url = new URL('/path', req.body.url) + + const childProcess = require('child_process') + childProcess.exec(url.host, noop) + }, + vulnerability: 'COMMAND_INJECTION', + occurrences: 1, + cb: noop, + makeRequest: makePostRequest, + testDescription: 'should detect vulnerability when tainted is coming from new url.URL base' + }) + + if (URL.parse) { + testThatRequestHasVulnerability( + { + fn: (req) => { + const { URL } = require('url') + const url = URL.parse(req.body.url) + const childProcess = require('child_process') + childProcess.exec(url.host, noop) + }, + vulnerability: 'COMMAND_INJECTION', + occurrences: 1, + cb: noop, + makeRequest: makePostRequest, + testDescription: 'should detect vulnerability when tainted is coming from url.URL.parse input' + }) + + testThatRequestHasVulnerability( + { + fn: (req) => { + const { URL } = require('url') + const url = URL.parse('/path', req.body.url) + const childProcess = require('child_process') + childProcess.exec(url.host, noop) + }, + vulnerability: 'COMMAND_INJECTION', + occurrences: 1, + cb: noop, + makeRequest: makePostRequest, + testDescription: 'should detect vulnerability when tainted is coming from url.URL.parse base' + }) + } + }) + + describe('tainted path parameters', () => { + function makeRequestWithPathParam (done) { + axios.get(`http://localhost:${config.port}/?id=malicious-path`).catch(done) + } + + testThatRequestHasVulnerability((req) => { + const childProcess = require('child_process') + childProcess.exec(req.query.id, noop) + }, 'COMMAND_INJECTION', 1, noop, makeRequestWithPathParam) + }) + } + ) + }) +}) diff --git a/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js b/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js index 5d15dafdf28..678c18b4981 100644 --- a/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/taint-tracking/plugin.spec.js @@ -49,20 +49,25 @@ describe('IAST Taint tracking plugin', () => { }) it('Should subscribe to body parser, qs, cookie and process_params channel', () => { - expect(taintTrackingPlugin._subscriptions).to.have.lengthOf(13) - expect(taintTrackingPlugin._subscriptions[0]._channel.name).to.equals('datadog:body-parser:read:finish') - expect(taintTrackingPlugin._subscriptions[1]._channel.name).to.equals('datadog:multer:read:finish') - expect(taintTrackingPlugin._subscriptions[2]._channel.name).to.equals('datadog:query:read:finish') - expect(taintTrackingPlugin._subscriptions[3]._channel.name).to.equals('datadog:express:query:finish') - expect(taintTrackingPlugin._subscriptions[4]._channel.name).to.equals('apm:express:middleware:next') - expect(taintTrackingPlugin._subscriptions[5]._channel.name).to.equals('datadog:cookie:parse:finish') - expect(taintTrackingPlugin._subscriptions[6]._channel.name).to.equals('datadog:sequelize:query:finish') - expect(taintTrackingPlugin._subscriptions[7]._channel.name).to.equals('apm:pg:query:finish') - expect(taintTrackingPlugin._subscriptions[8]._channel.name).to.equals('datadog:express:process_params:start') - expect(taintTrackingPlugin._subscriptions[9]._channel.name).to.equals('datadog:router:param:start') - expect(taintTrackingPlugin._subscriptions[10]._channel.name).to.equals('apm:graphql:resolve:start') - expect(taintTrackingPlugin._subscriptions[11]._channel.name).to.equals('datadog:url:parse:finish') - expect(taintTrackingPlugin._subscriptions[12]._channel.name).to.equals('datadog:url:getter:finish') + expect(taintTrackingPlugin._subscriptions).to.have.lengthOf(17) + let i = 0 + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:body-parser:read:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:multer:read:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:fastify:body-parser:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('apm:express:middleware:next') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:query:read:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:fastify:query-params:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:express:query:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:cookie:parse:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:fastify-cookie:read:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:sequelize:query:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('apm:pg:query:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:express:process_params:start') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:router:param:start') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:fastify:path-params:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('apm:graphql:resolve:start') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:url:parse:finish') + expect(taintTrackingPlugin._subscriptions[i++]._channel.name).to.equals('datadog:url:getter:finish') }) describe('taint sources', () => { diff --git a/packages/dd-trace/test/appsec/iast/taint-tracking/sources/taint-tracking.fastify.plugin.spec.js b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/taint-tracking.fastify.plugin.spec.js new file mode 100644 index 00000000000..2db27074a31 --- /dev/null +++ b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/taint-tracking.fastify.plugin.spec.js @@ -0,0 +1,134 @@ +'use strict' + +const axios = require('axios') +const agent = require('../../../../plugins/agent') +const Config = require('../../../../../src/config') +const { storage } = require('../../../../../../datadog-core') +const iast = require('../../../../../src/appsec/iast') +const iastContextFunctions = require('../../../../../src/appsec/iast/iast-context') +const { isTainted, getRanges } = require('../../../../../src/appsec/iast/taint-tracking/operations') +const { + HTTP_REQUEST_PATH_PARAM, + HTTP_REQUEST_URI +} = require('../../../../../src/appsec/iast/taint-tracking/source-types') + +describe('URI sourcing with fastify', () => { + let fastify + let appInstance + + withVersions('fastify', 'fastify', version => { + before(() => { + return agent.load(['http', 'fastify'], { client: false }) + }) + + after(() => { + return agent.close({ ritmReset: false }) + }) + + beforeEach(() => { + iast.enable(new Config({ + experimental: { + iast: { + enabled: true, + requestSampling: 100 + } + } + })) + + fastify = require(`../../../../../../../versions/fastify@${version}`).get() + }) + + afterEach(() => { + if (appInstance) { + appInstance.close() + appInstance = null + } + iast.disable() + }) + + it('should taint uri', async () => { + appInstance = fastify() + + appInstance.get('/path/*', (request, reply) => { + const store = storage('legacy').getStore() + const iastContext = iastContextFunctions.getIastContext(store) + const isPathTainted = isTainted(iastContext, request.raw.url) + expect(isPathTainted).to.be.true + const taintedPathValueRanges = getRanges(iastContext, request.raw.url) + expect(taintedPathValueRanges[0].iinfo.type).to.be.equal(HTTP_REQUEST_URI) + reply.code(200).send() + }) + + await appInstance.listen({ port: 0 }) + + const port = appInstance.server.address().port + + const response = await axios + .get(`http://localhost:${port}/path/vulnerable`) + expect(response.status).to.be.equal(200) + }) + }) +}) + +describe('Path params sourcing with fastify', () => { + let fastify + let appInstance + + withVersions('fastify', 'fastify', version => { + before(() => { + return agent.load(['http', 'fastify'], { client: false }) + }) + + after(() => { + return agent.close({ ritmReset: false }) + }) + + beforeEach(() => { + iast.enable(new Config({ + experimental: { + iast: { + enabled: true, + requestSampling: 100 + } + } + })) + + fastify = require(`../../../../../../../versions/fastify@${version}`).get() + }) + + afterEach(() => { + if (appInstance) { + appInstance.close() + appInstance = null + } + iast.disable() + }) + + it('should taint path params', async () => { + appInstance = fastify() + + appInstance.get('/:parameter1/:parameter2', (request, reply) => { + const store = storage('legacy').getStore() + const iastContext = iastContextFunctions.getIastContext(store) + + for (const pathParamName of ['parameter1', 'parameter2']) { + const pathParamValue = request.params[pathParamName] + const isParameterTainted = isTainted(iastContext, pathParamValue) + expect(isParameterTainted).to.be.true + const taintedParameterValueRanges = getRanges(iastContext, pathParamValue) + expect(taintedParameterValueRanges[0].iinfo.type).to.be.equal(HTTP_REQUEST_PATH_PARAM) + } + + reply.code(200).send() + }) + + await appInstance.listen({ port: 0 }) + + const port = appInstance.server.address().port + + const response = await axios + .get(`http://localhost:${port}/tainted1/tainted2`) + expect(response.status).to.be.equal(200) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/iast/utils.js b/packages/dd-trace/test/appsec/iast/utils.js index 932f23e5d3c..a1f174bd0c3 100644 --- a/packages/dd-trace/test/appsec/iast/utils.js +++ b/packages/dd-trace/test/appsec/iast/utils.js @@ -410,6 +410,114 @@ function prepareTestServerForIastInExpress (description, expressVersion, loadMid }) } +function prepareTestServerForIastInFastify (description, fastifyVersion, tests, iastConfig) { + describe(description, () => { + const config = {} + let app, server + + before(function () { + return agent.load(['fastify', 'http'], { client: false }, { flushInterval: 1 }) + }) + + before(async () => { + const fastify = require(`../../../../../versions/fastify@${fastifyVersion}`).get() + const fastifyApp = fastify() + + fastifyApp.all('/', (request, reply) => { + const headersSent = () => { + if (reply.raw && typeof reply.raw.headersSent !== 'undefined') { + return reply.raw.headersSent + } + // Fastify <3: use reply.sent + return reply.sent === true + } + + try { + const result = app && app(request, reply.raw) + + const finish = () => { + if (!headersSent()) { + reply.code(200).send() + } + } + + if (result && typeof result.then === 'function') { + result.then(finish).catch(() => finish()) + } else { + finish() + } + } catch (e) { + if (!headersSent()) { + reply.code(500).send() + } else if (reply.raw && typeof reply.raw.end === 'function') { + reply.raw.end() + } + } + }) + + await fastifyApp.listen({ port: 0 }) + + server = fastifyApp.server + config.port = server.address().port + }) + + beforeEachIastTest(iastConfig) + + afterEach(() => { + iast.disable() + rewriter.disable() + app = null + }) + + after(() => { + server?.close() + return agent?.close({ ritmReset: false }) + }) + + function testThatRequestHasVulnerability (fn, vulnerability, occurrencesAndLocation, cb, makeRequest) { + let testDescription + if (fn !== null && typeof fn === 'object') { + const obj = fn + fn = obj.fn + vulnerability = obj.vulnerability + occurrencesAndLocation = obj.occurrencesAndLocation || obj.occurrences + cb = obj.cb + makeRequest = obj.makeRequest + testDescription = obj.testDescription || testDescription + } + + testDescription = testDescription || `should have ${vulnerability} vulnerability` + + it(testDescription, function (done) { + this.timeout(5000) + app = fn + + checkVulnerabilityInRequest(vulnerability, occurrencesAndLocation, cb, makeRequest, config, done) + }) + } + + function testThatRequestHasNoVulnerability (fn, vulnerability, makeRequest) { + let testDescription + if (fn !== null && typeof fn === 'object') { + const obj = fn + fn = obj.fn + vulnerability = obj.vulnerability + makeRequest = obj.makeRequest + testDescription = obj.testDescription || testDescription + } + + testDescription = testDescription || `should not have ${vulnerability} vulnerability` + + it(testDescription, function (done) { + app = fn + checkNoVulnerabilityInRequest(vulnerability, config, done, makeRequest) + }) + } + + tests(testThatRequestHasVulnerability, testThatRequestHasNoVulnerability, config) + }) +} + function locationHasMatchingFrame (span, vulnerabilityType, vulnerabilities) { const stack = msgpack.decode(span.meta_struct['_dd.stack']) const matchingVulns = vulnerabilities.filter(vulnerability => vulnerability.type === vulnerabilityType) @@ -438,5 +546,6 @@ module.exports = { testInRequest, copyFileToTmp, prepareTestServerForIast, - prepareTestServerForIastInExpress + prepareTestServerForIastInExpress, + prepareTestServerForIastInFastify }