Skip to content
Open
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott

Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehaprasad-dev, @JealousGx, @Jxxunnn, @eddie333016, @davidmurdoch, @yashschandra, @atharv-sys32, @AG0708, @birkskyum, @mkly, @mcbbugu, @suhailopensource, @zkasuran, @mohd-akram, @RealBhupesh, @halillusion, @psang39, @hafzism, @JosephDoUrden, @Tyagiquamar, @Andarist, @msnelling, and @oesnuj. Thank you for your contributions!
Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehaprasad-dev, @JealousGx, @Jxxunnn, @eddie333016, @davidmurdoch, @yashschandra, @atharv-sys32, @AG0708, @birkskyum, @mkly, @mcbbugu, @suhailopensource, @zkasuran, @mohd-akram, @RealBhupesh, @halillusion, @psang39, @hafzism, @JosephDoUrden, @Tyagiquamar, @Andarist, @msnelling, @oesnuj, and @Dextheking1. Thank you for your contributions!

- feat(browser)!: `browser.navigation.type` on web vital and bfcache navigation spans now carries the navigation type exactly as web-vitals reports it. `bfcache` is now `back-forward-cache`, and a back/forward navigation that missed the bfcache (`back-forward`) or a discarded-tab restore (`restore`) is no longer folded into `navigate`. Update any dashboards or alerts filtering on `bfcache`.
- feat(core): Add `createFetchIntegration`, the shared implementation behind the global-`fetch` integrations in `@sentry/bun`, `@sentry/cloudflare`, `@sentry/deno` and `@sentry/vercel-edge`. Those four packages carried four copies of it; they now share one. Two changes come out of that:
Expand Down
4 changes: 2 additions & 2 deletions packages/browser/src/integrations/httpclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function _fetchResponseHandler(
}
const resCookieStr = response.headers.get('Set-Cookie') || undefined;
if (resCookieStr) {
const filtered = _INTERNAL_filterCookies(resCookieStr, dc.cookies);
const filtered = _INTERNAL_filterCookies(resCookieStr, dc.cookies, 'set-cookie');
responseCookies = typeof filtered === 'string' ? { 'set-cookie': filtered } : filtered;
}
}
Expand Down Expand Up @@ -141,7 +141,7 @@ function _xhrResponseHandler(
try {
const cookieString = xhr.getResponseHeader('Set-Cookie') || xhr.getResponseHeader('set-cookie') || undefined;
if (cookieString) {
const filtered = _INTERNAL_filterCookies(cookieString, dc.cookies);
const filtered = _INTERNAL_filterCookies(cookieString, dc.cookies, 'set-cookie');
responseCookies = typeof filtered === 'string' ? { 'set-cookie': filtered } : filtered;
}
} catch {
Expand Down
24 changes: 15 additions & 9 deletions packages/browser/test/integrations/httpclient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ describe('httpClientIntegration', () => {

triggerFetch(fetchHandler, {
requestHeaders: { Authorization: 'Bearer x', Accept: 'application/json', Cookie: 'theme=dark; session=secret' },
responseHeaders: { 'Content-Type': 'text/html', 'Set-Cookie': 'locale=en; session=secret' },
responseHeaders: { 'Content-Type': 'text/html', 'Set-Cookie': 'session=secret; Path=/; HttpOnly' },
});

expect(captureEventSpy).toHaveBeenCalledTimes(1);
Expand All @@ -158,7 +158,7 @@ describe('httpClientIntegration', () => {
});
expect(event.request?.cookies).toEqual({ theme: 'dark', session: '[Filtered]' });
expect(event.contexts?.response?.headers).toEqual({ 'content-type': 'text/html', 'set-cookie': '[Filtered]' });
expect(event.contexts?.response?.cookies).toEqual({ locale: 'en', session: '[Filtered]' });
expect(event.contexts?.response?.cookies).toEqual({ session: '[Filtered]' });
});

it('filters PII headers when an explicit deny list is configured', () => {
Expand Down Expand Up @@ -244,30 +244,36 @@ describe('httpClientIntegration', () => {
const { xhrHandler, captureEventSpy } = setup();

triggerXhr(xhrHandler, {
setCookie: 'session=abc123; theme=dark; connect.sid=secret',
setCookie: 'connect.sid=s3cr3t; Path=/; HttpOnly',
});

expect(getEvent(captureEventSpy).contexts?.response?.cookies).toEqual({
session: '[Filtered]',
theme: 'dark',
'connect.sid': '[Filtered]',
expect(getEvent(captureEventSpy).contexts?.response?.cookies).toEqual({ 'connect.sid': '[Filtered]' });
});

it('does not report Set-Cookie attributes as response cookies', () => {
const { xhrHandler, captureEventSpy } = setup();

triggerXhr(xhrHandler, {
setCookie: 'theme=dark; Max-Age=3600; Path=/; Domain=example.com',
});

expect(getEvent(captureEventSpy).contexts?.response?.cookies).toEqual({ theme: 'dark' });
});

it('collects response headers and filters response cookies by default', () => {
const { xhrHandler, captureEventSpy } = setup();

triggerXhr(xhrHandler, {
requestHeaders: { Authorization: 'Bearer x' },
setCookie: 'session=abc123; theme=dark',
setCookie: 'session=abc123; Path=/',
Comment on lines -262 to +268

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.

allResponseHeaders: 'content-type: text/html',
});

expect(captureEventSpy).toHaveBeenCalledTimes(1);
const event = getEvent(captureEventSpy);
expect(event.request?.headers).toEqual({ Authorization: '[Filtered]' });
expect(event.contexts?.response?.headers).toEqual({ 'content-type': 'text/html' });
expect(event.contexts?.response?.cookies).toEqual({ session: '[Filtered]', theme: 'dark' });
expect(event.contexts?.response?.cookies).toEqual({ session: '[Filtered]' });
});
});
});
6 changes: 4 additions & 2 deletions packages/core/src/integrations/requestdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

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.

requestData.cookies = cookies || {};
}

Expand Down
92 changes: 54 additions & 38 deletions packages/core/src/utils/cookie.ts
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:
*
Expand Down Expand Up @@ -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.

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.

*
* 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(';');

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

});

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

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.

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 => {

// 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;

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;


index = endIdx + 1;
try {
return unquoted.indexOf('%') !== -1 ? decodeURIComponent(unquoted) : unquoted;
} catch {
return unquoted;
}

return obj;
}
25 changes: 15 additions & 10 deletions packages/core/src/utils/data-collection/filterCookies.ts
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 {

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

if (behavior === false) {
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?

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 (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 {};

}

return filterKeyValueData(parsed, behavior, SENSITIVE_COOKIE_NAME_SNIPPETS);
return filterKeyValueData(cookies, behavior, SENSITIVE_COOKIE_NAME_SNIPPETS);
} catch {
return FILTERED;
}
Expand Down
30 changes: 2 additions & 28 deletions packages/core/src/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ResolvedDataCollection } from '../types/datacollection';
import type { PolymorphicRequest } from '../types/polymorphics';
import type { RequestEventData } from '../types/request';
import type { WebFetchHeaders, WebFetchRequest } from '../types/webfetchapi';
import { parseCookieHeader } from './cookie';
import { debug } from './debug-logger';
import { FILTERED_VALUE, SENSITIVE_COOKIE_NAME_SNIPPETS } from './data-collection/filtering-snippets';
import { shouldFilterDataKey } from './data-collection/filterKeyValueData';
Expand Down Expand Up @@ -303,7 +304,7 @@ export function httpHeadersToSpanAttributes(
continue;
}

