From 306761b9ff2d96d4553fd6583a334f1ce8f8e521 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Sun, 26 Mar 2023 12:29:55 -0400 Subject: [PATCH 1/2] Move changes from pr#7052 to fresh branch --- .changeset/wet-cooks-doubt.md | 5 ++ package.json | 3 ++ packages/analytics/src/errors.ts | 8 ++- packages/analytics/src/helpers.test.ts | 72 +++++++++++++++++++++++++- packages/analytics/src/helpers.ts | 52 ++++++++++++++++++- yarn.lock | 5 ++ 6 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 .changeset/wet-cooks-doubt.md diff --git a/.changeset/wet-cooks-doubt.md b/.changeset/wet-cooks-doubt.md new file mode 100644 index 00000000000..69dbdd34e43 --- /dev/null +++ b/.changeset/wet-cooks-doubt.md @@ -0,0 +1,5 @@ +--- +'@firebase/analytics': patch +--- + +Use the Trusted Types API when composing the gtag URL. diff --git a/package.json b/package.json index 77c90c0e2f4..17849e5e941 100644 --- a/package.json +++ b/package.json @@ -153,5 +153,8 @@ "watch": "1.0.2", "webpack": "4.46.0", "yargs": "17.6.2" + }, + "dependencies": { + "@types/trusted-types": "2.0.3" } } diff --git a/packages/analytics/src/errors.ts b/packages/analytics/src/errors.ts index 98293447c65..eacda193573 100644 --- a/packages/analytics/src/errors.ts +++ b/packages/analytics/src/errors.ts @@ -27,7 +27,8 @@ export const enum AnalyticsError { FETCH_THROTTLE = 'fetch-throttle', CONFIG_FETCH_FAILED = 'config-fetch-failed', NO_API_KEY = 'no-api-key', - NO_APP_ID = 'no-app-id' + NO_APP_ID = 'no-app-id', + INVALID_GTAG_RESOURCE = 'invalid-gtag-resource' } const ERRORS: ErrorMap = { @@ -64,7 +65,9 @@ const ERRORS: ErrorMap = { 'contain a valid API key.', [AnalyticsError.NO_APP_ID]: 'The "appId" field is empty in the local Firebase config. Firebase Analytics requires this field to' + - 'contain a valid app ID.' + 'contain a valid app ID.', + [AnalyticsError.INVALID_GTAG_RESOURCE]: + 'Trusted Types detected an invalid gtag resource: {$gtagURL}.' }; interface ErrorParams { @@ -77,6 +80,7 @@ interface ErrorParams { }; [AnalyticsError.INVALID_ANALYTICS_CONTEXT]: { errorInfo: string }; [AnalyticsError.INDEXEDDB_UNAVAILABLE]: { errorInfo: string }; + [AnalyticsError.INVALID_GTAG_RESOURCE]: { gtagURL: string }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/analytics/src/helpers.test.ts b/packages/analytics/src/helpers.test.ts index 1175ff0f61e..98df87b6c04 100644 --- a/packages/analytics/src/helpers.test.ts +++ b/packages/analytics/src/helpers.test.ts @@ -24,12 +24,16 @@ import { insertScriptTag, wrapOrCreateGtag, findGtagScriptOnPage, - promiseAllSettled + promiseAllSettled, + createGtagTrustedTypesScriptURL, + createTrustedTypesPolicy } from './helpers'; -import { GtagCommand } from './constants'; +import { GtagCommand, GTAG_URL } from './constants'; import { Deferred } from '@firebase/util'; import { ConsentSettings } from './public-types'; import { removeGtagScripts } from '../testing/gtag-script-util'; +import { logger } from './logger'; +import { AnalyticsError, ERROR_FACTORY } from './errors'; const fakeMeasurementId = 'abcd-efgh-ijkl'; const fakeAppId = 'my-test-app-1234'; @@ -46,6 +50,70 @@ const fakeDynamicConfig: DynamicConfig = { }; const fakeDynamicConfigPromises = [Promise.resolve(fakeDynamicConfig)]; +describe('Trusted Types policies and functions', () => { + describe('Trusted types exists', () => { + let ttStub: SinonStub; + + beforeEach(() => { + ttStub = stub( + window.trustedTypes as TrustedTypePolicyFactory, + 'createPolicy' + ).returns({ + createScriptURL: (s: string) => s + } as any); + }); + + afterEach(() => { + removeGtagScripts(); + ttStub.restore(); + }); + + it('Verify trustedTypes is called if the API is available', () => { + const trustedTypesPolicy = createTrustedTypesPolicy( + 'firebase-js-sdk-policy', + { + createScriptURL: createGtagTrustedTypesScriptURL + } + ); + + expect(ttStub).to.be.called; + expect(trustedTypesPolicy).not.to.be.undefined; + }); + + it('createGtagTrustedTypesScriptURL verifies gtag URL base exists when a URL is provided', () => { + expect(createGtagTrustedTypesScriptURL(GTAG_URL)).to.equal(GTAG_URL); + }); + + it('createGtagTrustedTypesScriptURL rejects URLs with non-gtag base', () => { + const NON_GTAG_URL = 'http://iamnotgtag.com'; + const loggerWarnStub = stub(logger, 'warn'); + const errorMessage = ERROR_FACTORY.create( + AnalyticsError.INVALID_GTAG_RESOURCE, + { + gtagURL: NON_GTAG_URL + } + ).message; + + expect(createGtagTrustedTypesScriptURL(NON_GTAG_URL)).to.equal(''); + expect(loggerWarnStub).to.be.calledWith(errorMessage); + }); + }); + + describe('Trusted types does not exist', () => { + it('Verify trustedTypes functions are not called if the API is not available', () => { + delete window.trustedTypes; + const trustedTypesPolicy = createTrustedTypesPolicy( + 'firebase-js-sdk-policy', + { + createScriptURL: createGtagTrustedTypesScriptURL + } + ); + + expect(trustedTypesPolicy).to.be.undefined; + }); + }); +}); + describe('Gtag wrapping functions', () => { afterEach(() => { removeGtagScripts(); diff --git a/packages/analytics/src/helpers.ts b/packages/analytics/src/helpers.ts index 1fb8a38319c..e926c14a725 100644 --- a/packages/analytics/src/helpers.ts +++ b/packages/analytics/src/helpers.ts @@ -24,10 +24,25 @@ import { import { DynamicConfig, DataLayer, Gtag, MinimalDynamicConfig } from './types'; import { GtagCommand, GTAG_URL } from './constants'; import { logger } from './logger'; +import { AnalyticsError, ERROR_FACTORY } from './errors'; // Possible parameter types for gtag 'event' and 'config' commands type GtagConfigOrEventParams = ControlParams & EventParams & CustomParams; +/** + * Verifies and creates a TrustedScriptURL. + */ +export function createGtagTrustedTypesScriptURL(url: string): string { + if (!url.startsWith(GTAG_URL)) { + const err = ERROR_FACTORY.create(AnalyticsError.INVALID_GTAG_RESOURCE, { + gtagURL: url + }); + logger.warn(err.message); + return ''; + } + return url; +} + /** * Makeshift polyfill for Promise.allSettled(). Resolves when all promises * have either resolved or rejected. @@ -40,6 +55,29 @@ export function promiseAllSettled( return Promise.all(promises.map(promise => promise.catch(e => e))); } +/** + * Creates a TrustedTypePolicy object that implements the rules passed as policyOptions. + * + * @param policyName A string containing the name of the policy + * @param policyOptions Object containing implementations of instance methods for TrustedTypesPolicy, see {@link https://developer.mozilla.org/en-US/docs/Web/API/TrustedTypePolicy#instance_methods + * | the TrustedTypePolicy reference documentation}. + */ +export function createTrustedTypesPolicy( + policyName: string, + policyOptions: Partial +): Partial | undefined { + // Create a TrustedTypes policy that we can use for updating src + // properties + let trustedTypesPolicy: Partial | undefined; + if (window.trustedTypes) { + trustedTypesPolicy = window.trustedTypes.createPolicy( + policyName, + policyOptions + ); + } + return trustedTypesPolicy; +} + /** * Inserts gtag script tag into the page to asynchronously download gtag. * @param dataLayerName Name of datalayer (most often the default, "_dataLayer"). @@ -48,10 +86,22 @@ export function insertScriptTag( dataLayerName: string, measurementId: string ): void { + const trustedTypesPolicy = createTrustedTypesPolicy( + 'firebase-js-sdk-policy', + { + createScriptURL: createGtagTrustedTypesScriptURL + } + ); + const script = document.createElement('script'); // We are not providing an analyticsId in the URL because it would trigger a `page_view` // without fid. We will initialize ga-id using gtag (config) command together with fid. - script.src = `${GTAG_URL}?l=${dataLayerName}&id=${measurementId}`; + + const gtagScriptURL = `${GTAG_URL}?l=${dataLayerName}&id=${measurementId}`; + (script.src as string | TrustedScriptURL) = trustedTypesPolicy + ? (trustedTypesPolicy as TrustedTypePolicy)?.createScriptURL(gtagScriptURL) + : gtagScriptURL; + script.async = true; document.head.appendChild(script); } diff --git a/yarn.lock b/yarn.lock index 4b38969007c..b55df34dc8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3693,6 +3693,11 @@ resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.1.tgz#8f80dd965ad81f3e1bc26d6f5c727e132721ff40" integrity sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg== +"@types/trusted-types@2.0.3": + version "2.0.3" + resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz#a136f83b0758698df454e328759dbd3d44555311" + integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g== + "@types/vinyl@^2.0.4": version "2.0.6" resolved "https://registry.npmjs.org/@types/vinyl/-/vinyl-2.0.6.tgz#b2d134603557a7c3d2b5d3dc23863ea2b5eb29b0" From 23010ec851a3cfb5f85e47771d5a3b6fe3c1d8fe Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Sun, 26 Mar 2023 12:35:54 -0400 Subject: [PATCH 2/2] Move @types/trusted-types into dev deps --- package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/package.json b/package.json index 17849e5e941..ad3dd89e986 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@types/sinon": "9.0.11", "@types/sinon-chai": "3.2.9", "@types/tmp": "0.2.3", + "@types/trusted-types": "2.0.3", "@types/yargs": "17.0.22", "@typescript-eslint/eslint-plugin": "5.43.0", "@typescript-eslint/eslint-plugin-tslint": "5.43.0", @@ -153,8 +154,5 @@ "watch": "1.0.2", "webpack": "4.46.0", "yargs": "17.6.2" - }, - "dependencies": { - "@types/trusted-types": "2.0.3" } }