Skip to content

Commit 75856dd

Browse files
authored
Fastify RASP support (#6081)
1 parent aed71a8 commit 75856dd

File tree

14 files changed

+528
-53
lines changed

14 files changed

+528
-53
lines changed

packages/dd-trace/src/appsec/blocking.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ let defaultBlockingActionParameters
1414

1515
const responseBlockedSet = new WeakSet()
1616

17+
const blockDelegations = new WeakMap()
18+
1719
const specificBlockingTypes = {
1820
GRAPHQL: 'graphql'
1921
}
@@ -99,6 +101,9 @@ function getBlockingData (req, specificType, actionParameters) {
99101
}
100102

101103
function block (req, res, rootSpan, abortController, actionParameters = defaultBlockingActionParameters) {
104+
// synchronous blocking overrides previously created delegations
105+
blockDelegations.delete(res)
106+
102107
try {
103108
if (res.headersSent) {
104109
log.warn('[ASM] Cannot send blocking response when headers have already been sent')
@@ -127,11 +132,33 @@ function block (req, res, rootSpan, abortController, actionParameters = defaultB
127132
rootSpan?.setTag('_dd.appsec.block.failed', 1)
128133
log.error('[ASM] Blocking error', err)
129134

135+
// TODO: if blocking fails, then the response will never be sent ?
136+
130137
updateBlockFailureMetric(req)
131138
return false
132139
}
133140
}
134141

142+
function registerBlockDelegation (req, res) {
143+
const args = arguments
144+
145+
return new Promise((resolve) => {
146+
// ignore subsequent delegations by never calling their resolve()
147+
if (blockDelegations.has(res)) return
148+
149+
blockDelegations.set(res, { args, resolve })
150+
})
151+
}
152+
153+
function callBlockDelegation (res) {
154+
const delegation = blockDelegations.get(res)
155+
if (delegation) {
156+
const result = block.apply(this, delegation.args)
157+
delegation.resolve(result)
158+
return result
159+
}
160+
}
161+
135162
function getBlockingAction (actions) {
136163
// waf only returns one action, but it prioritizes redirect over block
137164
return actions?.redirect_request || actions?.block_request
@@ -158,6 +185,8 @@ function setDefaultBlockingActionParameters (actions) {
158185
module.exports = {
159186
addSpecificEndpoint,
160187
block,
188+
registerBlockDelegation,
189+
callBlockDelegation,
161190
specificBlockingTypes,
162191
getBlockingData,
163192
getBlockingAction,

packages/dd-trace/src/appsec/channels.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ module.exports = {
1414
expressProcessParams: dc.channel('datadog:express:process_params:start'),
1515
expressSession: dc.channel('datadog:express-session:middleware:finish'),
1616
fastifyBodyParser: dc.channel('datadog:fastify:body-parser:finish'),
17-
fastifyResponseChannel: dc.channel('datadog:fastify:response:finish'),
18-
fastifyQueryParams: dc.channel('datadog:fastify:query-params:finish'),
1917
fastifyCookieParser: dc.channel('datadog:fastify-cookie:read:finish'),
18+
fastifyMiddlewareError: dc.channel('apm:fastify:middleware:error'),
2019
fastifyPathParams: dc.channel('datadog:fastify:path-params:finish'),
20+
fastifyQueryParams: dc.channel('datadog:fastify:query-params:finish'),
21+
fastifyResponseChannel: dc.channel('datadog:fastify:response:finish'),
2122
fsOperationStart: dc.channel('apm:fs:operation:start'),
2223
graphqlMiddlewareChannel: dc.tracingChannel('datadog:apollo:middleware'),
2324
httpClientRequestStart: dc.channel('apm:http:client:request:start'),

packages/dd-trace/src/appsec/index.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const apiSecuritySampler = require('./api_security_sampler')
3434
const web = require('../plugins/util/web')
3535
const { extractIp } = require('../plugins/util/ip_extractor')
3636
const { HTTP_CLIENT_IP } = require('../../../../ext/tags')
37-
const { isBlocked, block, setTemplates, getBlockingAction } = require('./blocking')
37+
const { isBlocked, block, callBlockDelegation, setTemplates, getBlockingAction } = require('./blocking')
3838
const UserTracking = require('./user_tracking')
3939
const { storage } = require('../../../datadog-core')
4040
const graphql = require('./graphql')
@@ -306,8 +306,13 @@ function onResponseWriteHead ({ req, res, abortController, statusCode, responseH
306306
storedResponseHeaders.set(req, responseHeaders)
307307
}
308308

309+
// TODO: do not call waf if inside block()
310+
// if (isBlocking()) {
311+
// return
312+
// }
313+
309314
// avoid "write after end" error
310-
if (isBlocked(res)) {
315+
if (isBlocked(res) || callBlockDelegation(res)) {
311316
abortController?.abort()
312317
return
313318
}

packages/dd-trace/src/appsec/rasp/fs-plugin.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class AppsecFsPlugin extends Plugin {
3535
this.addBind('apm:fs:operation:finish', this._onFsOperationFinishOrRenderEnd)
3636
this.addBind('tracing:datadog:express:response:render:start', this._onResponseRenderStart)
3737
this.addBind('tracing:datadog:express:response:render:end', this._onFsOperationFinishOrRenderEnd)
38+
// We might have to add the same subscribers for fastify later
3839

3940
super.configure(true)
4041
}

packages/dd-trace/src/appsec/rasp/index.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
'use strict'
22

33
const web = require('../../plugins/util/web')
4-
const { setUncaughtExceptionCaptureCallbackStart, expressMiddlewareError } = require('../channels')
5-
const { block, isBlocked } = require('../blocking')
4+
const {
5+
setUncaughtExceptionCaptureCallbackStart,
6+
expressMiddlewareError,
7+
fastifyMiddlewareError
8+
} = require('../channels')
9+
const { block, registerBlockDelegation, isBlocked } = require('../blocking')
610
const ssrf = require('./ssrf')
711
const sqli = require('./sql_injection')
812
const lfi = require('./lfi')
@@ -39,7 +43,7 @@ function findDatadogRaspAbortError (err, deep = 10) {
3943
}
4044

4145
function handleUncaughtExceptionMonitor (error) {
42-
if (!blockOnDatadogRaspAbortError({ error })) return
46+
if (!blockOnDatadogRaspAbortError({ error, isTopLevel: true })) return
4347

4448
if (process.hasUncaughtExceptionCaptureCallback()) {
4549
// uncaughtException event is not executed when hasUncaughtExceptionCaptureCallback is true
@@ -81,15 +85,22 @@ function handleUncaughtExceptionMonitor (error) {
8185
}
8286
}
8387

84-
function blockOnDatadogRaspAbortError ({ error }) {
88+
function blockOnDatadogRaspAbortError ({ error, isTopLevel }) {
8589
const abortError = findDatadogRaspAbortError(error)
8690
if (!abortError) return false
8791

8892
const { req, res, blockingAction, raspRule, ruleTriggered } = abortError
8993
if (!isBlocked(res)) {
90-
const blocked = block(req, res, web.root(req), null, blockingAction)
94+
const blockFn = isTopLevel ? block : registerBlockDelegation
95+
const blocked = blockFn(req, res, web.root(req), null, blockingAction)
9196
if (ruleTriggered) {
92-
updateRaspRuleMatchMetricTags(req, raspRule, true, blocked)
97+
// block() returns a bool, and registerBlockDelegation() returns a promise
98+
// we use Promise.resolve() to handle both cases
99+
Promise.resolve(blocked).then(blocked => {
100+
// TODO: bug: this metric is not called when the raspAbortError is caught by user
101+
// or on subsequent blockDelegations
102+
updateRaspRuleMatchMetricTags(req, raspRule, true, blocked)
103+
})
93104
}
94105
}
95106

@@ -103,7 +114,9 @@ function enable (config) {
103114
cmdi.enable(config)
104115

105116
process.on('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor)
117+
106118
expressMiddlewareError.subscribe(blockOnDatadogRaspAbortError)
119+
fastifyMiddlewareError.subscribe(blockOnDatadogRaspAbortError)
107120
}
108121

109122
function disable () {
@@ -113,7 +126,9 @@ function disable () {
113126
cmdi.disable()
114127

115128
process.off('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor)
116-
if (expressMiddlewareError.hasSubscribers) expressMiddlewareError.unsubscribe(blockOnDatadogRaspAbortError)
129+
130+
expressMiddlewareError.unsubscribe(blockOnDatadogRaspAbortError)
131+
fastifyMiddlewareError.unsubscribe(blockOnDatadogRaspAbortError)
117132
}
118133

119134
module.exports = {

packages/dd-trace/src/appsec/rasp/utils.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const RULE_TYPES = {
1919
SSRF: 'ssrf'
2020
}
2121

22+
const ALLOWED_ROOTSPAN_NAMES = new Set([
23+
'express.request',
24+
'fastify.request'
25+
])
26+
2227
class DatadogRaspAbortError extends Error {
2328
constructor (req, res, blockingAction, raspRule, ruleTriggered) {
2429
super('DatadogRaspAbortError')
@@ -28,6 +33,12 @@ class DatadogRaspAbortError extends Error {
2833
this.blockingAction = blockingAction
2934
this.raspRule = raspRule
3035
this.ruleTriggered = ruleTriggered
36+
37+
// hide these props to not pollute app logs
38+
Object.defineProperties(this, {
39+
req: { enumerable: false },
40+
res: { enumerable: false }
41+
})
3142
}
3243
}
3344

@@ -52,9 +63,9 @@ function handleResult (result, req, res, abortController, config, raspRule) {
5263

5364
if (abortController && !abortOnUncaughtException) {
5465
const blockingAction = getBlockingAction(result?.actions)
66+
const rootSpanName = rootSpan?.context?.()?._name
5567

56-
// Should block only in express
57-
if (blockingAction && rootSpan?.context()._name === 'express.request') {
68+
if (blockingAction && ALLOWED_ROOTSPAN_NAMES.has(rootSpanName)) {
5869
const abortError = new DatadogRaspAbortError(req, res, blockingAction, raspRule, ruleTriggered)
5970
abortController.abort(abortError)
6071

packages/dd-trace/test/appsec/blocking.spec.js

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('blocking', () => {
1616
}
1717

1818
let log, telemetry
19-
let block, setTemplates
19+
let block, registerBlockDelegation, callBlockDelegation, setTemplates
2020
let req, res, rootSpan
2121

2222
beforeEach(() => {
@@ -35,6 +35,8 @@ describe('blocking', () => {
3535
})
3636

3737
block = blocking.block
38+
registerBlockDelegation = blocking.registerBlockDelegation
39+
callBlockDelegation = blocking.callBlockDelegation
3840
setTemplates = blocking.setTemplates
3941

4042
req = {
@@ -151,6 +153,102 @@ describe('blocking', () => {
151153
})
152154
})
153155

156+
describe('block delegation', () => {
157+
it('should delegate block', (done) => {
158+
setTemplates(config)
159+
160+
const abortController = new AbortController()
161+
const promise = registerBlockDelegation(req, res, rootSpan, abortController)
162+
163+
expect(rootSpan.setTag).to.not.have.been.called
164+
expect(res.writeHead).to.not.have.been.called
165+
expect(res.constructor.prototype.end).to.not.have.been.called
166+
expect(abortController.signal.aborted).to.be.false
167+
expect(telemetry.updateBlockFailureMetric).to.not.have.been.called
168+
169+
const blocked = callBlockDelegation(res)
170+
171+
expect(blocked).to.be.true
172+
expect(rootSpan.setTag).to.have.been.calledOnceWithExactly('appsec.blocked', 'true')
173+
expect(res.writeHead).to.have.been.calledOnceWithExactly(403, {
174+
'Content-Type': 'application/json',
175+
'Content-Length': 8
176+
})
177+
expect(res.constructor.prototype.end).to.have.been.calledOnceWithExactly('jsonBody')
178+
expect(abortController.signal.aborted).to.be.true
179+
expect(telemetry.updateBlockFailureMetric).to.not.have.been.called
180+
181+
promise.then(blocked => {
182+
expect(blocked).to.be.true
183+
done()
184+
})
185+
})
186+
187+
it('should only resolve the first blocking delegation per request', (done) => {
188+
const firstPromise = registerBlockDelegation(req, res, rootSpan)
189+
const secondPromise = sinon.stub()
190+
const thirdPromise = sinon.stub()
191+
registerBlockDelegation(req, res, rootSpan).then(secondPromise)
192+
registerBlockDelegation(req, res, rootSpan).then(thirdPromise)
193+
194+
const blocked = callBlockDelegation(res)
195+
196+
expect(blocked).to.be.true
197+
expect(rootSpan.setTag).to.have.been.calledOnce
198+
expect(res.writeHead).to.have.been.calledOnce
199+
expect(res.constructor.prototype.end).to.have.been.calledOnce
200+
expect(telemetry.updateBlockFailureMetric).to.not.have.been.called
201+
202+
firstPromise.then((blocked) => {
203+
expect(blocked).to.be.true
204+
205+
setTimeout(() => {
206+
expect(secondPromise).to.not.have.been.called
207+
expect(thirdPromise).to.not.have.been.called
208+
done()
209+
}, 100)
210+
})
211+
})
212+
213+
it('should do nothing if no blocking delegation exists', () => {
214+
const blocked = callBlockDelegation(res)
215+
216+
expect(blocked).to.not.be.ok
217+
expect(log.warn).to.not.have.been.called
218+
expect(rootSpan.setTag).to.not.have.been.called
219+
expect(res.writeHead).to.not.have.been.called
220+
expect(res.constructor.prototype.end).to.not.have.been.called
221+
expect(telemetry.updateBlockFailureMetric).to.not.have.been.called
222+
})
223+
224+
it('should cancel block delegations when block is called', (done) => {
225+
const promise = sinon.stub()
226+
227+
registerBlockDelegation(req, res, rootSpan).then(promise)
228+
229+
const blocked = block(req, res, rootSpan)
230+
231+
expect(blocked).to.be.true
232+
expect(rootSpan.setTag).to.have.been.calledOnce
233+
expect(res.writeHead).to.have.been.calledOnce
234+
expect(res.constructor.prototype.end).to.have.been.calledOnce
235+
expect(telemetry.updateBlockFailureMetric).to.not.have.been.called
236+
237+
const result = callBlockDelegation(res)
238+
239+
expect(result).to.not.be.ok
240+
expect(rootSpan.setTag).to.have.been.calledOnce
241+
expect(res.writeHead).to.have.been.calledOnce
242+
expect(res.constructor.prototype.end).to.have.been.calledOnce
243+
expect(telemetry.updateBlockFailureMetric).to.not.have.been.called
244+
245+
setTimeout(() => {
246+
expect(promise).to.not.have.been.called
247+
done()
248+
}, 100)
249+
})
250+
})
251+
154252
describe('block with default templates', () => {
155253
const config = {
156254
appsec: {

packages/dd-trace/test/appsec/index.spec.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ describe('AppSec Index', function () {
9898
}
9999

100100
blocking = {
101-
setTemplates: sinon.stub()
101+
setTemplates: sinon.stub(),
102+
callBlockDelegation: sinon.stub()
102103
}
103104

104105
UserTracking = {
@@ -1122,6 +1123,7 @@ describe('AppSec Index', function () {
11221123
}, req)
11231124
expect(abortController.abort).to.have.been.calledOnce
11241125
expect(res.constructor.prototype.end).to.have.been.calledOnce
1126+
expect(blocking.callBlockDelegation).to.have.been.calledOnce
11251127

11261128
abortController.abort.resetHistory()
11271129

@@ -1130,6 +1132,17 @@ describe('AppSec Index', function () {
11301132
expect(waf.run).to.have.been.calledOnce
11311133
expect(abortController.abort).to.have.been.calledOnce
11321134
expect(res.constructor.prototype.end).to.have.been.calledOnce
1135+
expect(blocking.callBlockDelegation).to.have.been.calledOnce
1136+
})
1137+
1138+
it('should call abortController if blocking delegate is successful', () => {
1139+
blocking.callBlockDelegation.returns(true)
1140+
1141+
responseWriteHead.publish({ req, res, abortController, statusCode: 404, responseHeaders: {} })
1142+
1143+
expect(blocking.callBlockDelegation).to.have.been.calledOnceWithExactly(res)
1144+
expect(abortController.abort).to.have.been.calledOnce
1145+
expect(waf.run).to.not.have.been.called
11331146
})
11341147

11351148
it('should not call the WAF if response was already analyzed', () => {

0 commit comments

Comments
 (0)