dd-trace
Version:
Datadog APM tracing client for JavaScript
303 lines (251 loc) • 8.84 kB
JavaScript
const web = require('../plugins/util/web')
const log = require('../log')
const {
HTTP_OUTGOING_METHOD,
HTTP_OUTGOING_HEADERS,
HTTP_OUTGOING_RESPONSE_STATUS,
HTTP_OUTGOING_RESPONSE_HEADERS,
HTTP_OUTGOING_RESPONSE_BODY,
} = require('./addresses')
const KNUTH_FACTOR = 11400714819323199488n // eslint-disable-line unicorn/numeric-separators-style
const UINT64_MAX = (1n << 64n) - 1n
let config
let samplingRate
let globalRequestCounter
let bodyAnalysisCount
let downstreamAnalysisCount
let redirectBodyCollectionDecisions
function enable (_config) {
config = _config
globalRequestCounter = 0n
bodyAnalysisCount = new WeakMap()
downstreamAnalysisCount = new WeakMap()
redirectBodyCollectionDecisions = new WeakMap()
const bodyAnalysisSampleRate = config.appsec.apiSecurity?.downstreamBodyAnalysisSampleRate
samplingRate = Math.min(Math.max(bodyAnalysisSampleRate, 0), 1)
if (samplingRate !== bodyAnalysisSampleRate) {
log.warn(
'DD_API_SECURITY_DOWNSTREAM_BODY_ANALYSIS_SAMPLE_RATE value is %s and it\'s out of range',
bodyAnalysisSampleRate)
}
}
function disable () {
config = null
globalRequestCounter = null
bodyAnalysisCount = null
downstreamAnalysisCount = null
redirectBodyCollectionDecisions = null
}
/**
* Check we have a stored redirect body collection decision for a given URL.
* @param {import('http').IncomingMessage} req outgoing request.
* @param {string} outgoingUrl the URL being requested.
* @returns {boolean} the stored decision
*/
function consumeRedirectBodyCollectionDecision (req, outgoingUrl) {
const decisions = redirectBodyCollectionDecisions.get(req)
if (!decisions) return false
return decisions.delete(outgoingUrl)
}
/**
* Stores a redirect body collection decision for a follow-up request.
* @param {import('http').IncomingMessage} req outgoing request.
* @param {string} redirectUrl the URL to redirect to.
*/
function storeRedirectBodyCollectionDecision (req, redirectUrl) {
let decisions = redirectBodyCollectionDecisions.get(req)
if (!decisions) {
decisions = new Set()
redirectBodyCollectionDecisions.set(req, decisions)
}
decisions.add(redirectUrl)
}
/**
* Determines whether the current downstream request/responses bodies should be sampled for analysis.
* @param {import('http').IncomingMessage} req outgoing request.
* @param {string} outgoingUrl the URL being requested (to check for redirect decisions).
* @returns {boolean} true when the downstream response body should be captured.
*/
function shouldSampleBody (req, outgoingUrl) {
// Check if there's a stored decision from a previous redirect
const storedDecision = consumeRedirectBodyCollectionDecision(req, outgoingUrl)
if (storedDecision) return true
globalRequestCounter = (globalRequestCounter + 1n) & UINT64_MAX
const currentCount = bodyAnalysisCount.get(req) || 0
if (currentCount >= config.appsec.apiSecurity?.maxDownstreamRequestBodyAnalysis) {
return false
}
const hashed = (globalRequestCounter * KNUTH_FACTOR) % UINT64_MAX
// Replace 1000n with the accuraccy that we want to maintain
const threshold = (UINT64_MAX * BigInt(Math.round(samplingRate * 1000))) / 1000n
const shouldCollectBody = hashed <= threshold
// Track body analysis count if we're sampling the response body
if (shouldCollectBody) {
incrementBodyAnalysisCount(req)
}
return shouldCollectBody
}
/**
* Increments the number of downstream body analyses performed for the given request.
* @param {import('http').IncomingMessage} req outgoing request.
*/
function incrementBodyAnalysisCount (req) {
const currentCount = bodyAnalysisCount.get(req) || 0
bodyAnalysisCount.set(req, currentCount + 1)
}
/**
*
* @param {object} headers
* @returns {object} the headers with all keys converted to lowercase
*/
function lowercaseHeaderKeys (headers) {
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
}
/**
* Extracts request data from the context for WAF analysis
* @param {object} ctx context for the outgoing downstream request.
* @returns {object} a map of addresses and request data.
*/
function extractRequestData (ctx) {
const addresses = {}
const options = ctx?.args?.options || {}
addresses[HTTP_OUTGOING_METHOD] = getMethod(options.method)
const headers = options?.headers
if (headers && Object.keys(headers).length > 0) {
addresses[HTTP_OUTGOING_HEADERS] = lowercaseHeaderKeys(headers)
}
return addresses
}
/**
* Checks if a response is a redirect
* @param {import('http').IncomingMessage} req incoming server request.
* @param {import('http').IncomingMessage} res downstream response object.
* @returns {boolean} is redirect.
*/
function handleRedirectResponse (req, res) {
const isRedirect = res.statusCode >= 300 && res.statusCode < 400
const redirectLocation = res.headers?.location || ''
if (isRedirect && redirectLocation) {
// Store the body collection decision for the redirect target
storeRedirectBodyCollectionDecision(req, redirectLocation)
return true
}
return false
}
/**
* Extracts response data for WAF analysis.
* @param {import('http').IncomingMessage} res downstream response object.
* @param {Buffer|string|object|null} responseBody response body.
* @returns {object} a map of addresses and response data.
*/
function extractResponseData (res, responseBody) {
const addresses = {}
if (res.statusCode) {
addresses[HTTP_OUTGOING_RESPONSE_STATUS] = String(res.statusCode)
}
const headers = res.headers
if (headers && Object.keys(headers).length > 0) {
addresses[HTTP_OUTGOING_RESPONSE_HEADERS] = headers
}
if (responseBody) {
// Parse the body based on content-type
const contentType = res.headers?.['content-type']
const body = parseBody(responseBody, contentType)
if (body) {
addresses[HTTP_OUTGOING_RESPONSE_BODY] = body
}
}
return addresses
}
/**
* Tracks how many downstream analyses were executed for a given request and updates tracing tags.
* @param {import('http').IncomingMessage} req outgoing request.
*/
function incrementDownstreamAnalysisCount (req) {
const currentCount = downstreamAnalysisCount.get(req) || 0
downstreamAnalysisCount.set(req, currentCount + 1)
const span = web.root(req)
if (span) {
span.setTag('_dd.appsec.downstream_request', currentCount + 1)
}
}
/**
* Returns the HTTP method to use for a downstream request, defaulting to GET.
* @param {string} method method supplied in the outgoing request options.
* @returns {string} validated HTTP method.
*/
function getMethod (method) {
return typeof method === 'string' && method ? method : 'GET'
}
/**
* Parses a downstream response body.
* @param {Buffer|string|object|null} body raw response body
* @param {string|null} contentType response content-type used to select the parser.
* @returns {object|null} parsed body object or null when not supported.
*/
function parseBody (body, contentType) {
if (!body || !contentType) {
return null
}
const mime = extractMimeType(contentType)
try {
if (mime === 'application/json' || mime === 'text/json') {
if (typeof body === 'string') {
return JSON.parse(body)
}
if (Buffer.isBuffer(body)) {
return JSON.parse(body.toString('utf8'))
}
return null
}
if (mime === 'application/x-www-form-urlencoded') {
const formBody = Buffer.isBuffer(body) ? body.toString('utf8') : String(body)
const params = new URLSearchParams(formBody)
const result = {}
for (const [key, value] of params.entries()) {
if (key in result) {
const existing = result[key]
if (Array.isArray(existing)) {
existing.push(value)
} else {
result[key] = [existing, value]
}
} else {
result[key] = value
}
}
return result
}
// multipart/form-data is mentioned in RFC but parsing is complex.
// Other content-types also discarded per RFC
return null
} catch {
// Parsing failed: return null to avoid sending malformed body to WAF
return null
}
}
/**
* Extracts the MIME type portion of a content-type header value.
* @param {string|null} contentType raw content-type header value.
* @returns {string|null} lowercase mime type
*/
function extractMimeType (contentType) {
if (typeof contentType !== 'string') {
return null
}
return contentType.split(';', 1)[0].trim().toLowerCase()
}
module.exports = {
enable,
disable,
shouldSampleBody,
handleRedirectResponse,
incrementDownstreamAnalysisCount,
extractRequestData,
extractResponseData,
// exports for tests
parseBody,
getMethod,
storeRedirectBodyCollectionDecision,
}