Skip to content

Commit 91745f0

Browse files
committed
Add origin validation to popup/redirect flows (#3730)
* Add origin validation to the popup/redirect flows * Formatting * Cache the origin validation promise * Formatting * PR feedback * Formatting
1 parent 98977e8 commit 91745f0

File tree

10 files changed

+375
-6
lines changed

10 files changed

+375
-6
lines changed

packages-exp/auth-exp/demo/public/service-worker.js.map

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages-exp/auth-exp/demo/public/web-worker.js.map

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages-exp/auth-exp/demo/src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,6 +1260,7 @@ function signInWithPopupRedirect(provider) {
12601260
type === 'popup' ? reauthenticateWithPopup : reauthenticateWithRedirect;
12611261
break;
12621262
default:
1263+
inst = auth;
12631264
method = type === 'popup' ? signInWithPopup : signInWithRedirect;
12641265
}
12651266

packages-exp/auth-exp/src/api/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export enum Endpoint {
6060
FINALIZE_PHONE_MFA_ENROLLMENT = '/v2/accounts/mfaEnrollment:finalize',
6161
START_PHONE_MFA_SIGN_IN = '/v2/accounts/mfaSignIn:start',
6262
FINALIZE_PHONE_MFA_SIGN_IN = '/v2/accounts/mfaSignIn:finalize',
63-
WITHDRAW_MFA = '/v2/accounts/mfaEnrollment:withdraw'
63+
WITHDRAW_MFA = '/v2/accounts/mfaEnrollment:withdraw',
64+
GET_PROJECT_CONFIG = '/v1/projects'
6465
}
6566

6667
export const DEFAULT_API_TIMEOUT_MS = new Delay(30_000, 60_000);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @license
3+
* Copyright 2020 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { expect, use } from 'chai';
19+
import * as chaiAsPromised from 'chai-as-promised';
20+
21+
import { FirebaseError } from '@firebase/util';
22+
23+
import { Endpoint, HttpHeader } from '../';
24+
import { mockEndpoint } from '../../../test/helpers/api/helper';
25+
import { testAuth, TestAuth } from '../../../test/helpers/mock_auth';
26+
import * as mockFetch from '../../../test/helpers/mock_fetch';
27+
import { ServerError } from '../errors';
28+
import { _getProjectConfig } from './get_project_config';
29+
30+
use(chaiAsPromised);
31+
32+
describe('api/project_config/getProjectConfig', () => {
33+
let auth: TestAuth;
34+
35+
beforeEach(async () => {
36+
auth = await testAuth();
37+
mockFetch.setUp();
38+
});
39+
40+
afterEach(mockFetch.tearDown);
41+
42+
it('should POST to the correct endpoint', async () => {
43+
const mock = mockEndpoint(Endpoint.GET_PROJECT_CONFIG, {
44+
authorizedDomains: ['google.com']
45+
});
46+
47+
const response = await _getProjectConfig(auth);
48+
expect(response.authorizedDomains).to.eql(['google.com']);
49+
expect(mock.calls[0].method).to.eq('GET');
50+
expect(mock.calls[0].headers!.get(HttpHeader.X_CLIENT_VERSION)).to.eq(
51+
'testSDK/0.0.0'
52+
);
53+
});
54+
55+
it('should handle errors', async () => {
56+
mockEndpoint(
57+
Endpoint.GET_PROJECT_CONFIG,
58+
{
59+
error: {
60+
code: 400,
61+
message: ServerError.INVALID_PROVIDER_ID,
62+
errors: [
63+
{
64+
message: ServerError.INVALID_PROVIDER_ID
65+
}
66+
]
67+
}
68+
},
69+
400
70+
);
71+
72+
await expect(_getProjectConfig(auth)).to.be.rejectedWith(
73+
FirebaseError,
74+
'Firebase: The specified provider ID is invalid. (auth/invalid-provider-id).'
75+
);
76+
});
77+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* @license
3+
* Copyright 2020 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { _performApiRequest, Endpoint, HttpMethod } from '../';
19+
import { AuthCore } from '../../model/auth';
20+
21+
export interface GetProjectConfigRequest {}
22+
23+
export interface GetProjectConfigResponse {
24+
authorizedDomains: string[];
25+
}
26+
27+
export async function _getProjectConfig(
28+
auth: AuthCore
29+
): Promise<GetProjectConfigResponse> {
30+
return _performApiRequest<GetProjectConfigRequest, GetProjectConfigResponse>(
31+
auth,
32+
HttpMethod.GET,
33+
Endpoint.GET_PROJECT_CONFIG,
34+
{}
35+
);
36+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* @license
3+
* Copyright 2020 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { expect, use } from 'chai';
19+
import * as chaiAsPromised from 'chai-as-promised';
20+
import * as sinon from 'sinon';
21+
22+
import { FirebaseError } from '@firebase/util';
23+
24+
import { mockEndpoint } from '../../../test/helpers/api/helper';
25+
import { testAuth } from '../../../test/helpers/mock_auth';
26+
import * as fetch from '../../../test/helpers/mock_fetch';
27+
import { Endpoint } from '../../api';
28+
import { Auth } from '../../model/auth';
29+
import * as location from './location';
30+
import { _validateOrigin } from './validate_origin';
31+
32+
use(chaiAsPromised);
33+
34+
describe('core/util/validate_origin', () => {
35+
let auth: Auth;
36+
let authorizedDomains: string[];
37+
let currentUrl: string;
38+
beforeEach(async () => {
39+
authorizedDomains = [];
40+
currentUrl = '';
41+
42+
auth = await testAuth();
43+
fetch.setUp();
44+
mockEndpoint(Endpoint.GET_PROJECT_CONFIG, {
45+
get authorizedDomains(): string[] {
46+
return authorizedDomains;
47+
}
48+
});
49+
50+
sinon.stub(location, '_getCurrentUrl').callsFake(() => currentUrl);
51+
});
52+
53+
afterEach(() => {
54+
fetch.tearDown();
55+
sinon.restore();
56+
});
57+
58+
it('smoke test', async () => {
59+
currentUrl = 'https://google.com';
60+
authorizedDomains = ['google.com'];
61+
await expect(_validateOrigin(auth)).to.be.fulfilled;
62+
});
63+
64+
it('failure smoke test', async () => {
65+
currentUrl = 'https://google.com';
66+
authorizedDomains = ['youtube.com'];
67+
await expect(_validateOrigin(auth)).to.be.rejectedWith(
68+
FirebaseError,
69+
'auth/unauthorized-domain'
70+
);
71+
});
72+
73+
it('works when one domain matches', async () => {
74+
currentUrl = 'https://google.com';
75+
authorizedDomains = ['youtube.com', 'google.com'];
76+
await expect(_validateOrigin(auth)).to.be.fulfilled;
77+
});
78+
79+
it('fails when all domains fail', async () => {
80+
currentUrl = 'https://google.com';
81+
authorizedDomains = ['youtube.com', 'firebase.com'];
82+
await expect(_validateOrigin(auth)).to.be.rejectedWith(
83+
FirebaseError,
84+
'auth/unauthorized-domain'
85+
);
86+
});
87+
88+
it('works for chrome extensions', async () => {
89+
currentUrl = 'chrome-extension://somereallylongcomplexstring';
90+
authorizedDomains = [
91+
'google.com',
92+
'chrome-extension://somereallylongcomplexstring'
93+
];
94+
await expect(_validateOrigin(auth)).to.be.fulfilled;
95+
});
96+
97+
it('fails for wrong chrome extensions', async () => {
98+
currentUrl = 'chrome-extension://somereallylongcomplexstring';
99+
authorizedDomains = [
100+
'google.com',
101+
'chrome-extension://someOTHERreallylongcomplexstring'
102+
];
103+
await expect(_validateOrigin(auth)).to.be.rejectedWith(
104+
FirebaseError,
105+
'auth/unauthorized-domain'
106+
);
107+
});
108+
109+
it('works for subdomains', async () => {
110+
currentUrl = 'http://firebase.google.com';
111+
authorizedDomains = ['google.com'];
112+
await expect(_validateOrigin(auth)).to.be.fulfilled;
113+
});
114+
115+
it('works for deeply-linked pages', async () => {
116+
currentUrl = 'http://firebase.google.com/a/b/c/d/e/f/g.html';
117+
authorizedDomains = ['google.com'];
118+
await expect(_validateOrigin(auth)).to.be.fulfilled;
119+
});
120+
121+
it('works with IP addresses', async () => {
122+
currentUrl = 'http://192.168.0.1/a/b/c';
123+
authorizedDomains = ['192.168.0.1'];
124+
await expect(_validateOrigin(auth)).to.be.fulfilled;
125+
});
126+
127+
it('fails with different IP addresses', async () => {
128+
currentUrl = 'http://192.168.0.100/a/b/c';
129+
authorizedDomains = ['192.168.0.1'];
130+
await expect(_validateOrigin(auth)).to.be.rejectedWith(
131+
FirebaseError,
132+
'auth/unauthorized-domain'
133+
);
134+
});
135+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* @license
3+
* Copyright 2020 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { _getProjectConfig } from '../../api/project_config/get_project_config';
19+
import { Auth } from '../../model/auth';
20+
import { AuthErrorCode } from '../errors';
21+
import { fail } from './assert';
22+
import { _getCurrentUrl } from './location';
23+
24+
const IP_ADDRESS_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
25+
const HTTP_REGEX = /^https?/;
26+
27+
export async function _validateOrigin(auth: Auth): Promise<void> {
28+
const { authorizedDomains } = await _getProjectConfig(auth);
29+
30+
for (const domain of authorizedDomains) {
31+
try {
32+
if (matchDomain(domain)) {
33+
return;
34+
}
35+
} catch {
36+
// Do nothing if there's a URL error; just continue searching
37+
}
38+
}
39+
40+
// In the old SDK, this error also provides helpful messages.
41+
fail(AuthErrorCode.INVALID_ORIGIN, { appName: auth.name });
42+
}
43+
44+
function matchDomain(expected: string): boolean {
45+
const currentUrl = _getCurrentUrl();
46+
const { protocol, hostname } = new URL(currentUrl);
47+
if (expected.startsWith('chrome-extension://')) {
48+
const ceUrl = new URL(expected);
49+
50+
if (ceUrl.hostname === '' && hostname === '') {
51+
// For some reason we're not parsing chrome URLs properly
52+
return (
53+
protocol === 'chrome-extension:' &&
54+
expected.replace('chrome-extension://', '') ===
55+
currentUrl.replace('chrome-extension://', '')
56+
);
57+
}
58+
59+
return protocol === 'chrome-extension:' && ceUrl.hostname === hostname;
60+
}
61+
62+
if (!HTTP_REGEX.test(protocol)) {
63+
return false;
64+
}
65+
66+
if (IP_ADDRESS_REGEX.test(expected)) {
67+
// The domain has to be exactly equal to the pattern, as an IP domain will
68+
// only contain the IP, no extra character.
69+
return hostname === expected;
70+
}
71+
72+
// Dots in pattern should be escaped.
73+
const escapedDomainPattern = expected.replace(/\./g, '\\.');
74+
// Non ip address domains.
75+
// domain.com = *.domain.com OR domain.com
76+
const re = new RegExp(
77+
'^(.+\\.' + escapedDomainPattern + '|' + escapedDomainPattern + ')$',
78+
'i'
79+
);
80+
return re.test(hostname);
81+
}

0 commit comments

Comments
 (0)