Skip to content

ref(core): Consolidate cookie parsing into one parser - #24536

Open
s1gr1d wants to merge 3 commits into
developfrom
sig/consolidate-cookie-parsing
Open

s1gr1d wants to merge 3 commits into
developfrom
sig/consolidate-cookie-parsing

Conversation

@s1gr1d

@s1gr1d s1gr1d commented Sep 21, 2026

Copy link
Copy Markdown
Member

parseCookie (used for event cookie records) and parseCookieHeader (used for span attributes) had a different implementation for nameless segments, Set-Cookie attributes, and decoding.

parseCookieHeader is the new parser for both and returns ordered [name, value] pairs, with a set-cookie mode that ignores cookie attributes (like Max-Age).

Changes for event attributes

Case Input Before After
Set-Cookie attributes (e.g. Max-Age) filterCookies('sid=1; Max-Age=3600; Path=/', true, 'set-cookie') { sid: '[Filtered]', 'Max-Age': '3600', Path: '/' } { sid: '[Filtered]' }
Nameless cookie, =token form filterCookies('=s3cr3t; theme=dark') { '': 's3cr3t', theme: 'dark' }, so the token leaks { '': '[Filtered]', theme: 'dark' }
Nameless cookie, bare token filterCookies('s3cr3t; theme=dark') { theme: 'dark' }, the token is dropped { '': '[Filtered]', theme: 'dark' }

What stays the same

Case Input Event record Span attribute
Encoded value email=jane%40example.com { email: 'jane@example.com' } (decoded) ['email=jane%40example.com'] (raw, as sent)
Repeated name lang=en; lang=de { lang: 'en' } (first wins) ['lang=en', 'lang=de']
No cookie at all ;;; '[Filtered]' ['[Filtered]']

Fixes #24501

Added a changelog contribution entry because of this PR: #24525

@s1gr1d
s1gr1d requested a review from a team as a code owner September 21, 2026 09:18
@s1gr1d
s1gr1d requested review from Lms24 and logaretm and removed request for a team September 21, 2026 09:18
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

⚠️ Warning: Base artifact is not the latest one, because the latest workflow run is not done yet. This may lead to incorrect results. Try to re-run all tests to get up to date results.

Path Size % Change Change
@sentry/browser 29.09 kB - -
@sentry/browser - with treeshaking flags 27.35 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 27.26 kB - -
@sentry/browser (incl. Tracing) 50.56 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 50.58 kB - -
@sentry/browser (incl. Tracing, Profiling) 53.56 kB - -
@sentry/browser (incl. Tracing, Replay) 90.11 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 79.21 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 94.81 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 107.78 kB - -
@sentry/browser (incl. Feedback) 46.62 kB - -
@sentry/browser (incl. sendFeedback) 34.15 kB - -
@sentry/browser (incl. FeedbackAsync) 39.26 kB - -
@sentry/browser (incl. Metrics) 30.1 kB - -
@sentry/browser (incl. Logs) 30.35 kB - -
@sentry/browser (incl. Metrics & Logs) 31.02 kB - -
@sentry/react 30.84 kB - -
@sentry/react (incl. Tracing) 52.88 kB - -
@sentry/vue 36.34 kB - -
@sentry/vue (incl. Tracing) 52.86 kB - -
@sentry/svelte 29.11 kB - -
CDN Bundle 30.8 kB - -
CDN Bundle (incl. Tracing) 51.1 kB - -
CDN Bundle (incl. Logs, Metrics) 33.06 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 53.09 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 73.75 kB - -
CDN Bundle (incl. Tracing, Replay) 88.66 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 90.58 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 94.7 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 96.71 kB - -
CDN Bundle - uncompressed 91.16 kB - -
CDN Bundle (incl. Tracing) - uncompressed 152.53 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 97.73 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 158.49 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 227.14 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 272.1 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 278.04 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 285.81 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 291.74 kB - -
@sentry/nextjs (client) 55.21 kB - -
@sentry/sveltekit (client) 50.99 kB - -
@sentry/core/server 39.99 kB -0.03% -10 B 🔽
@sentry/core/browser 13.66 kB - -
@sentry/node 133.26 kB -0.04% -52 B 🔽
@sentry/node/import (ESM hook with diagnostics-channel injection) 82.17 kB - -
@sentry/node - without tracing 89.86 kB -0.06% -46 B 🔽
@sentry/node - without channel injection 112.08 kB -0.06% -57 B 🔽
@sentry/aws-serverless 98.13 kB -0.05% -44 B 🔽
@sentry/cloudflare (withSentry) - minified 204.63 kB -0.06% -110 B 🔽
@sentry/cloudflare (withSentry) 509.38 kB -0.01% -27 B 🔽

View base workflow run

