Skip to content

Commit 2fef726

Browse files
🐛 [RUM-11596] fix cookie domain within pages with empty location (#3866)
* 🐛 [RUM-11596] fix cookie domain within pages with empty location * ✅♻️ [e2e] rename Server.url to Server.origin * ✅🔥 [e2e] remove crossOriginUrl As it's not too much used, let's just use the server origin directly. * ✅♻️ [e2e] `baseUrl` includes the path * ✅ [RUM-11596] add e2e tests
1 parent 90fe178 commit 2fef726

File tree

15 files changed

+355
-62
lines changed

15 files changed

+355
-62
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { mockCookies } from '../../test'
2+
import { getCurrentSite, resetGetCurrentSite } from './cookie'
3+
4+
describe('cookie', () => {
5+
describe('getCurrentSite', () => {
6+
beforeEach(() => {
7+
resetGetCurrentSite()
8+
})
9+
10+
it('returns the eTLD+1 for example.com', () => {
11+
mockCookies()
12+
expect(getCurrentSite('example.com')).toBe('example.com')
13+
})
14+
15+
it('returns the eTLD+1 for example.co.uk', () => {
16+
mockCookies({
17+
filter: (cookie) => cookie.domain !== '.co.uk',
18+
})
19+
expect(getCurrentSite('example.co.uk')).toBe('example.co.uk')
20+
})
21+
22+
it('returns the eTLD+1 for foo.bar.baz.example.com', () => {
23+
mockCookies()
24+
expect(getCurrentSite('foo.bar.baz.example.com')).toBe('example.com')
25+
})
26+
27+
it('does not left any cookies', () => {
28+
const { getCookies } = mockCookies()
29+
expect(getCurrentSite('example.com')).toBe('example.com')
30+
expect(getCookies()).toEqual([])
31+
})
32+
33+
it('falls back to the referrer when the hostname is empty', () => {
34+
mockCookies()
35+
expect(getCurrentSite('', 'https://example.com')).toBe('example.com')
36+
})
37+
38+
it('returns undefined when the referrer is empty', () => {
39+
mockCookies()
40+
expect(getCurrentSite('', '')).toBeUndefined()
41+
})
42+
43+
it('caches the result', () => {
44+
const { setter } = mockCookies()
45+
46+
expect(getCurrentSite('example.com')).toBe('example.com')
47+
expect(setter).toHaveBeenCalledTimes(2)
48+
49+
expect(getCurrentSite('example.com')).toBe('example.com')
50+
expect(setter).toHaveBeenCalledTimes(2)
51+
})
52+
})
53+
})

packages/core/src/browser/cookie.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { display } from '../tools/display'
22
import { ONE_MINUTE, ONE_SECOND } from '../tools/utils/timeUtils'
33
import { findCommaSeparatedValue, findCommaSeparatedValues, generateUUID } from '../tools/utils/stringUtils'
4+
import { buildUrl } from '../tools/utils/urlPolyfill'
45

56
export interface CookieOptions {
67
secure?: boolean
@@ -70,21 +71,37 @@ export function areCookiesAuthorized(options: CookieOptions): boolean {
7071
* https://web.dev/same-site-same-origin/#site
7172
*/
7273
let getCurrentSiteCache: string | undefined
73-
export function getCurrentSite() {
74+
export function getCurrentSite(hostname = location.hostname, referrer = document.referrer): string | undefined {
7475
if (getCurrentSiteCache === undefined) {
75-
// Use a unique cookie name to avoid issues when the SDK is initialized multiple times during
76-
// the test cookie lifetime
77-
const testCookieName = `dd_site_test_${generateUUID()}`
78-
const testCookieValue = 'test'
76+
const defaultHostName = getCookieDefaultHostName(hostname, referrer)
77+
if (defaultHostName) {
78+
// Use a unique cookie name to avoid issues when the SDK is initialized multiple times during
79+
// the test cookie lifetime
80+
const testCookieName = `dd_site_test_${generateUUID()}`
81+
const testCookieValue = 'test'
7982

80-
const domainLevels = window.location.hostname.split('.')
81-
let candidateDomain = domainLevels.pop()!
82-
while (domainLevels.length && !getCookie(testCookieName)) {
83-
candidateDomain = `${domainLevels.pop()!}.${candidateDomain}`
84-
setCookie(testCookieName, testCookieValue, ONE_SECOND, { domain: candidateDomain })
83+
const domainLevels = defaultHostName.split('.')
84+
let candidateDomain = domainLevels.pop()!
85+
while (domainLevels.length && !getCookie(testCookieName)) {
86+
candidateDomain = `${domainLevels.pop()!}.${candidateDomain}`
87+
setCookie(testCookieName, testCookieValue, ONE_SECOND, { domain: candidateDomain })
88+
}
89+
deleteCookie(testCookieName, { domain: candidateDomain })
90+
getCurrentSiteCache = candidateDomain
8591
}
86-
deleteCookie(testCookieName, { domain: candidateDomain })
87-
getCurrentSiteCache = candidateDomain
8892
}
93+
8994
return getCurrentSiteCache
9095
}
96+
97+
function getCookieDefaultHostName(hostname: string, referrer: string) {
98+
try {
99+
return hostname || buildUrl(referrer).hostname
100+
} catch {
101+
// Ignore
102+
}
103+
}
104+
105+
export function resetGetCurrentSite() {
106+
getCurrentSiteCache = undefined
107+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ async function getSessionCookies(): Promise<{ count: number; domain: string }> {
238238

239239
return {
240240
count: sessionCookies.length,
241-
domain: getCurrentSite(),
241+
domain: getCurrentSite() || 'undefined',
242242
...sessionCookies,
243243
}
244244
}

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import { SESSION_STORE_KEY } from './sessionStoreStrategy'
1616

1717
export function selectCookieStrategy(initConfiguration: InitConfiguration): SessionStoreStrategyType | undefined {
1818
const cookieOptions = buildCookieOptions(initConfiguration)
19-
return areCookiesAuthorized(cookieOptions) ? { type: SessionPersistence.COOKIE, cookieOptions } : undefined
19+
return cookieOptions && areCookiesAuthorized(cookieOptions)
20+
? { type: SessionPersistence.COOKIE, cookieOptions }
21+
: undefined
2022
}
2123

2224
export function initCookieStrategy(configuration: Configuration, cookieOptions: CookieOptions): SessionStoreStrategy {
@@ -63,7 +65,7 @@ export function retrieveSessionCookie(): SessionState {
6365
return sessionState
6466
}
6567

66-
export function buildCookieOptions(initConfiguration: InitConfiguration) {
68+
export function buildCookieOptions(initConfiguration: InitConfiguration): CookieOptions | undefined {
6769
const cookieOptions: CookieOptions = {}
6870

6971
cookieOptions.secure =
@@ -72,7 +74,11 @@ export function buildCookieOptions(initConfiguration: InitConfiguration) {
7274
cookieOptions.partitioned = !!initConfiguration.usePartitionedCrossSiteSessionCookie
7375

7476
if (initConfiguration.trackSessionAcrossSubdomains) {
75-
cookieOptions.domain = getCurrentSite()
77+
const currentSite = getCurrentSite()
78+
if (!currentSite) {
79+
return
80+
}
81+
cookieOptions.domain = currentSite
7682
}
7783

7884
return cookieOptions

packages/core/test/cookie.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,99 @@ export function expireCookie() {
1010
export function getSessionState(sessionStoreKey: string) {
1111
return toSessionState(getCookie(sessionStoreKey))
1212
}
13+
14+
interface Cookie {
15+
name: string
16+
value: string
17+
domain?: string
18+
path?: string
19+
expires?: number
20+
secure?: boolean
21+
sameSite?: 'strict' | 'lax' | 'none'
22+
partitioned?: boolean
23+
}
24+
25+
export function mockCookies({ filter }: { filter?: (cookie: Cookie) => boolean } = {}) {
26+
let cookies: Cookie[] = []
27+
28+
const documentPrototype =
29+
'cookie' in Document.prototype
30+
? Document.prototype
31+
: // Firefox 67 doesn't define `cookie` on `Document.prototype`
32+
HTMLDocument.prototype
33+
34+
const getter = spyOnProperty(documentPrototype, 'cookie', 'get').and.callFake(() => {
35+
removeExpiredCookies()
36+
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join(';')
37+
})
38+
39+
const setter = spyOnProperty(documentPrototype, 'cookie', 'set').and.callFake((cookieString) => {
40+
const cookie = parseSingleCookieString(cookieString)
41+
42+
if (filter && !filter(cookie)) {
43+
return
44+
}
45+
46+
const matchingCookieIndex = cookies.findIndex(
47+
(otherCookie) =>
48+
cookie.name === otherCookie.name && cookie.domain === otherCookie.domain && cookie.path === otherCookie.path
49+
)
50+
if (matchingCookieIndex !== -1) {
51+
cookies[matchingCookieIndex] = cookie
52+
} else {
53+
cookies.push(cookie)
54+
}
55+
removeExpiredCookies()
56+
})
57+
58+
function removeExpiredCookies() {
59+
cookies = cookies.filter((cookie) => cookie.expires && cookie.expires > Date.now())
60+
}
61+
62+
return {
63+
getCookies: () => {
64+
removeExpiredCookies()
65+
return cookies
66+
},
67+
getter,
68+
setter,
69+
}
70+
}
71+
72+
function parseSingleCookieString(cookieString: string) {
73+
const parts = cookieString.split(';')
74+
const [name, value] = parts.shift()!.split('=')
75+
const parsedCookie: Cookie = {
76+
name,
77+
value,
78+
}
79+
80+
for (const part of parts) {
81+
const parsedPart = part.match(/^\s*([^=]*)(?:=(.*))?/)
82+
if (!parsedPart) {
83+
continue
84+
}
85+
86+
const name = parsedPart[1].toLowerCase()
87+
const value: string | undefined = parsedPart[2]
88+
89+
if (value) {
90+
if (name === 'domain') {
91+
parsedCookie.domain = value.startsWith('.') ? value : `.${value}`
92+
} else if (name === 'path') {
93+
parsedCookie.path = value
94+
} else if (name === 'expires') {
95+
parsedCookie.expires = new Date(value).getTime()
96+
} else if (name === 'max-age') {
97+
parsedCookie.expires = Date.now() + Number(value) * 1000
98+
} else if (name === 'samesite') {
99+
parsedCookie.sameSite = value as Cookie['sameSite']
100+
}
101+
} else if (name === 'partitioned') {
102+
parsedCookie.partitioned = true
103+
} else if (name === 'secure') {
104+
parsedCookie.secure = true
105+
}
106+
}
107+
return parsedCookie
108+
}

test/e2e/lib/framework/createTest.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { html, DEFAULT_SETUPS, npmSetup, reactSetup } from './pageSetups'
1818
import { createIntakeServerApp } from './serverApps/intake'
1919
import { createMockServerApp } from './serverApps/mock'
2020
import type { Extension } from './createExtension'
21+
import { isBrowserStack } from './environment'
2122

2223
interface LogsWorkerOptions {
2324
importScript?: boolean
@@ -30,7 +31,6 @@ export function createTest(title: string) {
3031

3132
interface TestContext {
3233
baseUrl: string
33-
crossOriginUrl: string
3434
intakeRegistry: IntakeRegistry
3535
servers: Servers
3636
page: Page
@@ -63,6 +63,7 @@ class TestBuilder {
6363
logsConfiguration?: LogsInitConfiguration
6464
} = {}
6565
private useServiceWorker: boolean = false
66+
private hostName?: string
6667

6768
constructor(private title: string) {}
6869

@@ -153,6 +154,8 @@ class TestBuilder {
153154

154155
const options = isModule ? '{ type: "module" }' : '{}'
155156

157+
// Service workers require HTTPS or localhost due to browser security restrictions
158+
this.hostName = 'localhost'
156159
this.withBody(html`
157160
<script>
158161
if (!window.myServiceWorker && 'serviceWorker' in navigator) {
@@ -167,6 +170,11 @@ class TestBuilder {
167170
return this
168171
}
169172

173+
withHostName(hostName: string) {
174+
this.hostName = hostName
175+
return this
176+
}
177+
170178
run(runner: TestRunner) {
171179
const setupOptions: SetupOptions = {
172180
body: this.body,
@@ -185,7 +193,7 @@ class TestBuilder {
185193
},
186194
testFixture: this.testFixture,
187195
extension: this.extension,
188-
useServiceWorker: this.useServiceWorker,
196+
hostName: this.hostName,
189197
}
190198

191199
if (this.alsoRunWithRumSlim) {
@@ -238,6 +246,20 @@ function declareTest(title: string, setupOptions: SetupOptions, factory: SetupFa
238246
addTag('test.browserName', browserName)
239247
addTestOptimizationTags(test.info().project.metadata as BrowserConfiguration)
240248

249+
test.skip(
250+
!!setupOptions.hostName && setupOptions.hostName.endsWith('.localhost') && isBrowserStack,
251+
// Skip those tests on BrowserStack because it doesn't support localhost subdomains. As a
252+
// workaround we could use normal domains and use either:
253+
// * the BrowserStack proxy capabilities -> not tried, but this sounds more complex because
254+
// we also want to run tests outside of BrowserStack
255+
// * the Playwright proxy capabilities -> tried and it seems to fail because of mismatch
256+
// version between Playwright local and BrowserStack versions
257+
// * a "ngrok-like" service -> not tried yet (it sounds more complex)
258+
//
259+
// See https://www.browserstack.com/support/faq/local-testing/local-exceptions/i-face-issues-while-testing-localhost-urls-or-private-servers-in-safari-on-macos-os-x-and-ios
260+
'Localhost subdomains are not supported in BrowserStack'
261+
)
262+
241263
const title = test.info().titlePath.join(' > ')
242264
setupOptions.context.test_name = title
243265

@@ -268,15 +290,16 @@ function createTestContext(
268290
browserContext: BrowserContext,
269291
browserLogsManager: BrowserLogsManager,
270292
browserName: TestContext['browserName'],
271-
{ basePath, useServiceWorker }: SetupOptions
293+
{ basePath, hostName }: SetupOptions
272294
): TestContext {
273-
const url = servers.base.url
274-
const hostname = useServiceWorker ? url.replace(/http:\/\/[^:]+:/, 'http://localhost:') : url
295+
const baseUrl = new URL(basePath, servers.base.origin)
296+
297+
if (hostName) {
298+
baseUrl.hostname = hostName
299+
}
275300

276301
return {
277-
// Service workers require HTTPS or localhost due to browser security restrictions
278-
baseUrl: hostname + basePath,
279-
crossOriginUrl: servers.crossOrigin.url,
302+
baseUrl: baseUrl.href,
280303
intakeRegistry: new IntakeRegistry(),
281304
servers,
282305
page,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const isBrowserStack = Boolean(process.env.BROWSER_STACK)
2+
export const isContinuousIntegration = Boolean(process.env.CI)

test/e2e/lib/framework/flushEvents.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,6 @@ export async function flushEvents(page: Page) {
2121
// The issue mainly occurs with local e2e tests (not browserstack), because the network latency is
2222
// very low (same machine), so the request resolves very quickly. In real life conditions, this
2323
// issue is mitigated, because requests will likely take a few milliseconds to reach the server.
24-
await page.goto(`${servers.base.url}/ok?duration=200`)
24+
await page.goto(`${servers.base.origin}/ok?duration=200`)
2525
await waitForServersIdle()
2626
}

test/e2e/lib/framework/httpServers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export type MockServerApp = ServerApp & {
1515
}
1616

1717
export interface Server<App extends ServerApp> {
18-
url: string
18+
origin: string
1919
app: App
2020
bindServerApp(serverApp: App): void
2121
waitForIdle(): Promise<void>
@@ -67,7 +67,7 @@ async function createServer<App extends ServerApp>(): Promise<Server<App>> {
6767
}
6868
return serverApp
6969
},
70-
url: `http://${address}:${port}`,
70+
origin: `http://${address}:${port}`,
7171
waitForIdle: createServerIdleWaiter(server),
7272
}
7373
}

0 commit comments

Comments
 (0)