From 965e9bda6f6f8aa5031061e45432f95d6634e481 Mon Sep 17 00:00:00 2001 From: akiliscodes Date: Fri, 11 Sep 2026 16:03:12 +0000 Subject: [PATCH] fix(common): preserve DEFAULT_PROJECT_ID_TOKEN in joinURIComponents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encodeURIPath encodes '{{projectId}}' as '%7B%7BprojectId%7D%7D', which prevents replaceProjectIdToken() from finding and substituting the placeholder later in the request lifecycle (decorateRequest → makeAuthenticatedRequest). Skip encoding when the trimmed component equals DEFAULT_PROJECT_ID_TOKEN so that ADC / lazy project-ID resolution continues to work for clients that do not supply an explicit projectId (e.g. new BigQuery()). Regression introduced by #9188. Fixes #9256. --- core/common/src/util.ts | 10 ++++++++-- core/common/test/service.ts | 15 +++++++++++++++ core/common/test/util.ts | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/core/common/src/util.ts b/core/common/src/util.ts index ef734faaa50c..8581eb4f7e41 100644 --- a/core/common/src/util.ts +++ b/core/common/src/util.ts @@ -1158,14 +1158,20 @@ export function encodeAbsoluteURI(uri: string): string { * Trims slashes, encodes path segments to prevent path traversal, and joins * URI components into a single relative path. * + * DEFAULT_PROJECT_ID_TOKEN is an internal constant set by the library, never + * user input, so bypassing encodeURIPath for it does not open a traversal + * path. Encoding it would produce '%7B%7BprojectId%7D%7D' in the URI, which + * replaceProjectIdToken() cannot match — causing 404s for clients that rely + * on lazy / ADC project-ID resolution (regression introduced by #9188). + * * @param {string[]} components - URI components to encode and join. * @return {string} The formatted and joined URI path. */ export function joinURIComponents(components: string[]): string { return components .map(uriComponent => { - const trimSlashesRegex = /^\/*|\/*$/g; - const trimmed = uriComponent.replace(trimSlashesRegex, ''); + const trimmed = uriComponent.replace(/^\/*|\/*$/g, ''); + if (trimmed === DEFAULT_PROJECT_ID_TOKEN) return trimmed; return encodeURIPath(trimmed); // Encode and prevent path traversal. }) .join('/'); diff --git a/core/common/test/service.ts b/core/common/test/service.ts index 768e7db4e4a6..57e1975fb9df 100644 --- a/core/common/test/service.ts +++ b/core/common/test/service.ts @@ -567,6 +567,21 @@ describe('Service', () => { service.request_(reqOpts, assert.ifError); }); + it('should pass DEFAULT_PROJECT_ID_TOKEN unencoded to makeAuthenticatedRequest', done => { + // Regression: #9188 caused {{projectId}} to be encoded as + // %7B%7BprojectId%7D%7D, so replaceProjectIdToken() could no longer + // find and substitute it, causing 404s for ADC clients. + const config = extend({}, CONFIG, {projectIdRequired: true}); + const service = new Service(config, {}); // no projectId → DEFAULT_PROJECT_ID_TOKEN + + service.makeAuthenticatedRequest = (reqOpts_: DecorateRequestOptions) => { + assert.match(reqOpts_.uri, /projects\/\{\{projectId\}\}\//); + done(); + }; + + service.request_({uri: 'queries'}, assert.ifError); + }); + it('should use projectId override', done => { const config = extend({}, CONFIG, {projectIdRequired: true}); const service = new Service(config, OPTIONS); diff --git a/core/common/test/util.ts b/core/common/test/util.ts index 6c018afd4d3d..a8b4e8e917a5 100644 --- a/core/common/test/util.ts +++ b/core/common/test/util.ts @@ -46,6 +46,7 @@ import { ParsedHttpRespMessage, ParsedHttpResponseBody, Util, + joinURIComponents, } from '../src/util'; import {DEFAULT_PROJECT_ID_TOKEN} from '../src/service'; @@ -1922,4 +1923,40 @@ describe('common/util', () => { assert.strictEqual(cb, callback); }); }); + + describe('joinURIComponents', () => { + it('should preserve DEFAULT_PROJECT_ID_TOKEN so replaceProjectIdToken can substitute it', () => { + // Regression: #9188 caused {{projectId}} to be encoded as %7B%7BprojectId%7D%7D, + // breaking ADC / lazy project-ID resolution. See #9256. + const joined = joinURIComponents([ + 'https://bigquery.googleapis.com/bigquery/v2', + 'projects', + DEFAULT_PROJECT_ID_TOKEN, + 'queries', + ]); + const substituted = replaceProjectIdToken(joined, 'my-project'); + assert.strictEqual( + substituted, + 'https://bigquery.googleapis.com/bigquery/v2/projects/my-project/queries', + ); + }); + + it('should still reject path traversal in non-token components', () => { + assert.throws(() => { + joinURIComponents(['https://example.com/v1', '{{../../admin}}', 'child']); + }); + }); + + it('should encode special characters in non-token path segments', () => { + const result = joinURIComponents([ + 'https://example.com/v1', + 'resource with spaces', + 'child', + ]); + assert.strictEqual( + result, + 'https://example.com/v1/resource%20with%20spaces/child', + ); + }); + }); });