@s1gr1d

s1gr1d commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 6db8941. Configure here.

Comment on lines -262 to +268
setCookie: 'session=abc123; theme=dark',
setCookie: 'session=abc123; Path=/',

Copy link
Copy Markdown
Member

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's the Cookie syntax. For Set-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.

Comment on lines +53 to +56
.map(segment => segment.trim())
// ";;" and trailing ";" leave empty segments
.filter(segment => segment !== '')
.map(segment => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be slightly more performant

(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.

@isaacs isaacs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments that could be good to fix while we're in there, but overall, this is great, and pretty much exactly what stood out as the main improvement suggested by #24090.

Splitting "parse the header into ordered pairs" from "collapse pairs into a record" is the right move. The span path wants order and duplicates, the event path wants a first-wins record with decoded values, and neither had a reason to own a parser.

Tests are much improved as well, love too see it 🔥

const cookies = normalizedRequest.cookies || (headers?.cookie ? parseCookie(headers.cookie) : undefined);
const cookies =
normalizedRequest.cookies ||
(headers?.cookie ? cookiePairsToRecord(parseCookieHeader(headers.cookie, 'cookie')) : undefined);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 ; in a cookie value splits into a second, differently named cookie, and that second name escapes the denylist.

Reproduced end to end through processSegmentSpan:

headers: { cookie: 'session=%3Btheme%3Ds3cr3t' }

'http.request.header.cookie': ['session=[Filtered]', 'theme=s3cr3t']

Two more lines keep this alive:

  • packages/core/src/utils/cookie.ts line 79: decodes the value.
  • packages/core/src/tracing/spans/captureSpan.ts line 107: safeSetSpanJSONAttributes skips keys that already exist. The later pass over requestData.headers at packages/core/src/integrations/requestdata.ts line 197 would parse the raw header correctly, but it is a no-op because the cookie pass at line 193 already set http.request.header.cookie.

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 normalizedRequest.cookies was supplied by the framework; when the data came from headers.cookie, let the header pass at line 197 handle it against the raw value.

Or, maybe better: have extractNormalizedRequestData hand back the CookiePair[] so nothing has to round-trip through a string at all.

cookieString: string,
behavior: CollectBehavior,
headerName: 'cookie' | 'set-cookie' = 'cookie',
): Record<string, string> | string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can drop the | string from this type, I think, if we make line 29 return {}.

Suggested change
): Record<string, string> | string {
): Record<string, string> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then we can also drop a bunch of ternaries in httpclient.ts, because it'll always be a Record<string,string>.

// A non-empty string we cannot parse may still hold a session token, so it counts as sensitive.
if (Object.keys(parsed).length === 0) {
if (Object.keys(cookies).length === 0) {
return cookieString ? FILTERED : {};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 cookieString ? FILTERED : {};
return {};

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 key=value pair.

if (typeof headerValue !== 'string') {
return [];
}
return headerName === 'set-cookie' ? [headerValue.split(';')[0]!] : headerValue.split(';');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 ,.

filterCookies('sid=1; Path=/, theme=dark; Path=/', true, 'set-cookie')
=> { sid: '[Filtered]' }

Where I'd expect that to be { sid: '[Filtered]', theme: 'dark' }

I think the fix here is to first split by ,, and then collect all the set-cookie-style parsed sections, to throw away everything after the first ;.

if (eqIdx === -1) {
break;
/**
* Splits a `Cookie` / `Set-Cookie` header into its ordered name-value pairs. Values stay as they are on the wire.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
* Splits a `Cookie` / `Set-Cookie` header into its ordered name-value pairs. Values stay as they are on the wire.
* Splits a `Cookie` / `Set-Cookie` header into its ordered name-value pairs. Values are not decoded or unquoted, but may be truncated.

return {};
}

try {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a dead try/catch now, right? Can cookiePairsToRecord(parseCookieHeader(cookieString, headerName)) throw?

.map(segment => segment.trim())
// ";;" and trailing ";" leave empty segments
.filter(segment => segment !== '')
.map(segment => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 string[][].

Suggested change
.map(segment => {
.map((segment): CookiePair => {

}
}
function decodeCookieValue(value: string): string {
const unquoted = value.charCodeAt(0) === 0x22 ? value.slice(1, -1) : value;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decodeCookieValue strips the last character whenever the first is ", without checking that the last one is also ". Verified: cookiePairsToRecord([['a', '"bar']]) gives { a: 'ba' }. Also it returns '' if the value is '"', which... idk if that's wrong, but it's weird?

Suggested change
const unquoted = value.charCodeAt(0) === 0x22 ? value.slice(1, -1) : value;
const unquoted = value.length > 1 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consolidate the two cookie parsers parseCookie and parseCookieHeader

3 participants