diff --git a/packages/analytics-js-common/__tests__/utilities/string.test.ts b/packages/analytics-js-common/__tests__/utilities/string.test.ts index f5c31b9181..0d1ab6e4a1 100644 --- a/packages/analytics-js-common/__tests__/utilities/string.test.ts +++ b/packages/analytics-js-common/__tests__/utilities/string.test.ts @@ -4,6 +4,7 @@ import { tryStringify, toBase64, fromBase64, + removeLeadingPeriod, } from '../../src/utilities/string'; describe('Common Utils - String', () => { @@ -101,4 +102,14 @@ describe('Common Utils - String', () => { expect(fromBase64('8J+Riw==')).toBe('👋'); }); }); + + describe('removeLeadingPeriod', () => { + it('should remove leading dot from the string if any', () => { + expect(removeLeadingPeriod('.sample')).toBe('sample'); + expect(removeLeadingPeriod('sample.')).toBe('sample.'); + expect(removeLeadingPeriod('..sample')).toBe('sample'); + expect(removeLeadingPeriod('sample')).toBe('sample'); + expect(removeLeadingPeriod('sample.com')).toBe('sample.com'); + }); + }); }); diff --git a/packages/analytics-js-common/src/utilities/string.ts b/packages/analytics-js-common/src/utilities/string.ts index ad32bf68d6..9b011e7969 100644 --- a/packages/analytics-js-common/src/utilities/string.ts +++ b/packages/analytics-js-common/src/utilities/string.ts @@ -6,6 +6,8 @@ const trim = (value: string): string => value.replace(/^\s+|\s+$/gm, ''); const removeDoubleSpaces = (value: string): string => value.replace(/ {2,}/g, ' '); +const removeLeadingPeriod = (value: string): string => value.replace(/^\.+/, ''); + /** * A function to convert values to string * @param val input value @@ -61,4 +63,4 @@ const toBase64 = (value: string): string => bytesToBase64(new TextEncoder().enco */ const fromBase64 = (value: string): string => new TextDecoder().decode(base64ToBytes(value)); -export { trim, removeDoubleSpaces, tryStringify, toBase64, fromBase64 }; +export { trim, removeDoubleSpaces, tryStringify, toBase64, fromBase64, removeLeadingPeriod }; diff --git a/packages/analytics-js/__tests__/components/configManager/commonUtil.test.ts b/packages/analytics-js/__tests__/components/configManager/commonUtil.test.ts index fba844d3cf..bc39d87dd2 100644 --- a/packages/analytics-js/__tests__/components/configManager/commonUtil.test.ts +++ b/packages/analytics-js/__tests__/components/configManager/commonUtil.test.ts @@ -9,8 +9,14 @@ import { updateDataPlaneEventsStateFromLoadOptions, getSourceConfigURL, } from '../../../src/components/configManager/util/commonUtil'; +import { + getDataServiceUrl, + isWebpageTopLevelDomain, +} from '../../../src/components/configManager/util/validate'; import { state, resetState } from '../../../src/state'; +jest.mock('../../../src/components/configManager/util/validate'); + const createScriptElement = (url: string) => { const script = document.createElement('script'); script.type = 'text/javascript'; @@ -30,8 +36,22 @@ describe('Config Manager Common Utilities', () => { error: jest.fn(), } as unknown as ILogger; + let originalGetDataServiceUrl: (endpoint: string, useExactDomain: boolean) => string; + let isWebpageTopLevelDomainOriginal: (domain: string) => boolean; + + beforeAll(() => { + // Save the original implementation + originalGetDataServiceUrl = jest.requireActual( + '../../../src/components/configManager/util/validate', + ).getDataServiceUrl; + isWebpageTopLevelDomainOriginal = jest.requireActual( + '../../../src/components/configManager/util/validate', + ).isWebpageTopLevelDomain; + }); + beforeEach(() => { resetState(); + (getDataServiceUrl as jest.Mock).mockRestore(); }); describe('getSDKUrl', () => { @@ -234,6 +254,98 @@ describe('Config Manager Common Utilities', () => { 'ConfigManager:: The storage data migration has been disabled because the configured storage encryption version (legacy) is not the latest (v3). To enable storage data migration, please update the storage encryption version to the latest version.', ); }); + + it('should not change the value of isEnabledServerSideCookies if the useServerSideCookies is set to false', () => { + state.loadOptions.value.useServerSideCookies = false; + state.loadOptions.value.storage = { + cookie: { + samesite: 'secure', + }, + }; + + updateStorageStateFromLoadOptions(mockLogger); + + expect(state.serverCookies.isEnabledServerSideCookies.value).toBe(false); + expect(state.storage.cookie.value).toEqual({ + samesite: 'secure', + }); + }); + + it('should set the value of isEnabledServerSideCookies to false if the useServerSideCookies is set to true but the dataServiceUrl is not valid url', () => { + state.loadOptions.value.useServerSideCookies = true; + (getDataServiceUrl as jest.Mock).mockImplementation(() => 'invalid-url'); + updateStorageStateFromLoadOptions(mockLogger); + + expect(state.serverCookies.isEnabledServerSideCookies.value).toBe(false); + }); + + it('should set the value of isEnabledServerSideCookies to true if the useServerSideCookies is set to true and the dataServiceUrl is a valid url', () => { + state.loadOptions.value.useServerSideCookies = true; + (getDataServiceUrl as jest.Mock).mockImplementation(() => 'https://www.dummy.url'); + updateStorageStateFromLoadOptions(mockLogger); + + expect(state.serverCookies.isEnabledServerSideCookies.value).toBe(true); + expect(state.serverCookies.dataServiceUrl.value).toBe('https://www.dummy.url'); + }); + + it('should determine the dataServiceUrl from the exact domain if sameDomainCookiesOnly load option is set to true', () => { + state.loadOptions.value.useServerSideCookies = true; + state.loadOptions.value.sameDomainCookiesOnly = true; + + (getDataServiceUrl as jest.Mock).mockImplementation(originalGetDataServiceUrl); + updateStorageStateFromLoadOptions(mockLogger); + + expect(state.serverCookies.isEnabledServerSideCookies.value).toBe(true); + expect(state.serverCookies.dataServiceUrl.value).toBe('https://www.test-host.com/rsaRequest'); + }); + + it('should determine the dataServiceUrl from the exact domain if setCookieDomain load option is provided', () => { + state.loadOptions.value.useServerSideCookies = true; + state.loadOptions.value.setCookieDomain = 'www.test-host.com'; + + (getDataServiceUrl as jest.Mock).mockImplementation(originalGetDataServiceUrl); + updateStorageStateFromLoadOptions(mockLogger); + + expect(state.serverCookies.isEnabledServerSideCookies.value).toBe(true); + expect(state.serverCookies.dataServiceUrl.value).toBe('https://www.test-host.com/rsaRequest'); + }); + + it('should set isEnabledServerSideCookies to true if provided setCookieDomain load option is top-level domain and sameDomainCookiesOnly option is not set', () => { + state.loadOptions.value.useServerSideCookies = true; + state.loadOptions.value.setCookieDomain = 'test-host.com'; + + (isWebpageTopLevelDomain as jest.Mock).mockImplementation(isWebpageTopLevelDomainOriginal); + (getDataServiceUrl as jest.Mock).mockImplementation(originalGetDataServiceUrl); + updateStorageStateFromLoadOptions(mockLogger); + + expect(state.serverCookies.isEnabledServerSideCookies.value).toBe(true); + expect(state.serverCookies.dataServiceUrl.value).toBe('https://test-host.com/rsaRequest'); + }); + + it('should set isEnabledServerSideCookies to false if provided setCookieDomain load option is different from current domain and sameDomainCookiesOnly option is not set', () => { + state.loadOptions.value.useServerSideCookies = true; + state.loadOptions.value.setCookieDomain = 'random-host.com'; + + (isWebpageTopLevelDomain as jest.Mock).mockImplementation(isWebpageTopLevelDomainOriginal); + (getDataServiceUrl as jest.Mock).mockImplementation(originalGetDataServiceUrl); + updateStorageStateFromLoadOptions(mockLogger); + + expect(state.serverCookies.isEnabledServerSideCookies.value).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + "ConfigManager:: The provided cookie domain (random-host.com) does not match the current webpage's domain (www.test-host.com). Hence, the cookies will be set client-side.", + ); + }); + + it('should set isEnabledServerSideCookies to true if provided setCookieDomain load option is different from current domain and sameDomainCookiesOnly option is set', () => { + state.loadOptions.value.useServerSideCookies = true; + state.loadOptions.value.setCookieDomain = 'test-host.com'; + state.loadOptions.value.sameDomainCookiesOnly = true; + + (getDataServiceUrl as jest.Mock).mockImplementation(originalGetDataServiceUrl); + updateStorageStateFromLoadOptions(mockLogger); + + expect(state.serverCookies.isEnabledServerSideCookies.value).toBe(true); + }); }); describe('updateConsentsStateFromLoadOptions', () => { diff --git a/packages/analytics-js/__tests__/components/configManager/validate.test.ts b/packages/analytics-js/__tests__/components/configManager/validate.test.ts index 86f55a0f48..2176388a7b 100644 --- a/packages/analytics-js/__tests__/components/configManager/validate.test.ts +++ b/packages/analytics-js/__tests__/components/configManager/validate.test.ts @@ -2,6 +2,7 @@ import { validateLoadArgs, getTopDomainUrl, getDataServiceUrl, + isWebpageTopLevelDomain, } from '../../../src/components/configManager/util/validate'; describe('Config manager util - validate load arguments', () => { @@ -46,12 +47,27 @@ describe('Config manager util - validate load arguments', () => { }); describe('getDataServiceUrl', () => { it('should return dataServiceUrl', () => { - const dataServiceUrl = getDataServiceUrl('endpoint'); + const dataServiceUrl = getDataServiceUrl('endpoint', false); expect(dataServiceUrl).toBe('https://test-host.com/endpoint'); }); it('should prepare the dataServiceUrl with endpoint without leading slash', () => { - const dataServiceUrl = getDataServiceUrl('/endpoint'); + const dataServiceUrl = getDataServiceUrl('/endpoint', false); expect(dataServiceUrl).toBe('https://test-host.com/endpoint'); }); + it('should return dataServiceUrl with exact domain', () => { + const dataServiceUrl = getDataServiceUrl('endpoint', true); + expect(dataServiceUrl).toBe('https://www.test-host.com/endpoint'); + }); + }); + + describe('isWebpageTopLevelDomain', () => { + it('should return true for top level domain', () => { + const isTopLevel = isWebpageTopLevelDomain('test-host.com'); + expect(isTopLevel).toBe(true); + }); + it('should return false for subdomain', () => { + const isTopLevel = isWebpageTopLevelDomain('sub.test-host.com'); + expect(isTopLevel).toBe(false); + }); }); }); diff --git a/packages/analytics-js/src/components/configManager/util/commonUtil.ts b/packages/analytics-js/src/components/configManager/util/commonUtil.ts index 80fef08ee0..5283a8b612 100644 --- a/packages/analytics-js/src/components/configManager/util/commonUtil.ts +++ b/packages/analytics-js/src/components/configManager/util/commonUtil.ts @@ -20,6 +20,7 @@ import type { import { clone } from 'ramda'; import type { PluginName } from '@rudderstack/analytics-js-common/types/PluginsManager'; import { isValidURL, removeDuplicateSlashes } from '@rudderstack/analytics-js-common/utilities/url'; +import { removeLeadingPeriod } from '@rudderstack/analytics-js-common/utilities/string'; import { MODULE_TYPE, APP_VERSION } from '../../../constants/app'; import { BUILD_TYPE, DEFAULT_CONFIG_BE_URL } from '../../../constants/urls'; import { state } from '../../../state'; @@ -32,6 +33,7 @@ import { UNSUPPORTED_PRE_CONSENT_EVENTS_DELIVERY_TYPE, UNSUPPORTED_PRE_CONSENT_STORAGE_STRATEGY, UNSUPPORTED_STORAGE_ENCRYPTION_VERSION_WARNING, + SERVER_SIDE_COOKIE_FEATURE_OVERRIDE_WARNING, } from '../../../constants/logMessages'; import { isErrorReportingEnabled, @@ -47,7 +49,7 @@ import { ErrorReportingProvidersToPluginNameMap, StorageEncryptionVersionsToPluginNameMap, } from '../constants'; -import { getDataServiceUrl, isValidStorageType } from './validate'; +import { getDataServiceUrl, isValidStorageType, isWebpageTopLevelDomain } from './validate'; import { getConsentManagementData } from '../../utilities/consent'; /** @@ -110,6 +112,8 @@ const updateStorageStateFromLoadOptions = (logger?: ILogger): void => { useServerSideCookies, dataServiceEndpoint, storage: storageOptsFromLoad, + setCookieDomain, + sameDomainCookiesOnly, } = state.loadOptions.value; let storageType = storageOptsFromLoad?.type; if (isDefined(storageType) && !isValidStorageType(storageType)) { @@ -160,9 +164,22 @@ const updateStorageStateFromLoadOptions = (logger?: ILogger): void => { if (useServerSideCookies) { state.serverCookies.isEnabledServerSideCookies.value = useServerSideCookies; + const providedCookieDomain = cookieOptions.domain ?? setCookieDomain; + /** + * Based on the following conditions, we decide whether to use the exact domain or not to determine the data service URL: + * 1. If the cookie domain is provided and it is not a top-level domain, then use the exact domain + * 2. If the sameDomainCookiesOnly flag is set to true, then use the exact domain + */ + const useExactDomain = + (isDefined(providedCookieDomain) && + !isWebpageTopLevelDomain(removeLeadingPeriod(providedCookieDomain as string))) || + sameDomainCookiesOnly; + const dataServiceUrl = getDataServiceUrl( dataServiceEndpoint ?? DEFAULT_DATA_SERVICE_ENDPOINT, + useExactDomain ?? false, ); + if (isValidURL(dataServiceUrl)) { state.serverCookies.dataServiceUrl.value = removeTrailingSlashes(dataServiceUrl) as string; @@ -171,6 +188,9 @@ const updateStorageStateFromLoadOptions = (logger?: ILogger): void => { // If the current host is different from the data service host, then it is a cross-site request // For server-side cookies to work, we need to set the SameSite=None and Secure attributes + // One round of cookie options manipulation is taking place here + // Based on these(setCookieDomain/storage.cookie or sameDomainCookiesOnly) two load-options, final cookie options are set in the storage module + // TODO: Refactor the cookie options manipulation logic in one place if (curHost !== dataServiceHost) { cookieOptions = { ...cookieOptions, @@ -178,6 +198,25 @@ const updateStorageStateFromLoadOptions = (logger?: ILogger): void => { secure: true, }; } + /** + * If the sameDomainCookiesOnly flag is not set and the cookie domain is provided(not top level domain), + * and the data service host is different from the provided cookie domain, then we disable server-side cookies + * ex: provided cookie domain: 'random.com', data service host: 'sub.example.com' + */ + if ( + !sameDomainCookiesOnly && + useExactDomain && + dataServiceHost !== removeLeadingPeriod(providedCookieDomain as string) + ) { + state.serverCookies.isEnabledServerSideCookies.value = false; + logger?.warn( + SERVER_SIDE_COOKIE_FEATURE_OVERRIDE_WARNING( + CONFIG_MANAGER, + providedCookieDomain, + dataServiceHost as string, + ), + ); + } } else { state.serverCookies.isEnabledServerSideCookies.value = false; } diff --git a/packages/analytics-js/src/components/configManager/util/validate.ts b/packages/analytics-js/src/components/configManager/util/validate.ts index d0eaf253da..6090e231cd 100644 --- a/packages/analytics-js/src/components/configManager/util/validate.ts +++ b/packages/analytics-js/src/components/configManager/util/validate.ts @@ -37,7 +37,7 @@ const isValidSourceConfig = (res: any): boolean => const isValidStorageType = (storageType?: StorageType): boolean => typeof storageType === 'string' && SUPPORTED_STORAGE_TYPES.includes(storageType); -const getTopDomainUrl = (url: string) => { +const getTopDomain = (url: string) => { // Create a URL object const urlObj = new URL(url); @@ -55,15 +55,25 @@ const getTopDomainUrl = (url: string) => { // If only two parts or less, return as it is topDomain = host; } + return { topDomain, protocol }; +}; + +const getTopDomainUrl = (url: string) => { + const { topDomain, protocol } = getTopDomain(url); return `${protocol}//${topDomain}`; }; -const getDataServiceUrl = (endpoint: string) => { - const url = getTopDomainUrl(window.location.href); +const getDataServiceUrl = (endpoint: string, useExactDomain: boolean) => { + const url = useExactDomain ? window.location.origin : getTopDomainUrl(window.location.href); const formattedEndpoint = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint; return `${url}/${formattedEndpoint}`; }; +const isWebpageTopLevelDomain = (providedDomain: string): boolean => { + const { topDomain } = getTopDomain(window.location.href); + return topDomain === providedDomain; +}; + export { validateLoadArgs, isValidSourceConfig, @@ -72,4 +82,5 @@ export { validateDataPlaneUrl, getTopDomainUrl, getDataServiceUrl, + isWebpageTopLevelDomain, }; diff --git a/packages/analytics-js/src/constants/logMessages.ts b/packages/analytics-js/src/constants/logMessages.ts index 644294599f..f36bb5e496 100644 --- a/packages/analytics-js/src/constants/logMessages.ts +++ b/packages/analytics-js/src/constants/logMessages.ts @@ -137,6 +137,13 @@ const STORAGE_DATA_MIGRATION_OVERRIDE_WARNING = ( ): string => `${context}${LOG_CONTEXT_SEPARATOR}The storage data migration has been disabled because the configured storage encryption version (${storageEncryptionVersion}) is not the latest (${defaultVersion}). To enable storage data migration, please update the storage encryption version to the latest version.`; +const SERVER_SIDE_COOKIE_FEATURE_OVERRIDE_WARNING = ( + context: string, + providedCookieDomain: string | undefined, + currentCookieDomain: string, +): string => + `${context}${LOG_CONTEXT_SEPARATOR}The provided cookie domain (${providedCookieDomain}) does not match the current webpage's domain (${currentCookieDomain}). Hence, the cookies will be set client-side.`; + const RESERVED_KEYWORD_WARNING = ( context: string, property: string, @@ -308,4 +315,5 @@ export { INVALID_POLYFILL_URL_WARNING, SOURCE_DISABLED_ERROR, COMPONENT_BASE_URL_ERROR, + SERVER_SIDE_COOKIE_FEATURE_OVERRIDE_WARNING, };