diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 97db8c64..f407ad23 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -37,6 +37,11 @@ const STS_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'; const STS_REQUEST_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; /** The default OAuth scope to request when none is provided. */ const DEFAULT_OAUTH_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; +/** The google apis domain pattern. */ +const GOOGLE_APIS_DOMAIN_PATTERN = '\\.googleapis\\.com$'; +/** The variable portion pattern in a Google APIs domain. */ +const VARIABLE_PORTION_PATTERN = '[^\\.\\s\\/\\\\]+'; + /** * Offset to take into account network delays and server clock skews. */ @@ -154,6 +159,9 @@ export abstract class BaseExternalAccountClient extends AuthClient { clientSecret: options.client_secret, } as ClientAuthentication) : undefined; + if (!this.validateGoogleAPIsUrl('sts', options.token_url)) { + throw new Error(`"${options.token_url}" is not a valid token url.`); + } this.stsCredential = new sts.StsCredentials(options.token_url, clientAuth); // Default OAuth scope. This could be overridden via public property. this.scopes = [DEFAULT_OAUTH_SCOPE]; @@ -161,6 +169,18 @@ export abstract class BaseExternalAccountClient extends AuthClient { this.audience = options.audience; this.subjectTokenType = options.subject_token_type; this.quotaProjectId = options.quota_project_id; + if ( + typeof options.service_account_impersonation_url !== 'undefined' && + !this.validateGoogleAPIsUrl( + 'iamcredentials', + options.service_account_impersonation_url + ) + ) { + throw new Error( + `"${options.service_account_impersonation_url}" is ` + + 'not a valid service account impersonation url.' + ); + } this.serviceAccountImpersonationUrl = options.service_account_impersonation_url; // As threshold could be zero, @@ -501,4 +521,57 @@ export abstract class BaseExternalAccountClient extends AuthClient { return this.scopes; } } + + /** + * Checks whether Google APIs URL is valid. + * @param apiName The apiName of url. + * @param url The Google API URL to validate. + * @return Whether the URL is valid or not. + */ + private validateGoogleAPIsUrl(apiName: string, url: string): boolean { + let parsedUrl; + // Return false if error is thrown during parsing URL. + try { + parsedUrl = new URL(url); + } catch (e) { + return false; + } + + const urlDomain = parsedUrl.hostname; + // Check the protocol is https. + if (parsedUrl.protocol !== 'https:') { + return false; + } + + const googleAPIsDomainPatterns: RegExp[] = [ + new RegExp( + '^' + + VARIABLE_PORTION_PATTERN + + '\\.' + + apiName + + GOOGLE_APIS_DOMAIN_PATTERN + ), + new RegExp('^' + apiName + GOOGLE_APIS_DOMAIN_PATTERN), + new RegExp( + '^' + + apiName + + '\\.' + + VARIABLE_PORTION_PATTERN + + GOOGLE_APIS_DOMAIN_PATTERN + ), + new RegExp( + '^' + + VARIABLE_PORTION_PATTERN + + '\\-' + + apiName + + GOOGLE_APIS_DOMAIN_PATTERN + ), + ]; + for (const googleAPIsDomainPattern of googleAPIsDomainPatterns) { + if (urlDomain.match(googleAPIsDomainPattern)) { + return true; + } + } + return false; + } } diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index e6baf436..9961529a 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -136,6 +136,115 @@ describe('BaseExternalAccountClient', () => { }, expectedError); }); + const invalidTokenUrls = [ + 'http://sts.googleapis.com', + 'https://', + 'https://sts.google.com', + 'https://sts.googleapis.net', + 'https://sts.googleapis.comevil.com', + 'https://sts.googleapis.com.evil.com', + 'https://sts.googleapis.com.evil.com/path/to/example', + 'https://sts..googleapis.com', + 'https://-sts.googleapis.com', + 'https://evilsts.googleapis.com', + 'https://us.east.1.sts.googleapis.com', + 'https://us east 1.sts.googleapis.com', + 'https://us-east- 1.sts.googleapis.com', + 'https://us/.east/.1.sts.googleapis.com', + 'https://us.ea\\st.1.sts.googleapis.com', + ]; + invalidTokenUrls.forEach(invalidTokenUrl => { + it(`should throw on invalid token url: ${invalidTokenUrl}`, () => { + const invalidOptions = Object.assign({}, externalAccountOptions); + invalidOptions.token_url = invalidTokenUrl; + const expectedError = new Error( + `"${invalidTokenUrl}" is not a valid token url.` + ); + assert.throws(() => { + return new TestExternalAccountClient(invalidOptions); + }, expectedError); + }); + }); + + it('should not throw on valid token urls', () => { + const validTokenUrls = [ + 'https://sts.googleapis.com', + 'https://sts.us-west-1.googleapis.com', + 'https://sts.google.googleapis.com', + 'https://sts.googleapis.com/path/to/example', + 'https://us-west-1.sts.googleapis.com', + 'https://us-west-1-sts.googleapis.com', + 'https://exmaple.sts.googleapis.com', + 'https://example-sts.googleapis.com', + ]; + const validOptions = Object.assign({}, externalAccountOptions); + for (const validTokenUrl of validTokenUrls) { + validOptions.token_url = validTokenUrl; + assert.doesNotThrow(() => { + return new TestExternalAccountClient(validOptions); + }); + } + }); + + const invalidServiceAccountImpersonationUrls = [ + 'http://iamcredentials.googleapis.com', + 'https://', + 'https://iamcredentials.google.com', + 'https://iamcredentials.googleapis.net', + 'https://iamcredentials.googleapis.comevil.com', + 'https://iamcredentials.googleapis.com.evil.com', + 'https://iamcredentials.googleapis.com.evil.com/path/to/example', + 'https://iamcredentials..googleapis.com', + 'https://-iamcredentials.googleapis.com', + 'https://eviliamcredentials.googleapis.com', + 'https://evil.eviliamcredentials.googleapis.com', + 'https://us.east.1.iamcredentials.googleapis.com', + 'https://us east 1.iamcredentials.googleapis.com', + 'https://us-east- 1.iamcredentials.googleapis.com', + 'https://us/.east/.1.iamcredentials.googleapis.com', + 'https://us.ea\\st.1.iamcredentials.googleapis.com', + ]; + invalidServiceAccountImpersonationUrls.forEach( + invalidServiceAccountImpersonationUrl => { + it(`should throw on invalid service account impersonation url: ${invalidServiceAccountImpersonationUrl}`, () => { + const invalidOptions = Object.assign( + {}, + externalAccountOptionsWithSA + ); + invalidOptions.service_account_impersonation_url = + invalidServiceAccountImpersonationUrl; + const expectedError = new Error( + `"${invalidServiceAccountImpersonationUrl}" is ` + + 'not a valid service account impersonation url.' + ); + assert.throws(() => { + return new TestExternalAccountClient(invalidOptions); + }, expectedError); + }); + } + ); + + it('should not throw on valid service account impersonation url', () => { + const validServiceAccountImpersonationUrls = [ + 'https://iamcredentials.googleapis.com', + 'https://iamcredentials.us-west-1.googleapis.com', + 'https://iamcredentials.google.googleapis.com', + 'https://iamcredentials.googleapis.com/path/to/example', + 'https://us-west-1.iamcredentials.googleapis.com', + 'https://us-west-1-iamcredentials.googleapis.com', + 'https://example.iamcredentials.googleapis.com', + 'https://example-iamcredentials.googleapis.com', + ]; + const validOptions = Object.assign({}, externalAccountOptionsWithSA); + for (const validServiceAccountImpersonationUrl of validServiceAccountImpersonationUrls) { + validOptions.service_account_impersonation_url = + validServiceAccountImpersonationUrl; + assert.doesNotThrow(() => { + return new TestExternalAccountClient(validOptions); + }); + } + }); + it('should not throw on valid options', () => { assert.doesNotThrow(() => { return new TestExternalAccountClient(externalAccountOptions);