atatus-nodejs
Version:
Atatus APM agent for Node.js
328 lines (288 loc) • 10.5 kB
JavaScript
/*
* Copyright Elasticsearch B.V. and other contributors where applicable.
* Licensed under the BSD 2-Clause License; you may not use this file except in
* compliance with the BSD 2-Clause License.
*/
;
const url = require('url');
var utils = require('./utils');
const basicAuth = require('basic-auth');
const getOriginalUrl = require('original-url');
const parseHttpHeadersFromReqOrRes = require('http-headers');
const cookie = require('cookie');
const stringify = require('fast-safe-stringify');
const truncate = require('unicode-byte-truncate');
const REDACTED = require('./constants').REDACTED;
const {
redactKeysFromObject,
redactKeysFromPostedFormVariables,
} = require('./filters/sanitize-field-names');
// When redacting individual cookie field values, this string is used instead
// of `[REDACTED]`. The APM spec says:
// > The replacement string SHOULD be `[REDACTED]`.
// We diverge from spec here because, for better or worse, the `cookie` module
// does `encodeURIComponent/decodeURIComponent` encoding on cookie fields. If we
// used the brackets, then the reconstructed cookie would look like
// `foo=bar; session-id=%5BREDACTED%5D`, which isn't helpful.
const COOKIE_VAL_REDACTED = 'REDACTED';
/**
* Extract appropriate `{transaction,error}.context.request` from an HTTP
* request object. This handles header and body capture and redaction
* according to the agent config.
*
* @param {Object} req - Typically `req` is a Node.js `http.IncomingMessage`
* (https://nodejs.org/api/all.html#all_http_class-httpincomingmessage).
* However, some cases (e.g. Lambda and Azure Functions instrumentation)
* create a pseudo-req object that matches well enough for this function.
* Some relevant fields: (TODO: document all used fields)
* - `headers` - Required. An object.
* - `body` - The incoming request body, if available. The `json` and
* `payload` fields are also checked to accomodate some web frameworks.
* - `bodyIsBase64Encoded` - An optional boolean. If `true`, then the `body`
* needs to be base64-decoded before inclusion and redaction. Used by
* Lambda instrumentation in some cases (e.g. for ELB triggers).
* @param {Object} conf - The full agent configuration.
* @param {String} type - 'errors' or 'transactions'. Indicates if this req
* is being captured for an APM error or transaction event.
*/
function getContextFromRequest(req, conf, type) {
// var captureBody = conf.captureBody === type || conf.captureBody === 'all';
var captureBody = conf.captureBody === 'request' || conf.captureBody === 'all'
var context = {
http_version: req.httpVersion,
method: req.method,
url: getUrlFromRequest(req),
headers: undefined,
};
if (req.socket && req.socket.remoteAddress) {
context.socket = {
remote_address: req.socket.remoteAddress,
};
}
if (conf.captureHeaders) {
context.headers = redactKeysFromObject(
req.headers,
conf.sanitizeFieldNamesRegExp,
);
if (context.headers.cookie && context.headers.cookie !== REDACTED) {
let cookies = cookie.parse(req.headers.cookie);
cookies = redactKeysFromObject(
cookies,
conf.sanitizeFieldNamesRegExp,
COOKIE_VAL_REDACTED,
);
try {
context.headers.cookie = Object.keys(cookies)
.map((k) => cookie.serialize(k, cookies[k]))
.join('; ');
} catch (_err) {
// Fallback to full redaction if there is an issue re-serializing.
context.headers.cookie = REDACTED;
}
}
}
var contentLength = parseInt(req.headers['content-length'], 10);
var transferEncoding = req.headers['transfer-encoding'];
var chunked =
typeof transferEncoding === 'string' &&
transferEncoding.toLowerCase() === 'chunked';
var body = req.json || req.body || req.payload;
var haveBody = body && (chunked || contentLength > 0);
if (haveBody) {
if (!captureBody) {
context.body = '[REDACTED]';
} else if (Buffer.isBuffer(body)) {
context.body = '<Buffer>';
} else {
if (typeof body === 'string' && req.bodyIsBase64Encoded === true) {
body = Buffer.from(body, 'base64').toString('utf8');
}
body = redactKeysFromPostedFormVariables(
body,
req.headers,
conf.sanitizeFieldNamesRegExp,
);
// if (typeof body !== 'string') {
// body = tryJsonStringify(body) || stringify(body);
// }
// context.body = body;
context.body = getBodyStr(body)
}
}
// TODO: Tempoary fix for https://github.com/elastic/apm-agent-nodejs/issues/813
if (context.url && context.url.port) {
context.url.port = String(context.url.port);
}
return context;
}
/**
* Extract appropriate `{transaction,error}.context.response` from an HTTP
* response object. This handles header redaction according to the agent config.
*
* @param {Object} res - Typically `res` is a Node.js `http.ServerResponse`
* (https://nodejs.org/api/http.html#class-httpserverresponse).
* However, some cases (e.g. Lambda and Azure Functions instrumentation)
* create a pseudo-res object that matches well enough for this function.
* Some relevant fields: (TODO: document all used fields)
* - `statusCode` - Required. A number.
* - `headers` - An object.
* - `headersSent` - Boolean indicating if the headers have been sent
* (https://nodejs.org/api/http.html#outgoingmessageheaderssent)
* - `finished` - Boolean indicating if `response.end()` has been called
* (https://nodejs.org/api/http.html#responsefinished)
* @param {Object} conf - The full agent configuration.
* @param {Boolean} isError - Indicates if this response contains an error and
* some extra fields should be added to the context
*/
function getContextFromResponse(res, conf, isError) {
var captureBody = conf.captureBody === 'response' || conf.captureBody === 'all';
var contentTypes = conf.logBodyContentTypes || [];
var isAllowedContentType = false;
var context = {
status_code: res.statusCode,
headers: undefined,
};
// if (conf.captureHeaders) {
// context.headers = res.headers || parseHttpHeadersFromReqOrRes(res, true);
// context.headers = redactKeysFromObject(
// context.headers,
// conf.sanitizeFieldNamesRegExp,
// );
// }
// if (isError) {
// context.headers_sent = res.headersSent;
// if (typeof res.finished === 'boolean') {
// context.finished = res.finished;
// } else {
// context.finished = res.writableEnded;
// }
// }
if (conf.captureHeaders) {
context.headers = res.headers || utils.getResponseHeaders(res) || {}
for (let i = 0; i < contentTypes.length; i++) {
isAllowedContentType = context.headers['content-type'] && context.headers['content-type'].includes(contentTypes[i])
if (isAllowedContentType) {
break
}
}
}
// if (type === 'errors') {
// context.headers_sent = res.headersSent
// context.finished = res.finished
// }
if (!isAllowedContentType) {
return context
}
if (res._atbody) {
if (captureBody) {
var isCompresssed = !!((context.headers['content-encoding'] || '').length)
context.body = utils.getResponseBodyString(res._atbody, isCompresssed)
} else {
context.body = '[REDACTED]'
}
}
return context;
}
/**
* Extract appropriate `{transaction,error}.context.user` from an HTTP
* request object.
*
* @param {Object} req - Typically `req` is a Node.js `http.IncomingMessage`.
* However, some cases (e.g. Lambda and Azure Functions instrumentation)
* create a pseudo-req object that matches well enough for this function.
* Some relevant fields: (TODO: document all used fields)
* - `headers` - Required. An object.
*/
function getUserContextFromRequest(req) {
var user = req.user || basicAuth(req) || req.session;
if (!user) {
return;
}
var context = {};
// if (typeof user.id === 'string' || typeof user.id === 'number') {
// context.id = user.id;
// } else if (typeof user._id === 'string' || typeof user._id === 'number') {
// context.id = user._id;
// }
var user_id = user.id || user._id || user.user_id || user.userId
if (typeof user_id === 'string' || typeof user_id === 'number') {
context.id = '' + user_id
}
if (typeof user.username === 'string') {
context.name = user.username;
} else if (typeof user.name === 'string') {
context.name = user.name
} else if (typeof user.firstName === 'string') {
context.name = user.firstName
if (typeof user.lastName === 'string') {
context.name = user.firstName + ' ' + user.lastName
}
}
if (typeof user.email === 'string') {
context.email = user.email;
}
return context;
}
function parseUrl(urlStr) {
return new url.URL(urlStr, 'relative:///');
}
function tryJsonStringify(obj) {
try {
return JSON.stringify(obj);
} catch (e) {}
}
function getBodyStr(body) {
var bodyStr = typeof body === 'string'
? body
: (tryJsonStringify(body) || stringify(body))
if (bodyStr.length > utils._MAX_HTTP_BODY_CHARS) {
bodyStr = truncate(bodyStr, utils._MAX_HTTP_BODY_CHARS)
}
return bodyStr
}
function safeGetReqSecure(req) {
try {
return req.secure
} catch(err) {
return false
}
}
function safeGetHostname(req) {
try {
return req.hostname
} catch(err) {
return (req.headers && req.headers['x-forwarded-host']) || 'localhost'
}
}
function getUrlFromRequest(req) {
try {
return getOriginalUrl(req)
} catch (err) {
try {
const raw = req.originalUrl || req.url
const protocol = (req.connection && req.connection.encrypted) || safeGetReqSecure(req) ? 'https:' : 'http:'
const host = req.headers.host || safeGetHostname(req)
const fullUrl = protocol + '//' + host + raw
const parsedUrl = parseUrl(raw || '')
var url = {
raw: raw,
protocol: protocol,
hostname: host,
port: parsedUrl.port ? Number(parsedUrl.port) : undefined,
pathname: parsedUrl.pathname || raw,
search: parsedUrl.search,
full: fullUrl
}
return url
} catch (e) {
return null
}
}
}
module.exports = {
getContextFromRequest,
getContextFromResponse,
getUserContextFromRequest,
parseUrl,
getBodyStr,
};