Skip to content

Commit 80880d5

Browse files
authored
feat(aap&iast): Detect vulnerability or attack on res.render in express (#6739)
We were ignoring fs operations happening into res.render, with this change we are analyzing the risky input for the fs operations into the render function.
1 parent 532cf7f commit 80880d5

File tree

10 files changed

+136
-20
lines changed

10 files changed

+136
-20
lines changed

packages/datadog-instrumentations/src/express.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,21 @@ function wrapResponseRender (render) {
5353
return render.apply(this, arguments)
5454
}
5555

56+
const abortController = new AbortController()
5657
return responseRenderChannel.traceSync(
57-
render,
58+
function () {
59+
if (abortController.signal.aborted) {
60+
const error = abortController.signal.reason || new Error('Aborted')
61+
throw error
62+
}
63+
64+
return render.apply(this, arguments)
65+
},
5866
{
5967
req: this.req,
6068
view,
61-
options
69+
options,
70+
abortController
6271
},
6372
this,
6473
...arguments

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module.exports = {
1212
cookieParser: dc.channel('datadog:cookie-parser:read:finish'),
1313
expressMiddlewareError: dc.channel('apm:express:middleware:error'),
1414
expressProcessParams: dc.channel('datadog:express:process_params:start'),
15+
expressResponseRenderStart: dc.channel('tracing:datadog:express:response:render:start'),
1516
expressSession: dc.channel('datadog:express-session:middleware:finish'),
1617
fastifyBodyParser: dc.channel('datadog:fastify:body-parser:finish'),
1718
fastifyCookieParser: dc.channel('datadog:fastify-cookie:read:finish'),

packages/dd-trace/src/appsec/iast/analyzers/path-traversal-analyzer.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ class PathTraversalAnalyzer extends InjectionAnalyzer {
6868
}
6969
this.analyze(pathArguments)
7070
})
71+
72+
this.addSub('tracing:datadog:express:response:render:start', (ctx) => {
73+
const store = storage('legacy').getStore()
74+
if (!store) return
75+
76+
this.analyze([ctx.view])
77+
})
7178
}
7279

