Skip to content

Commit ec8a26a

Browse files
⚗️ encode cookie options in cookie value (#3905)
1 parent ff3471d commit ec8a26a

File tree

10 files changed

+289
-47
lines changed

10 files changed

+289
-47
lines changed

packages/core/src/browser/cookie.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { display } from '../tools/display'
22
import { ONE_MINUTE, ONE_SECOND } from '../tools/utils/timeUtils'
3-
import { findCommaSeparatedValue, findCommaSeparatedValues, generateUUID } from '../tools/utils/stringUtils'
3+
import {
4+
findAllCommaSeparatedValues,
5+
findCommaSeparatedValue,
6+
findCommaSeparatedValues,
7+
generateUUID,
8+
} from '../tools/utils/stringUtils'
49
import { buildUrl } from '../tools/utils/urlPolyfill'
510

611
export interface CookieOptions {
@@ -21,15 +26,28 @@ export function setCookie(name: string, value: string, expireDelay: number = 0,
2126
document.cookie = `${name}=${value};${expires};path=/;samesite=${sameSite}${domain}${secure}${partitioned}`
2227
}
2328

29+
/**
30+
* Returns the value of the cookie with the given name
31+
* If there are multiple cookies with the same name, returns the first one
32+
*/
2433
export function getCookie(name: string) {
2534
return findCommaSeparatedValue(document.cookie, name)
2635
}
2736

37+
/**
38+
* Returns all the values of the cookies with the given name
39+
*/
40+
export function getCookies(name: string): string[] {
41+
return findAllCommaSeparatedValues(document.cookie).get(name) || []
42+
}
43+
2844
let initCookieParsed: Map<string, string> | undefined
2945

3046
/**
3147
* Returns a cached value of the cookie. Use this during SDK initialization (and whenever possible)
3248
* to avoid accessing document.cookie multiple times.
49+
*
50+
* ⚠️ If there are multiple cookies with the same name, returns the LAST one (unlike `getCookie()`)
3351
*/
3452
export function getInitCookie(name: string) {
3553
if (!initCookieParsed) {

packages/core/src/domain/session/sessionManager.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ import { getCurrentSite } from '../../browser/cookie'
1414
import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../../tools/experimentalFeatures'
1515
import { findLast } from '../../tools/utils/polyfills'
1616
import { monitorError } from '../../tools/monitor'
17-
import { SESSION_NOT_TRACKED, SESSION_TIME_OUT_DELAY } from './sessionConstants'
17+
import { SESSION_NOT_TRACKED, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants'
1818
import { startSessionStore } from './sessionStore'
1919
import type { SessionState } from './sessionState'
2020
import { toSessionState } from './sessionState'
2121
import { retrieveSessionCookie } from './storeStrategies/sessionInCookie'
2222
import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy'
23+
import { retrieveSessionFromLocalStorage } from './storeStrategies/sessionInLocalStorage'
2324

2425
export interface SessionManager<TrackingType extends string> {
2526
findSession: (
@@ -107,7 +108,7 @@ export function startSessionManager<TrackingType extends string>(
107108
const session = sessionStore.getSession()
108109

109110
if (!session) {
110-
reportUnexpectedSessionState().catch(() => void 0) // Ignore errors
111+
reportUnexpectedSessionState(configuration).catch(() => void 0) // Ignore errors
111112

112113
return {
113114
id: 'invalid',
@@ -172,16 +173,33 @@ function trackResume(configuration: Configuration, cb: () => void) {
172173
stopCallbacks.push(stop)
173174
}
174175

175-
async function reportUnexpectedSessionState() {
176-
const rawSession = retrieveSessionCookie()
176+
async function reportUnexpectedSessionState(configuration: Configuration) {
177+
const sessionStoreStrategyType = configuration.sessionStoreStrategyType
178+
if (!sessionStoreStrategyType) {
179+
return
180+
}
181+
182+
let rawSession
183+
let cookieContext
184+
185+
if (sessionStoreStrategyType.type === SessionPersistence.COOKIE) {
186+
rawSession = retrieveSessionCookie(sessionStoreStrategyType.cookieOptions)
187+
188+
cookieContext = {
189+
cookie: await getSessionCookies(),
190+
currentDomain: `${window.location.protocol}//${window.location.hostname}`,
191+
}
192+
} else {
193+
rawSession = retrieveSessionFromLocalStorage()
194+
}
177195
// monitor-until: forever, could be handy to troubleshoot issues until session manager rework
178196
addTelemetryDebug('Unexpected session state', {
197+
sessionStoreStrategyType: sessionStoreStrategyType.type,
179198
session: rawSession,
180199
isSyntheticsTest: isSyntheticsTest(),
181200
createdTimestamp: rawSession?.created,
182201
expireTimestamp: rawSession?.expire,
183-
cookie: await getSessionCookies(),
184-
currentDomain: `${window.location.protocol}//${window.location.hostname}`,
202+
...cookieContext,
185203
})
186204
}
187205

packages/core/src/domain/session/storeStrategies/sessionInCookie.spec.ts

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,42 @@
1-
import { mockClock, getSessionState } from '../../../../test'
1+
import { ExperimentalFeature } from '../../../tools/experimentalFeatures'
2+
import { mockClock, getSessionState, registerCleanupTask, mockExperimentalFeatures } from '../../../../test'
23
import { setCookie, deleteCookie, getCookie, getCurrentSite } from '../../../browser/cookie'
34
import type { SessionState } from '../sessionState'
4-
import type { Configuration } from '../../configuration'
5+
import { validateAndBuildConfiguration } from '../../configuration'
6+
import type { InitConfiguration } from '../../configuration'
57
import { SESSION_COOKIE_EXPIRATION_DELAY, SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from '../sessionConstants'
68
import { buildCookieOptions, selectCookieStrategy, initCookieStrategy } from './sessionInCookie'
7-
import type { SessionStoreStrategy } from './sessionStoreStrategy'
89
import { SESSION_STORE_KEY } from './sessionStoreStrategy'
910

10-
export const DEFAULT_INIT_CONFIGURATION = { trackAnonymousUser: true } as Configuration
11-
describe('session in cookie strategy', () => {
12-
const sessionState: SessionState = { id: '123', created: '0' }
13-
let cookieStorageStrategy: SessionStoreStrategy
11+
const DEFAULT_INIT_CONFIGURATION = { clientToken: 'abc', trackAnonymousUser: true }
1412

15-
beforeEach(() => {
16-
cookieStorageStrategy = initCookieStrategy(DEFAULT_INIT_CONFIGURATION, {})
17-
})
13+
function setupCookieStrategy(partialInitConfiguration: Partial<InitConfiguration> = {}) {
14+
const initConfiguration = {
15+
...DEFAULT_INIT_CONFIGURATION,
16+
...partialInitConfiguration,
17+
} as InitConfiguration
1818

19-
afterEach(() => {
20-
deleteCookie(SESSION_STORE_KEY)
21-
})
19+
const configuration = validateAndBuildConfiguration(initConfiguration)!
20+
const cookieOptions = buildCookieOptions(initConfiguration)!
21+
22+
registerCleanupTask(() => deleteCookie(SESSION_STORE_KEY, cookieOptions))
23+
24+
return initCookieStrategy(configuration, cookieOptions)
25+
}
26+
27+
describe('session in cookie strategy', () => {
28+
const sessionState: SessionState = { id: '123', created: '0' }
2229

2330
it('should persist a session in a cookie', () => {
31+
const cookieStorageStrategy = setupCookieStrategy()
2432
cookieStorageStrategy.persistSession(sessionState)
2533
const session = cookieStorageStrategy.retrieveSession()
2634
expect(session).toEqual({ ...sessionState })
2735
expect(getCookie(SESSION_STORE_KEY)).toBe('id=123&created=0')
2836
})
2937

3038
it('should set `isExpired=1` and `aid` to the cookie holding the session', () => {
39+
const cookieStorageStrategy = setupCookieStrategy()
3140
spyOn(Math, 'random').and.callFake(() => 0)
3241
cookieStorageStrategy.persistSession(sessionState)
3342
cookieStorageStrategy.expireSession(sessionState)
@@ -37,6 +46,7 @@ describe('session in cookie strategy', () => {
3746
})
3847

3948
it('should return an empty object if session string is invalid', () => {
49+
const cookieStorageStrategy = setupCookieStrategy()
4050
setCookie(SESSION_STORE_KEY, '{test:42}', 1000)
4151
const session = cookieStorageStrategy.retrieveSession()
4252
expect(session).toEqual({})
@@ -101,37 +111,69 @@ describe('session in cookie strategy', () => {
101111
})
102112
})
103113
})
114+
115+
describe('encode cookie options', () => {
116+
beforeEach(() => {
117+
mockExperimentalFeatures([ExperimentalFeature.ENCODE_COOKIE_OPTIONS])
118+
})
119+
120+
it('should encode cookie options in the cookie value', () => {
121+
// Some older browsers don't support partitioned cross-site session cookies
122+
// so instead of testing the cookie value, we test the call to the cookie setter
123+
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
124+
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })
125+
cookieStorageStrategy.persistSession({ id: '123' })
126+
127+
const calls = cookieSetSpy.calls.all()
128+
const lastCall = calls[calls.length - 1]
129+
expect(lastCall.args[0]).toMatch(/^_dd_s=id=123&c=1/)
130+
})
131+
132+
it('should not encode cookie options in the cookie value if the session is empty (deleting the cookie)', () => {
133+
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })
134+
cookieStorageStrategy.persistSession({})
135+
136+
expect(getCookie(SESSION_STORE_KEY)).toBeUndefined()
137+
})
138+
139+
it('should return the correct session state from the cookies', () => {
140+
spyOnProperty(document, 'cookie', 'get').and.returnValue('_dd_s=id=123&c=0;_dd_s=id=456&c=1;_dd_s=id=789&c=2')
141+
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })
142+
143+
expect(cookieStorageStrategy.retrieveSession()).toEqual({ id: '456' })
144+
})
145+
146+
it('should return the session state from the first cookie if there is no match', () => {
147+
spyOnProperty(document, 'cookie', 'get').and.returnValue('_dd_s=id=123&c=0;_dd_s=id=789&c=2')
148+
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })
149+
150+
expect(cookieStorageStrategy.retrieveSession()).toEqual({ id: '123' })
151+
})
152+
})
104153
})
105154

106155
describe('session in cookie strategy when opt-in anonymous user tracking', () => {
107156
const anonymousId = 'device-123'
108157
const sessionState: SessionState = { id: '123', created: '0' }
109-
let cookieStorageStrategy: SessionStoreStrategy
110-
beforeEach(() => {
111-
cookieStorageStrategy = initCookieStrategy(
112-
{ ...DEFAULT_INIT_CONFIGURATION, trackAnonymousUser: true } as Configuration,
113-
{}
114-
)
115-
})
116158

117-
afterEach(() => {
118-
deleteCookie(SESSION_STORE_KEY)
119-
})
120159
it('should persist with anonymous id', () => {
160+
const cookieStorageStrategy = setupCookieStrategy()
121161
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
122162
const session = cookieStorageStrategy.retrieveSession()
123163
expect(session).toEqual({ ...sessionState, anonymousId })
124164
expect(getCookie(SESSION_STORE_KEY)).toBe('id=123&created=0&aid=device-123')
125165
})
126166

127167
it('should expire with anonymous id', () => {
168+
const cookieStorageStrategy = setupCookieStrategy()
128169
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
129170
const session = cookieStorageStrategy.retrieveSession()
130171
expect(session).toEqual({ isExpired: '1', anonymousId })
131172
expect(getCookie(SESSION_STORE_KEY)).toBe('isExpired=1&aid=device-123')
132173
})
133174

134175
it('should persist for one year when opt-in', () => {
176+
const cookieStorageStrategy = setupCookieStrategy()
135177
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
136178
const clock = mockClock()
137179
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
@@ -141,6 +183,7 @@ describe('session in cookie strategy when opt-in anonymous user tracking', () =>
141183
})
142184

143185
it('should expire in one year when opt-in', () => {
186+
const cookieStorageStrategy = setupCookieStrategy()
144187
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
145188
const clock = mockClock()
146189
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
@@ -153,30 +196,24 @@ describe('session in cookie strategy when opt-in anonymous user tracking', () =>
153196
describe('session in cookie strategy when opt-out anonymous user tracking', () => {
154197
const anonymousId = 'device-123'
155198
const sessionState: SessionState = { id: '123', created: '0' }
156-
let cookieStorageStrategy: SessionStoreStrategy
157-
158-
beforeEach(() => {
159-
cookieStorageStrategy = initCookieStrategy({ trackAnonymousUser: false } as Configuration, {})
160-
})
161-
162-
afterEach(() => {
163-
deleteCookie(SESSION_STORE_KEY)
164-
})
165199

166200
it('should not extend cookie expiration time when opt-out', () => {
201+
const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false })
167202
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
168203
const clock = mockClock()
169204
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
170205
expect(cookieSetSpy.calls.argsFor(0)[0]).toContain(new Date(clock.timeStamp(SESSION_TIME_OUT_DELAY)).toUTCString())
171206
})
172207

173208
it('should not persist with one year when opt-out', () => {
209+
const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false })
174210
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
175211
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
176212
expect(cookieSetSpy.calls.argsFor(0)[0]).toContain(new Date(Date.now() + SESSION_EXPIRATION_DELAY).toUTCString())
177213
})
178214

179215
it('should not persist or expire a session with anonymous id when opt-out', () => {
216+
const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false })
180217
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
181218
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
182219
const session = cookieStorageStrategy.retrieveSession()

packages/core/src/domain/session/storeStrategies/sessionInCookie.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { isExperimentalFeatureEnabled, ExperimentalFeature } from '../../../tools/experimentalFeatures'
2+
import { isEmptyObject } from '../../../tools/utils/objectUtils'
13
import { isChromium } from '../../../tools/utils/browserDetection'
24
import type { CookieOptions } from '../../../browser/cookie'
3-
import { getCurrentSite, areCookiesAuthorized, getCookie, setCookie } from '../../../browser/cookie'
5+
import { getCurrentSite, areCookiesAuthorized, getCookies, setCookie, getCookie } from '../../../browser/cookie'
46
import type { InitConfiguration, Configuration } from '../../configuration'
57
import { tryOldCookiesMigration } from '../oldCookiesMigration'
68
import {
@@ -14,6 +16,8 @@ import { toSessionString, toSessionState, getExpiredSessionState } from '../sess
1416
import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy'
1517
import { SESSION_STORE_KEY } from './sessionStoreStrategy'
1618

19+
const SESSION_COOKIE_VERSION = 0
20+
1721
export function selectCookieStrategy(initConfiguration: InitConfiguration): SessionStoreStrategyType | undefined {
1822
const cookieOptions = buildCookieOptions(initConfiguration)
1923
return cookieOptions && areCookiesAuthorized(cookieOptions)
@@ -30,7 +34,7 @@ export function initCookieStrategy(configuration: Configuration, cookieOptions:
3034
isLockEnabled: isChromium(),
3135
persistSession: (sessionState: SessionState) =>
3236
storeSessionCookie(cookieOptions, configuration, sessionState, SESSION_EXPIRATION_DELAY),
33-
retrieveSession: retrieveSessionCookie,
37+
retrieveSession: () => retrieveSessionCookie(cookieOptions),
3438
expireSession: (sessionState: SessionState) =>
3539
storeSessionCookie(
3640
cookieOptions,
@@ -51,15 +55,34 @@ function storeSessionCookie(
5155
sessionState: SessionState,
5256
defaultTimeout: number
5357
) {
58+
let sessionStateString = toSessionString(sessionState)
59+
60+
if (isExperimentalFeatureEnabled(ExperimentalFeature.ENCODE_COOKIE_OPTIONS)) {
61+
sessionStateString = toSessionString({
62+
...sessionState,
63+
// deleting a cookie is writing a new cookie with an empty value
64+
// we don't want to store the cookie options in this case otherwise the cookie will not be deleted
65+
...(!isEmptyObject(sessionState) ? { c: encodeCookieOptions(options) } : {}),
66+
})
67+
}
68+
5469
setCookie(
5570
SESSION_STORE_KEY,
56-
toSessionString(sessionState),
71+
sessionStateString,
5772
configuration.trackAnonymousUser ? SESSION_COOKIE_EXPIRATION_DELAY : defaultTimeout,
5873
options
5974
)
6075
}
6176

62-
export function retrieveSessionCookie(): SessionState {
77+
/**
78+
* Retrieve the session state from the cookie that was set with the same cookie options
79+
* If there is no match, return the first cookie, because that's how `getCookie()` works
80+
*/
81+
export function retrieveSessionCookie(cookieOptions: CookieOptions): SessionState {
82+
if (isExperimentalFeatureEnabled(ExperimentalFeature.ENCODE_COOKIE_OPTIONS)) {
83+
return retrieveSessionCookieFromEncodedCookie(cookieOptions)
84+
}
85+
6386
const sessionString = getCookie(SESSION_STORE_KEY)
6487
const sessionState = toSessionState(sessionString)
6588
return sessionState
@@ -83,3 +106,42 @@ export function buildCookieOptions(initConfiguration: InitConfiguration): Cookie
83106

84107
return cookieOptions
85108
}
109+
110+
function encodeCookieOptions(cookieOptions: CookieOptions): string {
111+
const domainCount = cookieOptions.domain ? cookieOptions.domain.split('.').length - 1 : 0
112+
113+
/* eslint-disable no-bitwise */
114+
let byte = 0
115+
byte |= SESSION_COOKIE_VERSION << 5 // Store version in upper 3 bits
116+
byte |= domainCount << 1 // Store domain count in next 4 bits
117+
byte |= cookieOptions.crossSite ? 1 : 0 // Store useCrossSiteScripting in next bit
118+
/* eslint-enable no-bitwise */
119+
120+
return byte.toString(16) // Convert to hex string
121+
}
122+
123+
/**
124+
* Retrieve the session state from the cookie that was set with the same cookie options.
125+
* If there is no match, fallback to the first cookie, (because that's how `getCookie()` works)
126+
* and this allows to keep the current session id when we release this feature.
127+
*/
128+
function retrieveSessionCookieFromEncodedCookie(cookieOptions: CookieOptions): SessionState {
129+
const cookies = getCookies(SESSION_STORE_KEY)
130+
const opts = encodeCookieOptions(cookieOptions)
131+
132+
let sessionState: SessionState | undefined
133+
134+
// reverse the cookies so that if there is no match, the cookie returned is the first one
135+
for (const cookie of cookies.reverse()) {
136+
sessionState = toSessionState(cookie)
137+
138+
if (sessionState.c === opts) {
139+
break
140+
}
141+
}
142+
143+
// remove the cookie options from the session state as this is not part of the session state
144+
delete sessionState?.c
145+
146+
return sessionState ?? {}
147+
}

0 commit comments

Comments
 (0)