Skip to content

Commit 7ecad19

Browse files
authored
Cache tainted value of query params in express v5 (#5716)
* cache tainted value of query params in express v5 * fix tainting value * taint query object * remove only from esm test * cache tainted query * check if value equal cache * clone tainted object * cache only if it's primitive * use flat structure for cache * fix linter * check if cached value equals current * prevent double access to map * fix linter
1 parent 145dcd7 commit 7ecad19

File tree

5 files changed

+140
-87
lines changed

5 files changed

+140
-87
lines changed

integration-tests/appsec/iast.esm-security-controls.spec.js

Lines changed: 86 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -8,114 +8,118 @@ const { assert } = require('chai')
88
describe('ESM Security controls', () => {
99
let axios, sandbox, cwd, appFile, agent, proc
1010

11-
before(async function () {
12-
this.timeout(process.platform === 'win32' ? 90000 : 30000)
13-
sandbox = await createSandbox(['express@4']) // TODO: Remove pinning once our tests support Express v5
14-
cwd = sandbox.folder
15-
appFile = path.join(cwd, 'appsec', 'esm-security-controls', 'index.mjs')
16-
})
11+
['4', '5'].forEach(version => {
12+
describe(`With express v${version}`, () => {
13+
before(async function () {
14+
this.timeout(process.platform === 'win32' ? 90000 : 30000)
15+
sandbox = await createSandbox([`express@${version}`])
16+
cwd = sandbox.folder
17+
appFile = path.join(cwd, 'appsec', 'esm-security-controls', 'index.mjs')
18+
})
1719

18-
after(async function () {
19-
await sandbox.remove()
20-
})
20+
after(async function () {
21+
await sandbox.remove()
22+
})
2123

22-
const nodeOptions = '--import dd-trace/initialize.mjs'
24+
const nodeOptions = '--import dd-trace/initialize.mjs'
2325

24-
beforeEach(async () => {
25-
agent = await new FakeAgent().start()
26+
beforeEach(async () => {
27+
agent = await new FakeAgent().start()
2628

27-
proc = await spawnProc(appFile, {
28-
cwd,
29-
env: {
30-
DD_TRACE_AGENT_PORT: agent.port,
31-
DD_IAST_ENABLED: 'true',
32-
DD_IAST_REQUEST_SAMPLING: '100',
33-
// eslint-disable-next-line no-multi-str
34-
DD_IAST_SECURITY_CONTROLS_CONFIGURATION: '\
29+
proc = await spawnProc(appFile, {
30+
cwd,
31+
env: {
32+
DD_TRACE_AGENT_PORT: agent.port,
33+
DD_IAST_ENABLED: 'true',
34+
DD_IAST_REQUEST_SAMPLING: '100',
35+
// eslint-disable-next-line no-multi-str
36+
DD_IAST_SECURITY_CONTROLS_CONFIGURATION: '\
3537
SANITIZER:COMMAND_INJECTION:appsec/esm-security-controls/sanitizer.mjs:sanitize;\
3638
SANITIZER:COMMAND_INJECTION:appsec/esm-security-controls/sanitizer-default.mjs;\
3739
INPUT_VALIDATOR:*:appsec/esm-security-controls/validator.mjs:validate',
38-
NODE_OPTIONS: nodeOptions
39-
}
40-
})
40+
NODE_OPTIONS: nodeOptions
41+
}
42+
})
4143

42-
axios = Axios.create({ baseURL: proc.url })
43-
})
44+
axios = Axios.create({ baseURL: proc.url })
45+
})
4446

45-
afterEach(async () => {
46-
proc.kill()
47-
await agent.stop()
48-
})
47+
afterEach(async () => {
48+
proc.kill()
49+
await agent.stop()
50+
})
4951

50-
it('test endpoint with iv not configured does have COMMAND_INJECTION vulnerability', async function () {
51-
await axios.get('/cmdi-iv-insecure?command=ls -la')
52+
it('test endpoint with iv not configured does have COMMAND_INJECTION vulnerability', async function () {
53+
await axios.get('/cmdi-iv-insecure?command=ls -la')
5254

53-
await agent.assertMessageReceived(({ payload }) => {
54-
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
55-
spans.forEach(span => {
56-
assert.property(span.meta, '_dd.iast.json')
57-
assert.include(span.meta['_dd.iast.json'], '"COMMAND_INJECTION"')
55+
await agent.assertMessageReceived(({ payload }) => {
56+
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
57+
spans.forEach(span => {
58+
assert.property(span.meta, '_dd.iast.json')
59+
assert.include(span.meta['_dd.iast.json'], '"COMMAND_INJECTION"')
60+
})
61+
}, null, 1, true)
5862
})
59-
}, null, 1, true)
60-
})
6163

62-
it('test endpoint sanitizer does not have COMMAND_INJECTION vulnerability', async () => {
63-
await axios.get('/cmdi-s-secure?command=ls -la')
64+
it('test endpoint sanitizer does not have COMMAND_INJECTION vulnerability', async () => {
65+
await axios.get('/cmdi-s-secure?command=ls -la')
6466

65-
await agent.assertMessageReceived(({ payload }) => {
66-
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
67-
spans.forEach(span => {
68-
assert.notProperty(span.meta, '_dd.iast.json')
69-
assert.property(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')
67+
await agent.assertMessageReceived(({ payload }) => {
68+
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
69+
spans.forEach(span => {
70+
assert.notProperty(span.meta, '_dd.iast.json')
71+
assert.property(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')
72+
})
73+
}, null, 1, true)
7074
})
71-
}, null, 1, true)
72-
})
7375

74-
it('test endpoint with default sanitizer does not have COMMAND_INJECTION vulnerability', async () => {
75-
await axios.get('/cmdi-s-secure-default?command=ls -la')
76+
it('test endpoint with default sanitizer does not have COMMAND_INJECTION vulnerability', async () => {
77+
await axios.get('/cmdi-s-secure-default?command=ls -la')
7678

77-
await agent.assertMessageReceived(({ payload }) => {
78-
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
79-
spans.forEach(span => {
80-
assert.notProperty(span.meta, '_dd.iast.json')
81-
assert.property(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')
79+
await agent.assertMessageReceived(({ payload }) => {
80+
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
81+
spans.forEach(span => {
82+
assert.notProperty(span.meta, '_dd.iast.json')
83+
assert.property(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')
84+
})
85+
}, null, 1, true)
8286
})
83-
}, null, 1, true)
84-
})
8587

86-
it('test endpoint with default sanitizer does have COMMAND_INJECTION with original tainted', async () => {
87-
await axios.get('/cmdi-s-secure-comparison?command=ls -la')
88+
it('test endpoint with default sanitizer does have COMMAND_INJECTION with original tainted', async () => {
89+
await axios.get('/cmdi-s-secure-comparison?command=ls -la')
8890

89-
await agent.assertMessageReceived(({ payload }) => {
90-
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
91-
spans.forEach(span => {
92-
assert.property(span.meta, '_dd.iast.json')
93-
assert.include(span.meta['_dd.iast.json'], '"COMMAND_INJECTION"')
91+
await agent.assertMessageReceived(({ payload }) => {
92+
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
93+
spans.forEach(span => {
94+
assert.property(span.meta, '_dd.iast.json')
95+
assert.include(span.meta['_dd.iast.json'], '"COMMAND_INJECTION"')
96+
})
97+
}, null, 1, true)
9498
})
95-
}, null, 1, true)
96-
})
9799

98-
it('test endpoint with default sanitizer does have COMMAND_INJECTION vulnerability', async () => {
99-
await axios.get('/cmdi-s-secure-default?command=ls -la')
100+
it('test endpoint with default sanitizer does have COMMAND_INJECTION vulnerability', async () => {
101+
await axios.get('/cmdi-s-secure-default?command=ls -la')
100102

101-
await agent.assertMessageReceived(({ payload }) => {
102-
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
103-
spans.forEach(span => {
104-
assert.notProperty(span.meta, '_dd.iast.json')
105-
assert.property(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')
103+
await agent.assertMessageReceived(({ payload }) => {
104+
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
105+
spans.forEach(span => {
106+
assert.notProperty(span.meta, '_dd.iast.json')
107+
assert.property(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')
108+
})
109+
}, null, 1, true)
106110
})
107-
}, null, 1, true)
108-
})
109111

110-
it('test endpoint with iv does not have COMMAND_INJECTION vulnerability', async () => {
111-
await axios.get('/cmdi-iv-secure?command=ls -la')
112+
it('test endpoint with iv does not have COMMAND_INJECTION vulnerability', async () => {
113+
await axios.get('/cmdi-iv-secure?command=ls -la')
112114

113-
await agent.assertMessageReceived(({ payload }) => {
114-
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
115-
spans.forEach(span => {
116-
assert.notProperty(span.meta, '_dd.iast.json')
117-
assert.property(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')
115+
await agent.assertMessageReceived(({ payload }) => {
116+
const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request'))
117+
spans.forEach(span => {
118+
assert.notProperty(span.meta, '_dd.iast.json')
119+
assert.property(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')
120+
})
121+
}, null, 1, true)
118122
})
119-
}, null, 1, true)
123+
})
120124
})
121125
})

packages/dd-trace/src/appsec/iast/security-controls/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ function hookModule (filename, module, controlsByFile) {
8585
}
8686
})
8787
} catch (e) {
88-
log.error('[ASM] Error initializing IAST security control for %', filename, e)
88+
log.error('[ASM] Error initializing IAST security control for %s', filename, e)
8989
}
9090

9191
return module

packages/dd-trace/src/appsec/iast/taint-tracking/operations-taint-object.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
const TaintedUtils = require('@datadog/native-iast-taint-tracking')
44
const { IAST_TRANSACTION_ID } = require('../iast-context')
5+
const { HTTP_REQUEST_PARAMETER } = require('./source-types')
56
const log = require('../../../log')
67

8+
const SEPARATOR = '\u0000' // Unit Separator (cannot be in URL keys)
9+
710
function taintObject (iastContext, object, type) {
811
let result = object
912
const transactionId = iastContext?.[IAST_TRANSACTION_ID]
@@ -40,6 +43,46 @@ function taintObject (iastContext, object, type) {
4043
return result
4144
}
4245

46+
function taintQueryWithCache (iastContext, query) {
47+
const transactionId = iastContext?.[IAST_TRANSACTION_ID]
48+
if (!transactionId || !query) return query
49+
50+
iastContext.queryCache ??= new Map() // key: "a.b.c", value: tainted string
51+
52+
traverseAndTaint(query, '', iastContext.queryCache, transactionId)
53+
return query
54+
}
55+
56+
function traverseAndTaint (node, path, cache, transactionId) {
57+
if (node == null) return node
58+
59+
if (typeof node === 'string') {
60+
const cachedValue = cache.get(path)
61+
62+
if (cachedValue === node) {
63+
return cachedValue
64+
}
65+
66+
const tainted = TaintedUtils.newTaintedString(transactionId, node, path, HTTP_REQUEST_PARAMETER)
67+
cache.set(path, tainted)
68+
69+
return tainted
70+
}
71+
72+
if (typeof node === 'object') {
73+
const keys = Array.isArray(node) ? node.keys() : Object.keys(node)
74+
75+
for (const key of keys) {
76+
const childPath = path ? `${path}${SEPARATOR}${key}` : String(key)
77+
const tainted = traverseAndTaint(node[key], childPath, cache, transactionId)
78+
node[key] = tainted
79+
}
80+
}
81+
82+
return node
83+
}
84+
4385
module.exports = {
44-
taintObject
86+
taintObject,
87+
taintQueryWithCache
4588
}

packages/dd-trace/src/appsec/iast/taint-tracking/operations.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const {
1111
getTaintTrackingNoop,
1212
lodashTaintTrackingHandler
1313
} = require('./taint-tracking-impl')
14-
const { taintObject } = require('./operations-taint-object')
14+
const { taintObject, taintQueryWithCache } = require('./operations-taint-object')
1515

1616
const lodashOperationCh = dc.channel('datadog:lodash:operation')
1717

@@ -98,6 +98,7 @@ module.exports = {
9898
newTaintedString,
9999
newTaintedObject,
100100
taintObject,
101+
taintQueryWithCache,
101102
isTainted,
102103
getRanges,
103104
enableTaintOperations,

packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
const { SourceIastPlugin } = require('../iast-plugin')
44
const { getIastContext } = require('../iast-context')
55
const { storage } = require('../../../../../datadog-core')
6-
const { taintObject, newTaintedString, getRanges } = require('./operations')
6+
const { taintObject, newTaintedString, getRanges, taintQueryWithCache } = require('./operations')
77
const {
88
HTTP_REQUEST_BODY,
99
HTTP_REQUEST_COOKIE_VALUE,
@@ -63,7 +63,12 @@ class TaintTrackingPlugin extends SourceIastPlugin {
6363

6464
this.addSub(
6565
{ channelName: 'datadog:express:query:finish', tag: HTTP_REQUEST_PARAMETER },
66-
({ query }) => this._taintTrackingHandler(HTTP_REQUEST_PARAMETER, query)
66+
({ query }) => {
67+
const iastContext = getIastContext(storage('legacy').getStore())
68+
if (!iastContext || !query) return
69+
70+
taintQueryWithCache(iastContext, query)
71+
}
6772
)
6873

6974
this.addSub(

0 commit comments

Comments
 (0)