7380
_isExcluded (location) {

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

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

3-
const { fsOperationStart, incomingHttpRequestStart } = require('../channels')
3+
const { isAbsolute } = require('path')
4+
5+
const { fsOperationStart, incomingHttpRequestStart, expressResponseRenderStart } = require('../channels')
46
const { storage } = require('../../../../datadog-core')
57
const { enable: enableFsPlugin, disable: disableFsPlugin, RASP_MODULE } = require('./fs-plugin')
68
const { FS_OPERATION_PATH } = require('../addresses')
79
const waf = require('../waf')
810
const { RULE_TYPES, handleResult } = require('./utils')
9-
const { isAbsolute } = require('path')
1011

1112
let config
1213
let enabled
@@ -25,6 +26,7 @@ function enable (_config) {
2526
function disable () {
2627
if (fsOperationStart.hasSubscribers) fsOperationStart.unsubscribe(analyzeLfi)
2728
if (incomingHttpRequestStart.hasSubscribers) incomingHttpRequestStart.unsubscribe(onFirstReceivedRequest)
29+
if (expressResponseRenderStart.hasSubscribers) expressResponseRenderStart.unsubscribe(analyzeLfiInResponseRender)
2830

2931
disableFsPlugin(RASP_MODULE)
3032

@@ -42,10 +44,18 @@ function onFirstReceivedRequest () {
4244

4345
if (!analyzeSubscribed) {
4446
fsOperationStart.subscribe(analyzeLfi)
47+
expressResponseRenderStart.subscribe(analyzeLfiInResponseRender)
4548
analyzeSubscribed = true
4649
}
4750
}
4851

52+
function analyzeLfiInResponseRender (ctx) {
53+
const store = storage('legacy').getStore()
54+
if (!store) return
55+
56+
analyzeLfiPath(ctx.view, ctx.req, store.res, ctx.abortController)
57+
}
58+
4959
function analyzeLfi (ctx) {
5060
const store = storage('legacy').getStore()
5161
if (!store) return
@@ -54,15 +64,19 @@ function analyzeLfi (ctx) {
5464
if (!req || !fs) return
5565

5666
getPaths(ctx, fs).forEach(path => {
57-
const ephemeral = {
58-
[FS_OPERATION_PATH]: path
59-
}
67+
analyzeLfiPath(path, req, res, ctx.abortController)
68+
})
69+
}
6070

61-
const raspRule = { type: RULE_TYPES.LFI }
71+
function analyzeLfiPath (path, req, res, abortController) {
72+
const ephemeral = {
73+
[FS_OPERATION_PATH]: path
74+
}
6275

63-
const result = waf.run({ ephemeral }, req, raspRule)
64-
handleResult(result, req, res, ctx.abortController, config, raspRule)
65-
})
76+
const raspRule = { type: RULE_TYPES.LFI }
77+
78+
const result = waf.run({ ephemeral }, req, raspRule)
79+
handleResult(result, req, res, abortController, config, raspRule)
6680
}
6781

6882
function getPaths (ctx, fs) {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use strict'
2+
3+
const path = require('node:path')
4+
const fs = require('node:fs')
5+
const os = require('node:os')
6+
7+
const axios = require('axios')
8+
const semver = require('semver')
9+
10+
const { prepareTestServerForIastInExpress } = require('../utils')
11+
const { withVersions } = require('../../../setup/mocha')
12+
const { NODE_MAJOR } = require('../../../../../../version')
13+
14+
describe('Path traversal analyzer', () => {
15+
let renderFunctionPath
16+
before(() => {
17+
renderFunctionPath = path.join(os.tmpdir(), 'render-function.js')
18+
fs.copyFileSync(
19+
path.join(__dirname, 'resources', 'render-function.js'),
20+
renderFunctionPath
21+
)
22+
})
23+
24+
after(() => {
25+
fs.unlinkSync(renderFunctionPath)
26+
})
27+
28+
withVersions('express', 'express', version => {
29+
if (semver.intersects(version, '<=4.10.5') && NODE_MAJOR >= 24) {
30+
// eslint-disable-next-line mocha/no-pending-tests
31+
describe.skip(`refusing to run tests as express@${version} is incompatible with Node.js ${NODE_MAJOR}`)
32+
return
33+
}
34+
withVersions('express', 'ejs', _ => {
35+
prepareTestServerForIastInExpress('in express', version,
36+
(expressApp) => {
37+
expressApp.set('view engine', 'ejs')
38+
expressApp.set('views', path.join(__dirname, 'resources'))
39+
},
40+
(testThatRequestHasVulnerability, testThatRequestHasNoVulnerability) => {
41+
testThatRequestHasVulnerability(
42+
{
43+
fn: (req, res) => {
44+
require(renderFunctionPath)(res, req.query.file)
45+
return true
46+
},
47+
vulnerability: 'PATH_TRAVERSAL',
48+
occurrences: 1,
49+
makeRequest: (done, config) => {
50+
axios.get(`http://localhost:${config.port}/?file=template`)
51+
.catch((err, ...args) => {
52+
done(err)
53+
})
54+
}
55+
})
56+
57+
testThatRequestHasNoVulnerability(
58+
{
59+
fn: (req, res) => {
60+
require(renderFunctionPath)(res, 'template')
61+
return true
62+
},
63+
vulnerability: 'PATH_TRAVERSAL',
64+
makeRequest: (done, config) => {
65+
axios.get(`http://localhost:${config.port}/?file=template`)
66+
.catch(done)
67+
}
68+
})
69+
})
70+
})
71+
})
72+
})

packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ describe('path-traversal-analyzer', () => {
7474
})
7575

7676
it('Analyzer should be subscribed to proper channel', () => {
77-
expect(pathTraversalAnalyzer._subscriptions).to.have.lengthOf(1)
77+
expect(pathTraversalAnalyzer._subscriptions).to.have.lengthOf(2)
7878
expect(pathTraversalAnalyzer._subscriptions[0]._channel.name).to.equals('apm:fs:operation:start')
79+
expect(pathTraversalAnalyzer._subscriptions[1]._channel.name)
80+
.to.equals('tracing:datadog:express:response:render:start')
7981
})
8082

8183
it('If no context it should not report vulnerability', () => {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use strict'
2+
3+
module.exports = function render (res, file) {
4+
res.render(file)
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<html>
2+
<body>
3+
<h1>Kaixo!</h1>
4+
</body>
5+
</html>

packages/dd-trace/test/appsec/iast/utils.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ function beforeEachIastTest (iastConfig) {
134134
}
135135

136136
function endResponse (res, appResult) {
137+
if (appResult === true) return
138+
137139
if (appResult && typeof appResult.then === 'function') {
138140
appResult.then(() => {
139141
if (!res.headersSent) {

packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ describe('RASP - lfi', () => {
113113
}
114114

115115
function runFsMethodTest (description, options, fn, ...args) {
116-
const { vulnerableIndex = 0, ruleEvalCount } = options
116+
const { vulnerableIndex = 0, ruleEvalCount, secureFile = '/test.file' } = options
117117

118118
describe(description, () => {
119119
const getAppFn = options.getAppFn ?? getApp
@@ -131,7 +131,7 @@ describe('RASP - lfi', () => {
131131
it('should not block if param not found in the request', async () => {
132132
app = getAppFn(fn, args, options)
133133

134-
await axios.get('/?file=/test.file')
134+
await axios.get(`/?file=${secureFile}`)
135135

136136
return checkRaspExecutedAndNotThreat(agent, false)
137137
})
@@ -435,21 +435,20 @@ describe('RASP - lfi', () => {
435435
function getAppFn (fn, args, options) {
436436
return (req, res) => {
437437
try {
438-
const result = fn(args)
438+
const result = fn(req, res, args)
439439
options.onfinish?.(result)
440440
} catch (e) {
441441
if (e.message === 'DatadogRaspAbortError') {
442442
res.status(418)
443+
res.end('end')
443444
}
444445
}
445-
res.render('template')
446446
}
447447
}
448448

449-
runFsMethodTest('rule is eval only once and rendering file accesses are ignored',
450-
{ getAppFn, ruleEvalCount: 1 }, (args) => {
451-
const fs = require('fs')
452-
return fs.readFileSync(...args)
449+
runFsMethodTest('res.render',
450+
{ getAppFn, ruleEvalCount: 1, secureFile: 'template' }, (req, res) => {
451+
return res.render(req.query.file)
453452
}, __filename)
454453
})
455454
})

0 commit comments

Comments
 (0)