Skip to content

Commit 3600648

Browse files
dlarocquetom-andersen
authored andcommitted
Use safevalues to fix trusted types issues reported by tsec (#8301)
* Use safevalues to fix trusted types issues reported by tsec * Upgrade to safevalues 0.6.0 * Remove exemptions, and untested usages of safevalues * Add dependency that was accidentally removed * Add FIXMEs for tsec violations * Run formatting * Compare against full Gtag script in tests * Check that full reCAPTCHA script URL was assigned to script element * Replace FIXMEs with TODOs * Remove auth, rtdb, messaging from changeset
1 parent 5676813 commit 3600648

File tree

18 files changed

+95
-137
lines changed

18 files changed

+95
-137
lines changed

.changeset/happy-trees-battle.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@firebase/analytics': patch
3+
'@firebase/app-check': patch
4+
---
5+
6+
Begin using [safevalues](https://github.com/google/safevalues) to sanitize HTML vulnerable to XSS.

packages/analytics/package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,16 @@
4545
"@firebase/logger": "0.4.2",
4646
"@firebase/util": "1.9.7",
4747
"@firebase/component": "0.6.8",
48-
"tslib": "^2.1.0"
48+
"tslib": "^2.1.0",
49+
"safevalues": "0.6.0"
4950
},
5051
"license": "Apache-2.0",
5152
"devDependencies": {
5253
"@firebase/app": "0.10.6",
53-
"rollup": "2.79.1",
5454
"@rollup/plugin-commonjs": "21.1.0",
5555
"@rollup/plugin-json": "4.1.0",
5656
"@rollup/plugin-node-resolve": "13.3.0",
57+
"rollup": "2.79.1",
5758
"rollup-plugin-typescript2": "0.31.2",
5859
"typescript": "4.7.4"
5960
},

packages/analytics/src/helpers.test.ts

+5-73
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,12 @@ import {
2424
insertScriptTag,
2525
wrapOrCreateGtag,
2626
findGtagScriptOnPage,
27-
promiseAllSettled,
28-
createGtagTrustedTypesScriptURL,
29-
createTrustedTypesPolicy
27+
promiseAllSettled
3028
} from './helpers';
31-
import { GtagCommand, GTAG_URL } from './constants';
29+
import { GtagCommand } from './constants';
3230
import { Deferred } from '@firebase/util';
3331
import { ConsentSettings } from './public-types';
3432
import { removeGtagScripts } from '../testing/gtag-script-util';
35-
import { logger } from './logger';
36-
import { AnalyticsError, ERROR_FACTORY } from './errors';
3733

3834
const fakeMeasurementId = 'abcd-efgh-ijkl';
3935
const fakeAppId = 'my-test-app-1234';
@@ -50,71 +46,6 @@ const fakeDynamicConfig: DynamicConfig = {
5046
};
5147
const fakeDynamicConfigPromises = [Promise.resolve(fakeDynamicConfig)];
5248

53-
describe('Trusted Types policies and functions', () => {
54-
if (window.trustedTypes) {
55-
describe('Trusted types exists', () => {
56-
let ttStub: SinonStub;
57-
58-
beforeEach(() => {
59-
ttStub = stub(
60-
window.trustedTypes as TrustedTypePolicyFactory,
61-
'createPolicy'
62-
).returns({
63-
createScriptURL: (s: string) => s
64-
} as any);
65-
});
66-
67-
afterEach(() => {
68-
removeGtagScripts();
69-
ttStub.restore();
70-
});
71-
72-
it('Verify trustedTypes is called if the API is available', () => {
73-
const trustedTypesPolicy = createTrustedTypesPolicy(
74-
'firebase-js-sdk-policy',
75-
{
76-
createScriptURL: createGtagTrustedTypesScriptURL
77-
}
78-
);
79-
80-
expect(ttStub).to.be.called;
81-
expect(trustedTypesPolicy).not.to.be.undefined;
82-
});
83-
84-
it('createGtagTrustedTypesScriptURL verifies gtag URL base exists when a URL is provided', () => {
85-
expect(createGtagTrustedTypesScriptURL(GTAG_URL)).to.equal(GTAG_URL);
86-
});
87-
88-
it('createGtagTrustedTypesScriptURL rejects URLs with non-gtag base', () => {
89-
const NON_GTAG_URL = 'http://iamnotgtag.com';
90-
const loggerWarnStub = stub(logger, 'warn');
91-
const errorMessage = ERROR_FACTORY.create(
92-
AnalyticsError.INVALID_GTAG_RESOURCE,
93-
{
94-
gtagURL: NON_GTAG_URL
95-
}
96-
).message;
97-
98-
expect(createGtagTrustedTypesScriptURL(NON_GTAG_URL)).to.equal('');
99-
expect(loggerWarnStub).to.be.calledWith(errorMessage);
100-
});
101-
});
102-
}
103-
describe('Trusted types does not exist', () => {
104-
it('Verify trustedTypes functions are not called if the API is not available', () => {
105-
delete window.trustedTypes;
106-
const trustedTypesPolicy = createTrustedTypesPolicy(
107-
'firebase-js-sdk-policy',
108-
{
109-
createScriptURL: createGtagTrustedTypesScriptURL
110-
}
111-
);
112-
113-
expect(trustedTypesPolicy).to.be.undefined;
114-
});
115-
});
116-
});
117-
11849
describe('Gtag wrapping functions', () => {
11950
afterEach(() => {
12051
removeGtagScripts();
@@ -136,8 +67,9 @@ describe('Gtag wrapping functions', () => {
13667
insertScriptTag(customDataLayerName, fakeMeasurementId);
13768
const scriptTag = findGtagScriptOnPage(customDataLayerName);
13869
expect(scriptTag).to.not.be.null;
139-
expect(scriptTag!.src).to.contain(`l=customDataLayerName`);
140-
expect(scriptTag!.src).to.contain(`id=${fakeMeasurementId}`);
70+
expect(scriptTag!.src).to.equal(
71+
`https://www.googletagmanager.com/gtag/js?l=${customDataLayerName}&id=${fakeMeasurementId}`
72+
);
14173
});
14274

14375
// The test above essentially already touches this functionality but it is still valuable

packages/analytics/src/helpers.ts

+10-50
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,12 @@ import {
2424
import { DynamicConfig, DataLayer, Gtag, MinimalDynamicConfig } from './types';
2525
import { GtagCommand, GTAG_URL } from './constants';
2626
import { logger } from './logger';
27-
import { AnalyticsError, ERROR_FACTORY } from './errors';
27+
import { trustedResourceUrl } from 'safevalues';
28+
import { safeScriptEl } from 'safevalues/dom';
2829

2930
// Possible parameter types for gtag 'event' and 'config' commands
3031
type GtagConfigOrEventParams = ControlParams & EventParams & CustomParams;
3132

32-
/**
33-
* Verifies and creates a TrustedScriptURL.
34-
*/
35-
export function createGtagTrustedTypesScriptURL(url: string): string {
36-
if (!url.startsWith(GTAG_URL)) {
37-
const err = ERROR_FACTORY.create(AnalyticsError.INVALID_GTAG_RESOURCE, {
38-
gtagURL: url
39-
});
40-
logger.warn(err.message);
41-
return '';
42-
}
43-
return url;
44-
}
45-
4633
/**
4734
* Makeshift polyfill for Promise.allSettled(). Resolves when all promises
4835
* have either resolved or rejected.
@@ -55,29 +42,6 @@ export function promiseAllSettled<T>(
5542
return Promise.all(promises.map(promise => promise.catch(e => e)));
5643
}
5744

58-
/**
59-
* Creates a TrustedTypePolicy object that implements the rules passed as policyOptions.
60-
*
61-
* @param policyName A string containing the name of the policy
62-
* @param policyOptions Object containing implementations of instance methods for TrustedTypesPolicy, see {@link https://developer.mozilla.org/en-US/docs/Web/API/TrustedTypePolicy#instance_methods
63-
* | the TrustedTypePolicy reference documentation}.
64-
*/
65-
export function createTrustedTypesPolicy(
66-
policyName: string,
67-
policyOptions: Partial<TrustedTypePolicyOptions>
68-
): Partial<TrustedTypePolicy> | undefined {
69-
// Create a TrustedTypes policy that we can use for updating src
70-
// properties
71-
let trustedTypesPolicy: Partial<TrustedTypePolicy> | undefined;
72-
if (window.trustedTypes) {
73-
trustedTypesPolicy = window.trustedTypes.createPolicy(
74-
policyName,
75-
policyOptions
76-
);
77-
}
78-
return trustedTypesPolicy;
79-
}
80-
8145
/**
8246
* Inserts gtag script tag into the page to asynchronously download gtag.
8347
* @param dataLayerName Name of datalayer (most often the default, "_dataLayer").
@@ -86,21 +50,17 @@ export function insertScriptTag(
8650
dataLayerName: string,
8751
measurementId: string
8852
): void {
89-
const trustedTypesPolicy = createTrustedTypesPolicy(
90-
'firebase-js-sdk-policy',
91-
{
92-
createScriptURL: createGtagTrustedTypesScriptURL
93-
}
94-
);
95-
9653
const script = document.createElement('script');
54+
9755
// We are not providing an analyticsId in the URL because it would trigger a `page_view`
9856
// without fid. We will initialize ga-id using gtag (config) command together with fid.
99-
100-
const gtagScriptURL = `${GTAG_URL}?l=${dataLayerName}&id=${measurementId}`;
101-
(script.src as string | TrustedScriptURL) = trustedTypesPolicy
102-
? (trustedTypesPolicy as TrustedTypePolicy)?.createScriptURL(gtagScriptURL)
103-
: gtagScriptURL;
57+
//
58+
// We also have to ensure the template string before the first expression constitutes a valid URL
59+
// start, as this is what the initial validation focuses on. If the template literal begins
60+
// directly with an expression (e.g. `${GTAG_SCRIPT_URL}`), the validation fails due to an
61+
// empty initial string.
62+
const url = trustedResourceUrl`https://www.googletagmanager.com/gtag/js?l=${dataLayerName}&id=${measurementId}`;
63+
safeScriptEl.setSrc(script, url);
10464

10565
script.async = true;
10666
document.head.appendChild(script);

packages/app-check/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@firebase/util": "1.9.7",
4343
"@firebase/component": "0.6.8",
4444
"@firebase/logger": "0.4.2",
45+
"safevalues": "0.6.0",
4546
"tslib": "^2.1.0"
4647
},
4748
"license": "Apache-2.0",

packages/app-check/src/recaptcha.test.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ import {
3030
initializeV3,
3131
initializeEnterprise,
3232
getToken,
33-
GreCAPTCHATopLevel
33+
GreCAPTCHATopLevel,
34+
RECAPTCHA_ENTERPRISE_URL,
35+
RECAPTCHA_URL
3436
} from './recaptcha';
3537
import * as utils from './util';
3638
import {
@@ -81,7 +83,9 @@ describe('recaptcha', () => {
8183

8284
expect(findgreCAPTCHAScriptsOnPage().length).to.equal(0);
8385
await initializeV3(app, FAKE_SITE_KEY);
84-
expect(findgreCAPTCHAScriptsOnPage().length).to.equal(1);
86+
const greCATPCHAScripts = findgreCAPTCHAScriptsOnPage();
87+
expect(greCATPCHAScripts.length).to.equal(1);
88+
expect(greCATPCHAScripts[0].src).to.equal(RECAPTCHA_URL);
8589
});
8690

8791
it('creates invisible widget', async () => {
@@ -128,7 +132,9 @@ describe('recaptcha', () => {
128132

129133
expect(findgreCAPTCHAScriptsOnPage().length).to.equal(0);
130134
await initializeEnterprise(app, FAKE_SITE_KEY);
131-
expect(findgreCAPTCHAScriptsOnPage().length).to.equal(1);
135+
const greCAPTCHAScripts = findgreCAPTCHAScriptsOnPage();
136+
expect(greCAPTCHAScripts.length).to.equal(1);
137+
expect(greCAPTCHAScripts[0].src).to.equal(RECAPTCHA_ENTERPRISE_URL);
132138
});
133139

134140
it('creates invisible widget', async () => {

packages/app-check/src/recaptcha.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ import { FirebaseApp } from '@firebase/app';
1919
import { getStateReference } from './state';
2020
import { Deferred } from '@firebase/util';
2121
import { getRecaptcha, ensureActivated } from './util';
22+
import { trustedResourceUrl } from 'safevalues';
23+
import { safeScriptEl } from 'safevalues/dom';
2224

25+
// Note that these are used for testing. If they are changed, the URLs used in loadReCAPTCHAV3Script
26+
// and loadReCAPTCHAEnterpriseScript must also be changed. They aren't used to create the URLs
27+
// since trusted resource URLs must be created using template string literals.
2328
export const RECAPTCHA_URL = 'https://www.google.com/recaptcha/api.js';
2429
export const RECAPTCHA_ENTERPRISE_URL =
2530
'https://www.google.com/recaptcha/enterprise.js';
@@ -166,14 +171,20 @@ function renderInvisibleWidget(
166171

167172
function loadReCAPTCHAV3Script(onload: () => void): void {
168173
const script = document.createElement('script');
169-
script.src = RECAPTCHA_URL;
174+
safeScriptEl.setSrc(
175+
script,
176+
trustedResourceUrl`https://www.google.com/recaptcha/api.js`
177+
);
170178
script.onload = onload;
171179
document.head.appendChild(script);
172180
}
173181

174182
function loadReCAPTCHAEnterpriseScript(onload: () => void): void {
175183
const script = document.createElement('script');
176-
script.src = RECAPTCHA_ENTERPRISE_URL;
184+
safeScriptEl.setSrc(
185+
script,
186+
trustedResourceUrl`https://www.google.com/recaptcha/enterprise.js`
187+
);
177188
script.onload = onload;
178189
document.head.appendChild(script);
179190
}

packages/auth/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@
131131
"@firebase/logger": "0.4.2",
132132
"@firebase/util": "1.9.7",
133133
"undici": "5.28.4",
134-
"tslib": "^2.1.0"
134+
"tslib": "^2.1.0",
135+
"safevalues": "0.6.0"
135136
},
136137
"license": "Apache-2.0",
137138
"devDependencies": {

packages/auth/src/platform_browser/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ _setExternalJSProvider({
124124
// TODO: consider adding timeout support & cancellation
125125
return new Promise((resolve, reject) => {
126126
const el = document.createElement('script');
127+
// TODO: Do not use setAttribute, since it can lead to XSS. Instead, use the safevalues library to
128+
// safely set an attribute for a sanitized trustedResourceUrl. Since the trustedResourceUrl
129+
// must be initialized from a template string literal, this could involve some heavy
130+
// refactoring.
127131
el.setAttribute('src', url);
128132
el.onload = resolve;
129133
el.onerror = e => {

packages/auth/src/platform_browser/load_js.test.ts

+4
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ describe('platform-browser/load_js', () => {
4444
loadJS(url: string): Promise<Event> {
4545
return new Promise((resolve, reject) => {
4646
const el = document.createElement('script');
47+
// TODO: Do not use setAttribute, as this can lead to XSS. Instead, use the safevalues
48+
// library, or get an exception for tests.
4749
el.setAttribute('src', url);
4850
el.onload = resolve;
4951
el.onerror = e => {
@@ -65,6 +67,8 @@ describe('platform-browser/load_js', () => {
6567

6668
// eslint-disable-next-line @typescript-eslint/no-floating-promises
6769
_loadJS('http://localhost/url');
70+
// TODO: Do not use setAttribute, as this can lead to XSS. Instead, use the safevalues
71+
// library, or get an exception for tests.
6872
expect(el.setAttribute).to.have.been.calledWith(
6973
'src',
7074
'http://localhost/url'

packages/auth/tsconfig.json

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
{
22
"extends": "../../config/tsconfig.base.json",
33
"compilerOptions": {
4-
"outDir": "dist"
4+
"outDir": "dist",
5+
"plugins": [
6+
{
7+
"name": "tsec",
8+
"reportTsecDiagnosticsOnly": true,
9+
}
10+
]
511
},
612
"exclude": [
713
"dist/**/*",
814
"demo/**/*"
915
]
10-
}
16+
}

packages/database-compat/tsconfig.json

+8-2
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@
33
"compilerOptions": {
44
"outDir": "dist",
55
"strict": false,
6-
"downlevelIteration": true
6+
"downlevelIteration": true,
7+
"plugins": [
8+
{
9+
"name": "tsec",
10+
"reportTsecDiagnosticsOnly": true,
11+
}
12+
]
713
},
814
"exclude": [
915
"dist/**/*"
1016
]
11-
}
17+
}

packages/database/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@firebase/app-check-interop-types": "0.3.2",
5757
"@firebase/auth-interop-types": "0.2.3",
5858
"faye-websocket": "0.11.4",
59+
"safevalues": "0.6.0",
5960
"tslib": "^2.1.0"
6061
},
6162
"devDependencies": {

packages/database/src/realtime/BrowserPollConnection.ts

+6
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,8 @@ export class FirebaseIFrameScriptHolder {
475475
const iframeContents = '<html><body>' + script + '</body></html>';
476476
try {
477477
this.myIFrame.doc.open();
478+
// TODO: Do not use document.write, since it can lead to XSS. Instead, use the safevalues
479+
// library to sanitize the HTML in the iframeContents.
478480
this.myIFrame.doc.write(iframeContents);
479481
this.myIFrame.doc.close();
480482
} catch (e) {
@@ -717,6 +719,10 @@ export class FirebaseIFrameScriptHolder {
717719
const newScript = this.myIFrame.doc.createElement('script');
718720
newScript.type = 'text/javascript';
719721
newScript.async = true;
722+
// TODO: We cannot assign an arbitrary URL to a script attached to the DOM, since it is
723+
// at risk of XSS. We should use the safevalues library to create a safeScriptEl, and
724+
// assign a sanitized trustedResourceURL to it. Since the URL must be a template string
725+
// literal, this could require some heavy refactoring.
720726
newScript.src = url;
721727
// eslint-disable-next-line @typescript-eslint/no-explicit-any
722728
newScript.onload = (newScript as any).onreadystatechange =

0 commit comments

Comments
 (0)