Skip to content

Commit

Permalink
feat: update itp implementation based on load options (#1777)
Browse files Browse the repository at this point in the history
* feat: update itp implementation based on load options

* chore: remove unnecessary logs

* chore: refactor if condition

Co-authored-by: Sai Kumar Battinoju <[email protected]>

* chore: review comment addressed

* chore: update log message

Co-authored-by: Sai Kumar Battinoju <[email protected]>

* chore: review comment address

* chore: variable name update

---------

Co-authored-by: Sai Kumar Battinoju <[email protected]>
  • Loading branch information
MoumitaM and saikumarrs authored Jul 4, 2024
1 parent c4797ca commit 75aa117
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 7 deletions.
11 changes: 11 additions & 0 deletions packages/analytics-js-common/__tests__/utilities/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
tryStringify,
toBase64,
fromBase64,
removeLeadingPeriod,
} from '../../src/utilities/string';

describe('Common Utils - String', () => {
Expand Down Expand Up @@ -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');
});
});
});
4 changes: 3 additions & 1 deletion packages/analytics-js-common/src/utilities/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
validateLoadArgs,
getTopDomainUrl,
getDataServiceUrl,
isWebpageTopLevelDomain,
} from '../../../src/components/configManager/util/validate';

describe('Config manager util - validate load arguments', () => {
Expand Down Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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';

/**
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;

Expand All @@ -171,13 +188,35 @@ 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,
samesite: 'None',
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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,
Expand All @@ -72,4 +82,5 @@ export {
validateDataPlaneUrl,
getTopDomainUrl,
getDataServiceUrl,
isWebpageTopLevelDomain,
};
8 changes: 8 additions & 0 deletions packages/analytics-js/src/constants/logMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -308,4 +315,5 @@ export {
INVALID_POLYFILL_URL_WARNING,
SOURCE_DISABLED_ERROR,
COMPONENT_BASE_URL_ERROR,
SERVER_SIDE_COOKIE_FEATURE_OVERRIDE_WARNING,
};

0 comments on commit 75aa117

Please sign in to comment.