dd-trace
Version:
Datadog APM tracing client for JavaScript
563 lines (459 loc) • 16.9 kB
JavaScript
'use strict'
const log = require('../log')
const web = require('../plugins/util/web')
const { extractIp } = require('../plugins/util/ip_extractor')
const { HTTP_CLIENT_IP } = require('../../../../ext/tags')
const { IS_SERVERLESS } = require('../serverless')
const { isEmpty } = require('../util')
const RuleManager = require('./rule_manager')
const appsecRemoteConfig = require('./remote_config')
const {
bodyParser,
cookieParser,
multerParser,
fastifyBodyParser,
fastifyCookieParser,
incomingHttpRequestStart,
incomingHttpRequestEnd,
passportVerify,
passportUser,
expressSession,
queryParser,
nextBodyParsed,
nextQueryParsed,
expressProcessParams,
fastifyQueryParams,
responseBody,
responseWriteHead,
responseSetHeader,
routerParam,
fastifyResponseChannel,
fastifyPathParams,
stripeCheckoutSessionCreate,
stripePaymentIntentCreate,
stripeConstructEvent,
} = require('./channels')
const waf = require('./waf')
const addresses = require('./addresses')
const Reporter = require('./reporter')
const appsecTelemetry = require('./telemetry')
const apiSecuritySampler = require('./api_security_sampler')
const { isBlocked, block, callBlockDelegation, setTemplates, getBlockingAction } = require('./blocking')
const { getActiveRequest } = require('./store')
const UserTracking = require('./user_tracking')
const graphql = require('./graphql')
const rasp = require('./rasp')
const responseAnalyzedSet = new WeakSet()
const storedResponseHeaders = new WeakMap()
const storedBodies = new WeakMap()
let isEnabled = false
let config
function enable (_config) {
if (isEnabled) return
try {
appsecTelemetry.enable(_config)
graphql.enable()
if (_config.appsec.rasp.enabled) {
rasp.enable(_config)
}
setTemplates(_config)
RuleManager.loadRules(_config.appsec)
appsecRemoteConfig.enableWafUpdate(_config.appsec)
Reporter.init(_config.appsec, _config.inferredProxyServicesEnabled)
apiSecuritySampler.configure(_config)
UserTracking.setCollectionMode(_config.appsec.eventTracking.mode, false)
bodyParser.subscribe(onRequestBodyParsed)
multerParser.subscribe(onRequestBodyParsed)
cookieParser.subscribe(onRequestCookieParser)
incomingHttpRequestStart.subscribe(incomingHttpStartTranslator)
incomingHttpRequestEnd.subscribe(incomingHttpEndTranslator)
passportVerify.subscribe(onPassportVerify) // possible optimization: only subscribe if collection mode is enabled
passportUser.subscribe(onPassportDeserializeUser)
expressSession.subscribe(onExpressSession)
queryParser.subscribe(onRequestQueryParsed)
nextBodyParsed.subscribe(onRequestBodyParsed)
nextQueryParsed.subscribe(onRequestQueryParsed)
expressProcessParams.subscribe(onRequestProcessParams)
fastifyBodyParser.subscribe(onRequestBodyParsed)
fastifyQueryParams.subscribe(onRequestQueryParsed)
fastifyCookieParser.subscribe(onRequestCookieParser)
fastifyPathParams.subscribe(onRequestProcessParams)
routerParam.subscribe(onRequestProcessParams)
responseBody.subscribe(onResponseBody)
fastifyResponseChannel.subscribe(onResponseBody)
responseWriteHead.subscribe(onResponseWriteHead)
responseSetHeader.subscribe(onResponseSetHeader)
stripeCheckoutSessionCreate.subscribe(onStripeCheckoutSessionCreate)
stripePaymentIntentCreate.subscribe(onStripePaymentIntentCreate)
stripeConstructEvent.subscribe(onStripeConstructEvent)
isEnabled = true
config = _config
} catch (err) {
if (!IS_SERVERLESS) {
log.error('[ASM] Unable to start AppSec', err)
}
disable()
}
}
const analyzedBodies = new WeakSet()
function onRequestBodyParsed ({ req, res, body, abortController }) {
if (body === undefined || body === null) return
if (!req) {
req = getActiveRequest()
}
const rootSpan = web.root(req)
if (!rootSpan) return
if (!req.body) {
// do not store body if it is in req.body
storedBodies.set(req, body)
}
if (typeof body === 'object') {
if (isEmpty(body)) return
analyzedBodies.add(body)
}
const results = waf.run({
persistent: {
[addresses.HTTP_INCOMING_BODY]: body,
},
}, req)
handleResults(results?.actions, req, res, rootSpan, abortController)
}
const analyzedCookies = new WeakSet()
function onRequestCookieParser ({ req, res, abortController, cookies }) {
if (!cookies || typeof cookies !== 'object') return
const rootSpan = web.root(req)
if (!rootSpan) return
if (isEmpty(cookies)) return
analyzedCookies.add(cookies)
const results = waf.run({
persistent: {
[addresses.HTTP_INCOMING_COOKIES]: cookies,
},
}, req)
handleResults(results?.actions, req, res, rootSpan, abortController)
}
function incomingHttpStartTranslator ({ req, res, abortController }) {
const rootSpan = web.root(req)
if (!rootSpan) return
const clientIp = extractIp(config, req)
rootSpan.addTags({
'_dd.appsec.enabled': 1,
'_dd.runtime_family': 'nodejs',
[HTTP_CLIENT_IP]: clientIp,
})
if (config.inferredProxyServicesEnabled) {
const context = web.getContext(req)
if (context?.inferredProxySpan) {
context.inferredProxySpan.setTag('_dd.appsec.enabled', 1)
}
}
const persistent = {
[addresses.HTTP_INCOMING_URL]: req.url,
[addresses.HTTP_INCOMING_HEADERS]: copyHeadersOmitting(req.headers, 'cookie'),
[addresses.HTTP_INCOMING_METHOD]: req.method,
}
if (clientIp) {
persistent[addresses.HTTP_CLIENT_IP] = clientIp
}
const results = waf.run({ persistent }, req)
handleResults(results?.actions, req, res, rootSpan, abortController)
}
function incomingHttpEndTranslator ({ req, res }) {
const persistent = {}
// we need to keep this to support other body parsers
if (req.body !== undefined && req.body !== null) {
if (typeof req.body === 'object') {
if (!isEmpty(req.body) && !analyzedBodies.has(req.body)) {
persistent[addresses.HTTP_INCOMING_BODY] = req.body
}
} else {
persistent[addresses.HTTP_INCOMING_BODY] = req.body
}
}
// we need to keep this to support other cookie parsers
if (
req.cookies !== null &&
typeof req.cookies === 'object' &&
!isEmpty(req.cookies) &&
!analyzedCookies.has(req.cookies)
) {
persistent[addresses.HTTP_INCOMING_COOKIES] = req.cookies
}
// we need to keep this to support nextjs
const query = req.query
if (
query !== null &&
typeof query === 'object' &&
!isEmpty(query)
) {
persistent[addresses.HTTP_INCOMING_QUERY] = query
}
// This hook runs before span finish, so ensure route/endpoint tags are available before API Security sampling runs.
web.setRouteOrEndpointTag(req)
if (apiSecuritySampler.sampleRequest(req, res, true)) {
persistent[addresses.WAF_CONTEXT_PROCESSOR] = { 'extract-schema': true }
}
if (!isEmpty(persistent)) {
waf.run({ persistent }, req)
}
waf.disposeContext(req)
const storedHeaders = storedResponseHeaders.get(req) || {}
const body = req.body || storedBodies.get(req)
Reporter.finishRequest(req, res, storedHeaders, body)
if (storedHeaders) {
storedResponseHeaders.delete(req)
}
storedBodies.delete(req)
}
function onPassportVerify ({ framework, login, user, success, abortController }) {
const req = getActiveRequest()
const rootSpan = req && web.root(req)
if (!rootSpan) {
log.warn('[ASM] No rootSpan found in onPassportVerify')
return
}
const results = UserTracking.trackLogin(framework, login, user, success, rootSpan)
handleResults(results?.actions, req, web.getContext(req)?.res, rootSpan, abortController)
}
function onPassportDeserializeUser ({ user, abortController }) {
const req = getActiveRequest()
const rootSpan = req && web.root(req)
if (!rootSpan) {
log.warn('[ASM] No rootSpan found in onPassportDeserializeUser')
return
}
const results = UserTracking.trackUser(user, rootSpan)
handleResults(results?.actions, req, web.getContext(req)?.res, rootSpan, abortController)
}
function onExpressSession ({ req, res, sessionId, abortController }) {
const rootSpan = web.root(req)
if (!rootSpan) {
log.warn('[ASM] No rootSpan found in onExpressSession')
return
}
const isSdkCalled = rootSpan.context()._tags['usr.session_id']
if (isSdkCalled) return
const results = waf.run({
persistent: {
[addresses.USER_SESSION_ID]: sessionId,
},
}, req)
handleResults(results?.actions, req, res, rootSpan, abortController)
}
function onRequestQueryParsed ({ req, res, query, abortController }) {
if (!query || typeof query !== 'object') return
if (!req) {
req = getActiveRequest()
}
const rootSpan = web.root(req)
if (!rootSpan) return
if (isEmpty(query)) return
const results = waf.run({
persistent: {
[addresses.HTTP_INCOMING_QUERY]: query,
},
}, req)
handleResults(results?.actions, req, res, rootSpan, abortController)
}
function onRequestProcessParams ({ req, res, abortController, params }) {
const rootSpan = web.root(req)
if (!rootSpan) return
if (!params || typeof params !== 'object' || isEmpty(params)) return
const results = waf.run({
persistent: {
[addresses.HTTP_INCOMING_PARAMS]: params,
},
}, req)
handleResults(results?.actions, req, res, rootSpan, abortController)
}
function onResponseBody ({ req, res, body }) {
if (!body || typeof body !== 'object') return
if (!apiSecuritySampler.sampleRequest(req, res)) return
// we don't support blocking at this point, so no results needed
waf.run({
persistent: {
[addresses.HTTP_INCOMING_RESPONSE_BODY]: body,
},
}, req)
}
function onResponseWriteHead ({ req, res, abortController, statusCode, responseHeaders }) {
// Normalize header names to lowercase so downstream consumers see the same shape
// regardless of how the caller wrote them.
const normalizedResponseHeaders = {}
for (const [key, value] of Object.entries(responseHeaders)) {
normalizedResponseHeaders[key.toLowerCase()] = value
}
if (!isEmpty(normalizedResponseHeaders)) {
storedResponseHeaders.set(req, normalizedResponseHeaders)
}
// TODO: do not call waf if inside block()
// if (isBlocking()) {
// return
// }
// avoid "write after end" error
if (isBlocked(res) || callBlockDelegation(res)) {
abortController?.abort()
return
}
// avoid double waf call
if (responseAnalyzedSet.has(res)) {
return
}
const rootSpan = web.root(req)
if (!rootSpan) return
const results = waf.run({
persistent: {
[addresses.HTTP_INCOMING_RESPONSE_CODE]: String(statusCode),
[addresses.HTTP_INCOMING_RESPONSE_HEADERS]: copyHeadersOmitting(normalizedResponseHeaders, 'set-cookie'),
},
}, req)
responseAnalyzedSet.add(res)
handleResults(results?.actions, req, res, rootSpan, abortController)
}
function onResponseSetHeader ({ res, abortController }) {
if (isBlocked(res)) {
abortController?.abort()
}
}
function onStripeCheckoutSessionCreate (payload) {
if (payload?.mode !== 'payment') return
waf.run({
persistent: {
[addresses.PAYMENT_CREATION]: {
integration: 'stripe',
id: payload.id,
amount_total: payload.amount_total,
client_reference_id: payload.client_reference_id,
currency: payload.currency,
'discounts.coupon': payload.discounts?.[0]?.coupon,
'discounts.promotion_code': payload.discounts?.[0]?.promotion_code,
livemode: payload.livemode,
'total_details.amount_discount': payload.total_details?.amount_discount,
'total_details.amount_shipping': payload.total_details?.amount_shipping,
},
},
})
}
function onStripePaymentIntentCreate (payload) {
if (payload === null || typeof payload !== 'object') return
waf.run({
persistent: {
[addresses.PAYMENT_CREATION]: {
integration: 'stripe',
id: payload.id,
amount: payload.amount,
currency: payload.currency,
livemode: payload.livemode,
payment_method: payload.payment_method,
},
},
})
}
function onStripeConstructEvent (payload) {
const object = payload?.data?.object
if (object === null || typeof object !== 'object') return
let persistent
switch (payload.type) {
case 'payment_intent.succeeded':
persistent = {
[addresses.PAYMENT_SUCCESS]: {
integration: 'stripe',
id: object.id,
amount: object.amount,
currency: object.currency,
livemode: object.livemode,
payment_method: object.payment_method,
},
}
break
case 'payment_intent.payment_failed':
persistent = {
[addresses.PAYMENT_FAILURE]: {
integration: 'stripe',
id: object.id,
amount: object.amount,
currency: object.currency,
'last_payment_error.code': object.last_payment_error?.code,
'last_payment_error.decline_code': object.last_payment_error?.decline_code,
'last_payment_error.payment_method.id': object.last_payment_error?.payment_method?.id,
'last_payment_error.payment_method.type': object.last_payment_error?.payment_method?.type,
livemode: object.livemode,
},
}
break
case 'payment_intent.canceled':
persistent = {
[addresses.PAYMENT_CANCELLATION]: {
integration: 'stripe',
id: object.id,
amount: object.amount,
cancellation_reason: object.cancellation_reason,
currency: object.currency,
livemode: object.livemode,
},
}
break
default:
return
}
waf.run({ persistent })
}
function handleResults (actions, req, res, rootSpan, abortController) {
if (!actions || !req || !res || !rootSpan || !abortController) return
const blockingAction = getBlockingAction(actions)
if (blockingAction) {
block(req, res, rootSpan, abortController, blockingAction)
}
}
function disable () {
isEnabled = false
config = null
RuleManager.clearAllRules()
appsecTelemetry.disable()
graphql.disable()
rasp.disable()
appsecRemoteConfig.disableWafUpdate()
apiSecuritySampler.disable()
// Channel#unsubscribe() is undefined for non active channels
if (bodyParser.hasSubscribers) bodyParser.unsubscribe(onRequestBodyParsed)
if (multerParser.hasSubscribers) multerParser.unsubscribe(onRequestBodyParsed)
if (cookieParser.hasSubscribers) cookieParser.unsubscribe(onRequestCookieParser)
if (incomingHttpRequestStart.hasSubscribers) incomingHttpRequestStart.unsubscribe(incomingHttpStartTranslator)
if (incomingHttpRequestEnd.hasSubscribers) incomingHttpRequestEnd.unsubscribe(incomingHttpEndTranslator)
if (passportVerify.hasSubscribers) passportVerify.unsubscribe(onPassportVerify)
if (passportUser.hasSubscribers) passportUser.unsubscribe(onPassportDeserializeUser)
if (expressSession.hasSubscribers) expressSession.unsubscribe(onExpressSession)
if (queryParser.hasSubscribers) queryParser.unsubscribe(onRequestQueryParsed)
if (nextBodyParsed.hasSubscribers) nextBodyParsed.unsubscribe(onRequestBodyParsed)
if (nextQueryParsed.hasSubscribers) nextQueryParsed.unsubscribe(onRequestQueryParsed)
if (expressProcessParams.hasSubscribers) expressProcessParams.unsubscribe(onRequestProcessParams)
if (fastifyBodyParser.hasSubscribers) fastifyBodyParser.unsubscribe(onRequestBodyParsed)
if (fastifyQueryParams.hasSubscribers) fastifyQueryParams.unsubscribe(onRequestQueryParsed)
if (fastifyCookieParser.hasSubscribers) fastifyCookieParser.unsubscribe(onRequestCookieParser)
if (fastifyPathParams.hasSubscribers) fastifyPathParams.unsubscribe(onRequestProcessParams)
if (routerParam.hasSubscribers) routerParam.unsubscribe(onRequestProcessParams)
if (responseBody.hasSubscribers) responseBody.unsubscribe(onResponseBody)
if (fastifyResponseChannel.hasSubscribers) fastifyResponseChannel.unsubscribe(onResponseBody)
if (responseWriteHead.hasSubscribers) responseWriteHead.unsubscribe(onResponseWriteHead)
if (responseSetHeader.hasSubscribers) responseSetHeader.unsubscribe(onResponseSetHeader)
if (stripeCheckoutSessionCreate.hasSubscribers) stripeCheckoutSessionCreate.unsubscribe(onStripeCheckoutSessionCreate)
if (stripePaymentIntentCreate.hasSubscribers) stripePaymentIntentCreate.unsubscribe(onStripePaymentIntentCreate)
if (stripeConstructEvent.hasSubscribers) stripeConstructEvent.unsubscribe(onStripeConstructEvent)
}
/**
* @param {Record<string, unknown>} src
* @param {string} omit
*/
function copyHeadersOmitting (src, omit) {
const filtered = {}
for (const key of Object.keys(src)) {
if (key !== omit) filtered[key] = src[key]
}
return filtered
}
module.exports = {
enable,
disable,
incomingHttpStartTranslator,
incomingHttpEndTranslator,
}