Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: change how 'cookie' header is represented in trans to avoid possible mapping conflict #4007

Merged
merged 1 commit into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions CHANGELOG.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,47 @@ Notes:

See the <<upgrade-to-v4>> guide.

==== Unreleased

[float]
===== Breaking changes

[float]
===== Features

[float]
===== Bug fixes

- Change how the "cookie" HTTP request header is represented in APM transaction
data to avoid a rare, but possible, intake bug where the transaction could be
rejected due to a mapping conflict.

Before this change a `Cookie: foo=bar; sessionid=42` HTTP request header
would be represented in the transaction document in Elasticsearch with these
document fields (the example assumes <<sanitize-field-names>> matches
"sessionid", as it does by default):

```
http.request.headers.cookie: "[REDACTED]"
...
http.request.cookies.foo: "bar"
http.request.cookies.sessionid: "[REDACTED]"
```

After this change it is represented as:

```
http.request.headers.cookie: "foo=bar; sessionid=REDACTED"
```

In other words, `http.request.cookies` are no longer separated out.
({issues}4006[#4006])


[float]
===== Chores


[[release-notes-4.5.3]]
==== 4.5.3 - 2024/04/23

Expand Down
5 changes: 3 additions & 2 deletions lib/filters/sanitize-field-names.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,17 @@ function redactKeysFromPostedFormVariables(body, requestHeaders, regexes) {
*
* @param {Object} obj The source object be copied with redacted fields
* @param {Array<RegExp>} regexes RegExps to check if the entry value needd to be redacted
* @param {String} redactedStr The string to use for redacted values. Defaults to '[REDACTED]'.
* @returns {Object} Copy of the source object with REDACTED entries or the original if falsy or regexes is not an array
*/
function redactKeysFromObject(obj, regexes) {
function redactKeysFromObject(obj, regexes, redactedStr = REDACTED) {
if (!obj || !Array.isArray(regexes)) {
return obj;
}
const result = {};
for (const key of Object.keys(obj)) {
const shouldRedact = regexes.some((regex) => regex.test(key));
result[key] = shouldRedact ? REDACTED : obj[key];
result[key] = shouldRedact ? redactedStr : obj[key];
}
return result;
}
Expand Down
28 changes: 22 additions & 6 deletions lib/parsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ const {
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
Expand Down Expand Up @@ -61,14 +70,21 @@ function getContextFromRequest(req, conf, type) {
conf.sanitizeFieldNamesRegExp,
);

if (context.headers.cookie) {
context.cookies = cookie.parse(req.headers.cookie);
context.cookies = redactKeysFromObject(
context.cookies,
if (context.headers.cookie && context.headers.cookie !== REDACTED) {
let cookies = cookie.parse(req.headers.cookie);
cookies = redactKeysFromObject(
cookies,
conf.sanitizeFieldNamesRegExp,
COOKIE_VAL_REDACTED,
);
// Redact the cookie to avoid data duplication
context.headers.cookie = 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;
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"docs:open": "PREVIEW=1 npm run docs:build",
"docs:build": "./docs/scripts/build_docs.sh apm-agent-nodejs ./docs ./build",
"lint": "npm run lint:eslint && npm run lint:license-files && npm run lint:yaml-files && npm run lint:tav",
"lint:eslint": "eslint # requires node >=18.18.0",
"lint:eslint": "eslint . # requires node >=18.18.0",
"lint:eslint-nostyle": "eslint --rule 'prettier/prettier: off' . # lint without checking style, not normally used; requires node>=18.18.0",
"lint:fix": "eslint --fix . # requires node >=18.18.0",
"lint:license-files": "./dev-utils/gen-notice.sh --lint . # requires node >=16",
Expand Down
7 changes: 1 addition & 6 deletions test/instrumentation/transaction.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -553,15 +553,10 @@ test('#_encode() - http request meta data', function (t) {
host: 'example.com',
'user-agent': 'user-agent-header',
'content-length': 42,
cookie: '[REDACTED]',
cookie: 'cookie1=foo; cookie2=bar; session-id=REDACTED',
'x-foo': 'bar',
'x-bar': 'baz',
},
cookies: {
cookie1: 'foo',
cookie2: 'bar',
'session-id': '[REDACTED]',
},
body: '[REDACTED]',
},
});
Expand Down
63 changes: 63 additions & 0 deletions test/parsers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ var http = require('http');
var test = require('tape');

var parsers = require('../lib/parsers');
const { normalizeSanitizeFieldNames } = require('../lib/config/normalizers');

test('#getContextFromResponse()', function (t) {
t.test('for error (before headers)', function (t) {
Expand Down Expand Up @@ -279,6 +280,68 @@ test('#getContextFromRequest()', function (t) {
t.end();
});

t.test('cookie header fields are sanitized', function (t) {
const conf = { captureHeaders: true, sanitizeFieldNames: ['*session*'] };
normalizeSanitizeFieldNames(conf);
const req = {
httpVersion: '1.1',
method: 'GET',
url: '/',
headers: {
host: 'example.com',
cookie: 'foo=bar%3Bbaz; spam=eggs; sessionid=42',
},
};
const parsed = parsers.getContextFromRequest(req, conf);
t.deepEqual(parsed, {
http_version: '1.1',
method: 'GET',
url: {
raw: '/',
protocol: 'http:',
hostname: 'example.com',
pathname: '/',
full: 'http://example.com/',
},
headers: {
host: 'example.com',
cookie: 'foo=bar%3Bbaz; spam=eggs; sessionid=REDACTED',
},
});
t.end();
});

t.test('cookie header is in sanitizeFieldNames', function (t) {
const conf = {
captureHeaders: true,
sanitizeFieldNames: ['*session*', 'cookie'],
};
normalizeSanitizeFieldNames(conf);
const req = {
httpVersion: '1.1',
method: 'GET',
url: '/',
headers: {
host: 'example.com',
cookie: 'foo=bar%3Bbaz; spam=eggs; sessionid=42',
},
};
const parsed = parsers.getContextFromRequest(req, conf);
t.deepEqual(parsed, {
http_version: '1.1',
method: 'GET',
url: {
raw: '/',
protocol: 'http:',
hostname: 'example.com',
pathname: '/',
full: 'http://example.com/',
},
headers: { host: 'example.com', cookie: '[REDACTED]' },
});
t.end();
});

function getMockReq() {
return {
httpVersion: '1.1',
Expand Down
Loading