Skip to content

Commit b00cfb8

Browse files
david-lunatrentm
andauthored
fix: avoid redaction of response headers (#3427)
Make sure that redaction of captured response headers does not impact the actual headers object in user code. Refs: #3382 Co-authored-by: Trent Mick <trent.mick@elastic.co>
1 parent 425334f commit b00cfb8

5 files changed

Lines changed: 87 additions & 27 deletions

File tree

CHANGELOG.asciidoc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ Notes:
4141
4242
* Add support for 'knex' version v1 and v2. ({pull}3355[#3355])
4343
* Add `tedious@16.x` support. ({pull}3366[#3366])
44+
* Add `apm.setGlobalLabel()` to dynamically extend the `globalLabels` set in the initial config.
45+
Refer to <<apm-set-global-label>> for details. ({pull}3337[#3337])
4446
4547
[float]
4648
===== Bug fixes
@@ -60,6 +62,9 @@ code (`import 'elastic-apm-node/start.js'` or `require('elastic-apm-node/start.j
6062
in a Worker will *not* start the APM agent. Instead, one must use:
6163
`require('elastic-apm-node').start()` or equivalent.
6264
65+
* Avoid redaction of response headers while extracting `transaction.context.response`
66+
data from the HTTP response. Contributed by @lytc. ({pull}3427[#3427])
67+
6368
[float]
6469
===== Chores
6570
@@ -100,7 +105,6 @@ in a Worker will *not* start the APM agent. Instead, one must use:
100105
and the APM agent will automatically add a MetricReader to ship metrics to
101106
APM server. See the <<opentelemetry-bridge>> for details. ({pull}3152[#3152])
102107
103-
* Add `apm.setGlobalLabel()` to dynamically extend the `globalLabels` set in the initial config. Refer to <<apm-set-global-label>> for details. ({pull}3337[#3337])
104108
105109
[float]
106110
===== Chores

lib/filters/sanitize-field-names.js

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ const REDACTED = require('../constants').REDACTED
1515
*
1616
* Express provides multiple body parser middlewares with x-www-form-urlencoded
1717
* handling. See http://expressjs.com/en/resources/middleware/body-parser.html
18+
*
19+
* @param {Object | String} body
20+
* @param {Object} requestHeaders
21+
* @param {Array<RegExp>} regexes
22+
* @returns {Object | String} a copy of the body with the redacted fields
1823
*/
1924
function redactKeysFromPostedFormVariables (body, requestHeaders, regexes) {
2025
// only redact from application/x-www-form-urlencoded
@@ -30,31 +35,31 @@ function redactKeysFromPostedFormVariables (body, requestHeaders, regexes) {
3035
// if body is a string, use querystring to create object,
3136
// pass to redactKeysFromObject, and reserialize as string
3237
if (typeof body === 'string') {
33-
const objBody = querystring.parse(body)
34-
redactKeysFromObject(objBody, regexes)
38+
const objBody = redactKeysFromObject(querystring.parse(body), regexes)
3539
return querystring.stringify(objBody)
3640
}
3741

3842
return body
3943
}
4044

41-
function redactKeyFromObject (obj, regex) {
42-
for (const [key] of Object.entries(obj)) {
43-
if (regex.test(key)) {
44-
obj[key] = REDACTED
45-
}
46-
}
47-
return obj
48-
}
49-
45+
/**
46+
* Returns a copy of the provided object. Each entry of the copy will have
47+
* its value REDACTEd if the key matches any of the regexes
48+
*
49+
* @param {Object} obj The source object be copied with redacted fields
50+
* @param {Array<RegExp>} regexes RegExps to check if the entry value needd to be redacted
51+
* @returns {Object} Copy of the source object with REDACTED entries or the original if falsy or regexes is not an array
52+
*/
5053
function redactKeysFromObject (obj, regexes) {
5154
if (!obj || !Array.isArray(regexes)) {
5255
return obj
5356
}
54-
for (const [, regex] of regexes.entries()) {
55-
redactKeyFromObject(obj, regex)
57+
const result = {}
58+
for (const key of Object.keys(obj)) {
59+
const shouldRedact = regexes.some((regex) => regex.test(key))
60+
result[key] = shouldRedact ? REDACTED : obj[key]
5661
}
57-
return obj
62+
return result
5863
}
5964

6065
module.exports = {

lib/parsers.js

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,7 @@ function getContextFromRequest (req, conf, type) {
5656
}
5757

5858
if (conf.captureHeaders) {
59-
context.headers = redactKeysFromObject(
60-
Object.assign({}, req.headers),
61-
conf.sanitizeFieldNamesRegExp
62-
)
59+
context.headers = redactKeysFromObject(req.headers, conf.sanitizeFieldNamesRegExp)
6360

6461
// TODO: remove filterHttpHeaders option in next major release
6562
// https://github.com/elastic/apm-agent-nodejs/issues/3332
@@ -102,6 +99,25 @@ function getContextFromRequest (req, conf, type) {
10299
return context
103100
}
104101

102+
/**
103+
* Extract appropriate `{transaction,error}.context.response` from an HTTP
104+
* response object. This handles header redaction according to the agent config.
105+
*
106+
* @param {Object} res - Typically `res` is a Node.js `http.OutgoingMessage`
107+
* (https://nodejs.org/api/http.html#class-httpoutgoingmessage).
108+
* However, some cases (e.g. Lambda and Azure Functions instrumentation)
109+
* create a pseudo-res object that matches well enough for this function.
110+
* Some relevant fields: (TODO: document all used fields)
111+
* - `statusCode` - Required. A number.
112+
* - `headers` - An object.
113+
* - `headersSent` - Boolean indicating if the headers have been sent
114+
* (https://nodejs.org/api/http.html#outgoingmessageheaderssent)
115+
* - `finished` - Boolean indicating if `response.end()` has been called
116+
* (https://nodejs.org/api/http.html#responsefinished)
117+
* @param {Object} conf - The full agent configuration.
118+
* @param {Boolean} isError - Indicates if this response contains an error and
119+
* some extra fields should be added to the context
120+
*/
105121
function getContextFromResponse (res, conf, isError) {
106122
var context = {
107123
status_code: res.statusCode,

test/parsers.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,36 @@ test('#getContextFromResponse()', function (t) {
8787
res.end()
8888
})
8989
})
90+
91+
// Some instrumentations set a pseudo response object, e.g. serverless
92+
// functions where the "response" isn't a core Node.js `http.OutgoingMessage`.
93+
t.test('for pseudo-res', function (t) {
94+
const testCases = [
95+
{
96+
res: { statusCode: 500 },
97+
conf: { captureHeaders: true },
98+
isError: false,
99+
expectedContext: { status_code: 500, headers: {} }
100+
},
101+
{
102+
res: { statusCode: 500 },
103+
conf: { captureHeaders: true },
104+
isError: true,
105+
expectedContext: { status_code: 500, headers: {}, headers_sent: undefined, finished: undefined }
106+
},
107+
{
108+
res: { statusCode: 200, headers: { 'content-type': 'application/json' } },
109+
conf: { captureHeaders: true },
110+
isError: false,
111+
expectedContext: { status_code: 200, headers: { 'content-type': 'application/json' } }
112+
}
113+
]
114+
testCases.forEach((tc, idx) => {
115+
const context = parsers.getContextFromResponse(tc.res, tc.conf, tc.isError)
116+
t.deepEqual(context, tc.expectedContext, `pseudo-res testCase ${idx}`)
117+
})
118+
t.end()
119+
})
90120
})
91121

92122
test('#getContextFromRequest()', function (t) {

test/sanitize-field-names/main.test.js

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,25 @@ test('redactKeysFromObject tests', function (t) {
2121
three: 'four',
2222
five: 'six'
2323
}
24-
redactKeysFromObject(obj1, [/th.*ee/])
25-
t.equals(obj1.three, '[REDACTED]', 'key three redacted')
26-
t.equals(obj1.one, 'two', 'key one remains in ibject')
27-
t.equals(obj1.five, 'six', 'key five remains in object')
24+
const redactedObj1 = redactKeysFromObject(obj1, [/th.*ee/])
25+
t.equals(redactedObj1.three, '[REDACTED]', 'key three redacted')
26+
t.equals(redactedObj1.one, 'two', 'key one remains in ibject')
27+
t.equals(redactedObj1.five, 'six', 'key five remains in object')
28+
t.ok(obj1 !== redactedObj1, 'redacted object is a copy')
29+
t.equals(obj1.three, 'four', 'original object is not redacted')
2830

2931
const obj2 = {
3032
one: 'two',
3133
three: 'four',
3234
five: 'six'
3335
}
34-
redactKeysFromObject(obj2, [/th.*ee/, /three/, /.*five/])
35-
t.equals(obj2.three, '[REDACTED]', 'key three redacted')
36-
t.equals(obj2.one, 'two', 'key one remains in ibject')
37-
t.equals(obj2.five, '[REDACTED]', 'key five redacted')
36+
const redactedObj2 = redactKeysFromObject(obj2, [/th.*ee/, /three/, /.*five/])
37+
t.equals(redactedObj2.three, '[REDACTED]', 'key three redacted')
38+
t.equals(redactedObj2.one, 'two', 'key one remains in ibject')
39+
t.equals(redactedObj2.five, '[REDACTED]', 'key five redacted')
40+
t.ok(obj2 !== redactedObj2, 'redacted object is a copy')
41+
t.equals(obj2.three, 'four', 'original object is not redacted')
42+
t.equals(obj2.five, 'six', 'original object is not redacted')
3843

3944
t.end()
4045
})

0 commit comments

Comments
 (0)