Skip to content

Commit 2c9ab13

Browse files
authored
Automated Session Tracking (#5060)
1 parent 8cd547e commit 2c9ab13

File tree

14 files changed

+279
-31
lines changed

14 files changed

+279
-31
lines changed

.github/workflows/instrumentations.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ jobs:
3030
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
3131
- uses: ./.github/actions/plugins/test
3232

33+
express-session:
34+
runs-on: ubuntu-latest
35+
env:
36+
PLUGINS: express-session
37+
steps:
38+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
39+
- uses: ./.github/actions/plugins/test
40+
3341
multer:
3442
runs-on: ubuntu-latest
3543
env:
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use strict'
2+
3+
const shimmer = require('../../datadog-shimmer')
4+
const { channel, addHook } = require('./helpers/instrument')
5+
6+
const sessionMiddlewareFinishCh = channel('datadog:express-session:middleware:finish')
7+
8+
function wrapSessionMiddleware (sessionMiddleware) {
9+
return function wrappedSessionMiddleware (req, res, next) {
10+
shimmer.wrap(arguments, 2, function wrapNext (next) {
11+
return function wrappedNext () {
12+
if (sessionMiddlewareFinishCh.hasSubscribers) {
13+
const abortController = new AbortController()
14+
15+
sessionMiddlewareFinishCh.publish({ req, res, sessionId: req.sessionID, abortController })
16+
17+
if (abortController.signal.aborted) return
18+
}
19+
20+
return next.apply(this, arguments)
21+
}
22+
})
23+
24+
return sessionMiddleware.apply(this, arguments)
25+
}
26+
}
27+
28+
function wrapSession (session) {
29+
return function wrappedSession () {
30+
const sessionMiddleware = session.apply(this, arguments)
31+
32+
return shimmer.wrapFunction(sessionMiddleware, wrapSessionMiddleware)
33+
}
34+
}
35+
36+
addHook({
37+
name: 'express-session',
38+
versions: ['>=1.5.0']
39+
}, session => {
40+
return shimmer.wrapFunction(session, wrapSession)
41+
})

packages/datadog-instrumentations/src/helpers/hooks.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ module.exports = {
4747
elasticsearch: () => require('../elasticsearch'),
4848
express: () => require('../express'),
4949
'express-mongo-sanitize': () => require('../express-mongo-sanitize'),
50+
'express-session': () => require('../express-session'),
5051
fastify: () => require('../fastify'),
5152
'find-my-way': () => require('../find-my-way'),
5253
fs: () => require('../fs'),
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use strict'
2+
3+
const { assert } = require('chai')
4+
const dc = require('dc-polyfill')
5+
const axios = require('axios')
6+
const agent = require('../../dd-trace/test/plugins/agent')
7+
8+
withVersions('express-session', 'express-session', version => {
9+
describe('express-session instrumentation', () => {
10+
const sessionMiddlewareCh = dc.channel('datadog:express-session:middleware:finish')
11+
let port, server, subscriberStub, routeHandlerStub
12+
13+
before(() => {
14+
return agent.load(['http'], { client: false })
15+
})
16+
17+
before((done) => {
18+
const express = require('../../../versions/express').get()
19+
const expressSession = require(`../../../versions/express-session@${version}`).get()
20+
21+
const app = express()
22+
23+
app.use(expressSession({
24+
secret: 'secret',
25+
resave: false,
26+
rolling: true,
27+
saveUninitialized: true,
28+
genid: () => 'sid_123'
29+
}))
30+
31+
app.get('/', (req, res) => {
32+
routeHandlerStub()
33+
34+
res.send('OK')
35+
})
36+
37+
server = app.listen(0, () => {
38+
port = server.address().port
39+
done()
40+
})
41+
})
42+
43+
beforeEach(() => {
44+
routeHandlerStub = sinon.stub()
45+
subscriberStub = sinon.stub()
46+
47+
sessionMiddlewareCh.subscribe(subscriberStub)
48+
})
49+
50+
afterEach(() => {
51+
sessionMiddlewareCh.unsubscribe(subscriberStub)
52+
})
53+
54+
after(() => {
55+
server.close()
56+
return agent.close({ ritmReset: false })
57+
})
58+
59+
it('should not do anything when there are no subscribers', async () => {
60+
sessionMiddlewareCh.unsubscribe(subscriberStub)
61+
62+
const res = await axios.get(`http://localhost:${port}/`)
63+
64+
assert.equal(res.data, 'OK')
65+
sinon.assert.notCalled(subscriberStub)
66+
sinon.assert.calledOnce(routeHandlerStub)
67+
})
68+
69+
it('should call the subscriber when the middleware is called', async () => {
70+
subscriberStub.callsFake(({ sessionId }) => {
71+
assert.equal(sessionId, 'sid_123')
72+
})
73+
74+
const res = await axios.get(`http://localhost:${port}/`)
75+
76+
assert.equal(res.data, 'OK')
77+
sinon.assert.calledOnce(subscriberStub)
78+
sinon.assert.calledOnce(routeHandlerStub)
79+
})
80+
81+
it('should not call next when the subscriber calls abort()', async () => {
82+
subscriberStub.callsFake(({ res, abortController }) => {
83+
res.end('BLOCKED')
84+
abortController.abort()
85+
})
86+
87+
const res = await axios.get(`http://localhost:${port}/`)
88+
89+
assert.equal(res.data, 'BLOCKED')
90+
sinon.assert.calledOnce(subscriberStub)
91+
sinon.assert.notCalled(routeHandlerStub)
92+
})
93+
})
94+
})

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ module.exports = {
2222

2323
USER_ID: 'usr.id',
2424
USER_LOGIN: 'usr.login',
25+
USER_SESSION_ID: 'usr.session_id',
2526

2627
WAF_CONTEXT_PROCESSOR: 'waf.context.processor',
2728

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ module.exports = {
1515
incomingHttpRequestEnd: dc.channel('dd-trace:incomingHttpRequestEnd'),
1616
passportVerify: dc.channel('datadog:passport:verify:finish'),
1717
passportUser: dc.channel('datadog:passport:deserializeUser:finish'),
18+
expressSession: dc.channel('datadog:express-session:middleware:finish'),
1819
queryParser: dc.channel('datadog:query:read:finish'),
1920
setCookieChannel: dc.channel('datadog:iast:set-cookie'),
2021
nextBodyParsed: dc.channel('apm:next:body-parsed'),

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
incomingHttpRequestEnd,
1212
passportVerify,
1313
passportUser,
14+
expressSession,
1415
queryParser,
1516
nextBodyParsed,
1617
nextQueryParsed,
@@ -69,6 +70,7 @@ function enable (_config) {
6970
incomingHttpRequestEnd.subscribe(incomingHttpEndTranslator)
7071
passportVerify.subscribe(onPassportVerify) // possible optimization: only subscribe if collection mode is enabled
7172
passportUser.subscribe(onPassportDeserializeUser)
73+
expressSession.subscribe(onExpressSession)
7274
queryParser.subscribe(onRequestQueryParsed)
7375
nextBodyParsed.subscribe(onRequestBodyParsed)
7476
nextQueryParsed.subscribe(onRequestQueryParsed)
@@ -213,6 +215,25 @@ function onPassportDeserializeUser ({ user, abortController }) {
213215
handleResults(results, store.req, store.req.res, rootSpan, abortController)
214216
}
215217

218+
function onExpressSession ({ req, res, sessionId, abortController }) {
219+
const rootSpan = web.root(req)
220+
if (!rootSpan) {
221+
log.warn('[ASM] No rootSpan found in onExpressSession')
222+
return
223+
}
224+
225+
const isSdkCalled = rootSpan.context()._tags['usr.session_id']
226+
if (isSdkCalled) return
227+
228+
const results = waf.run({
229+
persistent: {
230+
[addresses.USER_SESSION_ID]: sessionId
231+
}
232+
}, req)
233+
234+
handleResults(results, req, res, rootSpan, abortController)
235+
}
236+
216237
function onRequestQueryParsed ({ req, res, query, abortController }) {
217238
if (!query || typeof query !== 'object') return
218239

@@ -327,6 +348,7 @@ function disable () {
327348
if (incomingHttpRequestEnd.hasSubscribers) incomingHttpRequestEnd.unsubscribe(incomingHttpEndTranslator)
328349
if (passportVerify.hasSubscribers) passportVerify.unsubscribe(onPassportVerify)
329350
if (passportUser.hasSubscribers) passportUser.unsubscribe(onPassportDeserializeUser)
351+
if (expressSession.hasSubscribers) expressSession.unsubscribe(onExpressSession)
330352
if (queryParser.hasSubscribers) queryParser.unsubscribe(onRequestQueryParsed)
331353
if (nextBodyParsed.hasSubscribers) nextBodyParsed.unsubscribe(onRequestBodyParsed)
332354
if (nextQueryParsed.hasSubscribers) nextQueryParsed.unsubscribe(onRequestQueryParsed)

packages/dd-trace/src/appsec/sdk/set_user.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,15 @@ function setUser (tracer, user) {
2626
setUserTags(user, rootSpan)
2727
rootSpan.setTag('_dd.appsec.user.collection_mode', 'sdk')
2828

29-
waf.run({
30-
persistent: {
31-
[addresses.USER_ID]: '' + user.id
32-
}
33-
})
29+
const persistent = {
30+
[addresses.USER_ID]: '' + user.id
31+
}
32+
33+
if (user.session_id && typeof user.session_id === 'string') {
34+
persistent[addresses.USER_SESSION_ID] = user.session_id
35+
}
36+
37+
waf.run({ persistent })
3438
}
3539

3640
module.exports = {

packages/dd-trace/src/remote_config/capabilities.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ module.exports = {
2424
APM_TRACING_SAMPLE_RULES: 1n << 29n,
2525
ASM_AUTO_USER_INSTRUM_MODE: 1n << 31n,
2626
ASM_ENDPOINT_FINGERPRINT: 1n << 32n,
27+
ASM_SESSION_FINGERPRINT: 1n << 33n,
2728
ASM_NETWORK_FINGERPRINT: 1n << 34n,
2829
ASM_HEADER_FINGERPRINT: 1n << 35n,
2930
ASM_RASP_CMDI: 1n << 37n

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ function enableWafUpdate (appsecConfig) {
9393
rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_BLOCKING_RESPONSE, true)
9494
rc.updateCapabilities(RemoteConfigCapabilities.ASM_TRUSTED_IPS, true)
9595
rc.updateCapabilities(RemoteConfigCapabilities.ASM_ENDPOINT_FINGERPRINT, true)
96+
rc.updateCapabilities(RemoteConfigCapabilities.ASM_SESSION_FINGERPRINT, true)
9697
rc.updateCapabilities(RemoteConfigCapabilities.ASM_NETWORK_FINGERPRINT, true)
9798
rc.updateCapabilities(RemoteConfigCapabilities.ASM_HEADER_FINGERPRINT, true)
9899

@@ -127,6 +128,7 @@ function disableWafUpdate () {
127128
rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_BLOCKING_RESPONSE, false)
128129
rc.updateCapabilities(RemoteConfigCapabilities.ASM_TRUSTED_IPS, false)
129130
rc.updateCapabilities(RemoteConfigCapabilities.ASM_ENDPOINT_FINGERPRINT, false)
131+
rc.updateCapabilities(RemoteConfigCapabilities.ASM_SESSION_FINGERPRINT, false)
130132
rc.updateCapabilities(RemoteConfigCapabilities.ASM_NETWORK_FINGERPRINT, false)
131133
rc.updateCapabilities(RemoteConfigCapabilities.ASM_HEADER_FINGERPRINT, false)
132134

0 commit comments

Comments
 (0)