-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
ref(core): Consolidate cookie parsing into one parser #24536
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
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ import type { Event } from '../types/event'; | |
| import type { IntegrationFn } from '../types/integration'; | ||
| import type { QueryParams, RequestEventData } from '../types/request'; | ||
| import type { StreamedSpanJSON } from '../types/span'; | ||
| import { parseCookie } from '../utils/cookie'; | ||
| import { cookiePairsToRecord, parseCookieHeader } from '../utils/cookie'; | ||
| import { SENSITIVE_COOKIE_NAME_SNIPPETS } from '../utils/data-collection/filtering-snippets'; | ||
| import { filterKeyValueData } from '../utils/data-collection/filterKeyValueData'; | ||
| import { filterQueryParams } from '../utils/data-collection/filterQueryParams'; | ||
|
|
@@ -245,7 +245,9 @@ function extractNormalizedRequestData( | |
| } | ||
|
|
||
| if (include.cookies) { | ||
| const cookies = normalizedRequest.cookies || (headers?.cookie ? parseCookie(headers.cookie) : undefined); | ||
| const cookies = | ||
| normalizedRequest.cookies || | ||
| (headers?.cookie ? cookiePairsToRecord(parseCookieHeader(headers.cookie, 'cookie')) : undefined); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a decoding-escape hole here. This bit produces a record of decoded values. Then line 190 turns that record back into a header string and line 192 re-parses the result. A percent-encoded Reproduced end to end through Two more lines keep this alive:
This is pre-existing. But since we're cleaning up cookie handling, and this is the last place that parses a cookie string it built itself, and the fix is small, probably a good idea to clean it up. Suggestion: only synthesize a cookie string when Or, maybe better: have |
||
| requestData.cookies = cookies || {}; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,5 +1,5 @@ | ||||||
| /** | ||||||
| * This code was originally copied from the 'cookie` module at v0.5.0 and was simplified for our use case. | ||||||
| * The value decoding in `cookiePairsToRecord` was originally copied from the 'cookie` module at v0.5.0. | ||||||
| * https://github.com/jshttp/cookie/blob/a0c84147aab6266bdb3996cf4062e93907c0b0fc/index.js | ||||||
| * It had the following license: | ||||||
| * | ||||||
|
|
@@ -28,51 +28,67 @@ | |||||
| * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||||||
| */ | ||||||
|
|
||||||
| /** | ||||||
| * Parses a cookie string | ||||||
| */ | ||||||
| export function parseCookie(str: string): Record<string, string> { | ||||||
| const obj: Record<string, string> = {}; | ||||||
| let index = 0; | ||||||
| import { FILTERED_VALUE } from './data-collection/filtering-snippets'; | ||||||
|
|
||||||
| while (index < str.length) { | ||||||
| const eqIdx = str.indexOf('=', index); | ||||||
| /** A cookie's name and raw value. A nameless cookie (RFC 6265bis) has the name `''`. */ | ||||||
| export type CookiePair = [name: string, value: string]; | ||||||
|
|
||||||
| // no more cookie pairs | ||||||
| if (eqIdx === -1) { | ||||||
| break; | ||||||
| /** | ||||||
| * Splits a `Cookie` / `Set-Cookie` header into its ordered name-value pairs. Values stay as they are on the wire. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This isn't quite true? The values get trimmed, at least, right?
Suggested change
|
||||||
| * | ||||||
| * A segment without an `=` is a nameless cookie, so the bare token is its value (RFC 6265bis). | ||||||
| */ | ||||||
| export function parseCookieHeader(value: string | string[], headerName: 'cookie' | 'set-cookie'): CookiePair[] { | ||||||
| // Set-Cookie: one cookie per value, followed by attributes ("name=value; HttpOnly; Secure") | ||||||
| // Cookie: multiple cookies separated by ";" (the space after ";" is not guaranteed on the wire) | ||||||
| const segments = (Array.isArray(value) ? value : [value]).flatMap(headerValue => { | ||||||
| if (typeof headerValue !== 'string') { | ||||||
| return []; | ||||||
| } | ||||||
| return headerName === 'set-cookie' ? [headerValue.split(';')[0]!] : headerValue.split(';'); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh! this is going to be a problem if you have multiple cookies in a single set-cookie header, because http headers can be joined by Where I'd expect that to be I think the fix here is to first split by |
||||||
| }); | ||||||
|
|
||||||
| let endIdx = str.indexOf(';', index); | ||||||
|
|
||||||
| if (endIdx === -1) { | ||||||
| endIdx = str.length; | ||||||
| } else if (endIdx < eqIdx) { | ||||||
| // backtrack on prior semicolon | ||||||
| index = str.lastIndexOf(';', eqIdx - 1) + 1; | ||||||
| continue; | ||||||
| } | ||||||
| return ( | ||||||
| segments | ||||||
| .map(segment => segment.trim()) | ||||||
| // ";;" and trailing ";" leave empty segments | ||||||
| .filter(segment => segment !== '') | ||||||
| .map(segment => { | ||||||
|
Comment on lines
+53
to
+56
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. l: should we use a good old for loop over the three loops here? This might be slightly more performant but given we're deailing with a list of cookies, it's not a lot of entries most likely. Feel free to keep as-is.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would keep it as a cookie header only has a handful of entries (so performance does not really matter) and it gives better readability.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
(nerd-sniped) Technically this approach is just a hair less performant, because we could do the map/filter/map in one pass over the items instead of 3. But even a huge cookie header is capped at a hard limit of 4KiB, so even if they're all single-value keys and values, that's an absolute hard max of less than 1024 items, which is several orders of magnitude less than what would matter, and so we should just optimize for readability.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we annotate the type here, it keeps it from slipping open to
Suggested change
|
||||||
| // Only first "=" separates name from value: "jwt=eyJhbGc=" has value "eyJhbGc=" | ||||||
| const equalSignIndex = segment.indexOf('='); | ||||||
| return equalSignIndex === -1 | ||||||
| ? // No "=": nameless cookie, the whole segment is the value | ||||||
| ['', segment] | ||||||
| : // Trim both parts, so that "theme = dark" is named "theme", not "theme " | ||||||
| [segment.slice(0, equalSignIndex).trim(), segment.slice(equalSignIndex + 1).trim()]; | ||||||
| }) | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| const key = str.slice(index, eqIdx).trim(); | ||||||
| /** | ||||||
| * Converts cookie pairs to a record with decoded values. The first cookie of a name wins. | ||||||
| * | ||||||
| * A nameless cookie's token is its value, and no name-based denylist can match it. So it is stored | ||||||
| * under the name `''` and its value is always filtered. | ||||||
| */ | ||||||
| export function cookiePairsToRecord(pairs: CookiePair[]): Record<string, string> { | ||||||
| const record: Record<string, string> = {}; | ||||||
|
|
||||||
| // only assign once | ||||||
| if (undefined === obj[key]) { | ||||||
| let val = str.slice(eqIdx + 1, endIdx).trim(); | ||||||
| for (const [name, value] of pairs) { | ||||||
| if (record[name] === undefined) { | ||||||
| record[name] = name === '' ? FILTERED_VALUE : decodeCookieValue(value); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // quoted values | ||||||
| if (val.charCodeAt(0) === 0x22) { | ||||||
| val = val.slice(1, -1); | ||||||
| } | ||||||
| return record; | ||||||
| } | ||||||
|
|
||||||
| try { | ||||||
| obj[key] = val.indexOf('%') !== -1 ? decodeURIComponent(val) : val; | ||||||
| } catch { | ||||||
| obj[key] = val; | ||||||
| } | ||||||
| } | ||||||
| function decodeCookieValue(value: string): string { | ||||||
| const unquoted = value.charCodeAt(0) === 0x22 ? value.slice(1, -1) : value; | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| index = endIdx + 1; | ||||||
| try { | ||||||
| return unquoted.indexOf('%') !== -1 ? decodeURIComponent(unquoted) : unquoted; | ||||||
| } catch { | ||||||
| return unquoted; | ||||||
| } | ||||||
|
|
||||||
| return obj; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,30 +1,35 @@ | ||||||
| import type { CollectBehavior } from '../../types/datacollection'; | ||||||
| import { parseCookie } from '../cookie'; | ||||||
| import { cookiePairsToRecord, parseCookieHeader } from '../cookie'; | ||||||
| import { FILTERED_VALUE as FILTERED, SENSITIVE_COOKIE_NAME_SNIPPETS } from './filtering-snippets'; | ||||||
| import { filterKeyValueData } from './filterKeyValueData'; | ||||||
|
|
||||||
| /** | ||||||
| * Filters a cookie string according to a `CollectBehavior`. | ||||||
| * Filters a `Cookie` / `Set-Cookie` header string according to a `CollectBehavior`. | ||||||
| * | ||||||
| * When individual cookies can be parsed, each key-value pair is filtered | ||||||
| * independently. When parsing fails, the entire string is replaced with `[Filtered]`. | ||||||
| * A nameless segment inside an otherwise parseable string (`"opaque-blob; theme=dark"`) is | ||||||
| * dropped, since a record key cannot carry a `[Filtered]` marker without leaking the token. | ||||||
| * Each named cookie is filtered independently. A nameless cookie (`"opaque-blob"`, `"=opaque-blob"`) | ||||||
| * is reported as `{ '': '[Filtered]' }`, since its token is the value. When the string holds no | ||||||
| * cookie at all, the entire string is replaced with `[Filtered]`. | ||||||
| * | ||||||
| * @param headerName - `'set-cookie'` keeps only the cookie pair and ignores the attributes (`Path`, `Max-Age`, ...) | ||||||
| */ | ||||||
| export function filterCookies(cookieString: string, behavior: CollectBehavior): Record<string, string> | string { | ||||||
| export function filterCookies( | ||||||
| cookieString: string, | ||||||
| behavior: CollectBehavior, | ||||||
| headerName: 'cookie' | 'set-cookie' = 'cookie', | ||||||
| ): Record<string, string> | string { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can drop the
Suggested change
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then we can also drop a bunch of ternaries in |
||||||
| if (behavior === false) { | ||||||
| return {}; | ||||||
| } | ||||||
|
|
||||||
| try { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is a dead try/catch now, right? Can |
||||||
| const parsed = parseCookie(cookieString); | ||||||
| const cookies = cookiePairsToRecord(parseCookieHeader(cookieString, headerName)); | ||||||
|
|
||||||
| // A non-empty string we cannot parse may still hold a session token, so it counts as sensitive. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is no longer true, because we throw out anything that isn't a valid |
||||||
| if (Object.keys(parsed).length === 0) { | ||||||
| if (Object.keys(cookies).length === 0) { | ||||||
| return cookieString ? FILTERED : {}; | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. related to previous comment, we can pare down the return type a bit.
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| return filterKeyValueData(parsed, behavior, SENSITIVE_COOKIE_NAME_SNIPPETS); | ||||||
| return filterKeyValueData(cookies, behavior, SENSITIVE_COOKIE_NAME_SNIPPETS); | ||||||
| } catch { | ||||||
| return FILTERED; | ||||||
| } | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
q: were these tests just wrong before? As in, multiple cookies being set in one set-cookie header?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that's the
Cookiesyntax. ForSet-Cookie, those other values are just other attributes like Max-Age or Path (which we don't anymore now - just key/value).But outcome of our offline discussion was that we might send the set-cookie attributes as well and see set-cookie as one joined string.