UNPKG

atatus-nodejs

Version:

Atatus APM agent for Node.js

328 lines (288 loc) 10.5 kB
/* * 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. */ 'use strict'; 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, };