const cookies = parseCookieHeader(value, lowerKey === 'set-cookie');
const cookies = parseCookieHeader(value, lowerKey);
spanAttributes[`${prefix}${lowerKey}`] = cookies.length
? cookies.map(([cookieKey, cookieValue]) => {
// A nameless cookie's bare token is its value; no denylist could match it, so it is
Expand Down Expand Up @@ -343,33 +344,6 @@ export function httpHeadersToSpanAttributes(
return spanAttributes;
}

/**
* Splits a `Cookie` / `Set-Cookie` header into its name-value pairs.
*
* A segment without an `=` is a nameless cookie, so the bare token is its value (RFC 6265bis):
* it is returned as a pair with an empty name.
*/
function parseCookieHeader(value: string | string[], isSetCookie: boolean): [string, string][] {
// Set-Cookie: one cookie per value, with attributes ("name=value; HttpOnly; Secure")
// Cookie: multiple cookies separated by ";" (the space after ";" is not guaranteed on the wire)
const cookies = (Array.isArray(value) ? value : [value]).flatMap(headerValue => {
if (typeof headerValue !== 'string' || headerValue === '') {
return [];
}
return isSetCookie ? [headerValue.split(';')[0]!] : headerValue.split(';');
});

return cookies
.map(cookie => cookie.trim())
.filter(cookie => cookie !== '')
.map(cookie => {
const equalSignIndex = cookie.indexOf('=');
return equalSignIndex !== -1
? [cookie.substring(0, equalSignIndex), cookie.substring(equalSignIndex + 1)]
: ['', cookie];
});
}

/** Extract the query params from an URL. */
export function extractQueryParamsFromUrl(url: string): string | undefined {
// url is path and query string
Expand Down
19 changes: 18 additions & 1 deletion packages/core/test/lib/integrations/requestdata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function baseEvent(overrides: Partial<Event> = {}): Event {
};
}

/** Rich normalized request (Cookie header only — tests `parseCookie` path). */
/** Rich normalized request (Cookie header only — tests the cookie header parsing path). */
function richNormalizedRequest() {
return {
method: 'POST',
Expand Down Expand Up @@ -313,6 +313,23 @@ describe('requestDataIntegration', () => {
expect(event.request?.cookies).toEqual({ id: '42' });
});

it('filters a nameless token in the cookie header', () => {
const integration = requestDataIntegration();
const event: Event = {
sdkProcessingMetadata: {
normalizedRequest: {
method: 'GET',
url: 'https://example.com/',
headers: { cookie: '=y7Uu0Rk2QpLmXv3; theme=dark' },
},
},
};

integration.processEvent?.(event, {}, mockClient({ cookies: true }));

expect(event.request?.cookies).toEqual({ '': '[Filtered]', theme: 'dark' });
});

it('omits headers when include.headers is false and dataCollection enables headers', () => {
const integration = requestDataIntegration({ include: { headers: false } });
const event: Event = {
Expand Down
Loading
Loading