From b9dc2a49e66f048d8919fad06a3e56bfef0fd8ab Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:04:54 +0000 Subject: [PATCH 01/21] fix(common): encode REST URI path variables and prevent path traversal --- core/common/src/service-object.ts | 26 +++++++++----- core/common/src/service.ts | 32 ++++++++++------- core/common/src/util.ts | 49 ++++++++++++++++++++++++++ core/common/test/util.ts | 52 ++++++++++++++++++++++++++++ handwritten/bigquery/test/dataset.ts | 27 +++++++++++++++ 5 files changed, 165 insertions(+), 21 deletions(-) diff --git a/core/common/src/service-object.ts b/core/common/src/service-object.ts index 798193813f76..7874d819bc13 100644 --- a/core/common/src/service-object.ts +++ b/core/common/src/service-object.ts @@ -563,17 +563,25 @@ class ServiceObject extends EventEmitter { const uriComponents = [this.baseUrl, this.id || '', reqOpts.uri]; if (isAbsoluteUrl) { - uriComponents.splice(0, uriComponents.indexOf(reqOpts.uri)); + const url = new URL(reqOpts.uri); + const encodedPath = util.encodeURIPath(url.pathname); + url.pathname = encodedPath; + let res = url.toString(); + if (!reqOpts.uri.endsWith('/') && res.endsWith('/')) { + res = res.slice(0, -1); + } + reqOpts.uri = res; + } else { + reqOpts.uri = uriComponents + .filter(x => x!.trim()) // Limit to non-empty strings. + .map(uriComponent => { + const trimSlashesRegex = /^\/*|\/*$/g; + const trimmed = uriComponent!.replace(trimSlashesRegex, ''); + return util.encodeURIPath(trimmed); + }) + .join('/'); } - reqOpts.uri = uriComponents - .filter(x => x!.trim()) // Limit to non-empty strings. - .map(uriComponent => { - const trimSlashesRegex = /^\/*|\/*$/g; - return uriComponent!.replace(trimSlashesRegex, ''); - }) - .join('/'); - const childInterceptors = (arrify as unknown as (arg1: any) => [])( reqOpts.interceptors_!, ); diff --git a/core/common/src/service.ts b/core/common/src/service.ts index d0a179242467..a07a18a33376 100644 --- a/core/common/src/service.ts +++ b/core/common/src/service.ts @@ -212,20 +212,28 @@ export class Service { uriComponents.push(reqOpts.uri); if (isAbsoluteUrl) { - uriComponents.splice(0, uriComponents.indexOf(reqOpts.uri)); + const url = new URL(reqOpts.uri); + const encodedPath = util.encodeURIPath(url.pathname); + url.pathname = encodedPath; + let res = url.toString(); + if (!reqOpts.uri.endsWith('/') && res.endsWith('/')) { + res = res.slice(0, -1); + } + reqOpts.uri = res; + } else { + reqOpts.uri = uriComponents + .map(uriComponent => { + const trimSlashesRegex = /^\/*|\/*$/g; + const trimmed = uriComponent.replace(trimSlashesRegex, ''); + return util.encodeURIPath(trimmed); + }) + .join('/') + // Some URIs have colon separators. + // Bad: https://.../projects/:list + // Good: https://.../projects:list + .replace(/\/:/g, ':'); } - reqOpts.uri = uriComponents - .map(uriComponent => { - const trimSlashesRegex = /^\/*|\/*$/g; - return uriComponent.replace(trimSlashesRegex, ''); - }) - .join('/') - // Some URIs have colon separators. - // Bad: https://.../projects/:list - // Good: https://.../projects:list - .replace(/\/:/g, ':'); - const requestInterceptors = this.getRequestInterceptors(); (arrify as unknown as (arg1: any) => any[])(reqOpts.interceptors_!).forEach( diff --git a/core/common/src/util.ts b/core/common/src/util.ts index 322e6cfee37a..c36d0858e458 100644 --- a/core/common/src/util.ts +++ b/core/common/src/util.ts @@ -913,6 +913,10 @@ export class Util { return dup; } + encodeWithSlashes = encodeWithSlashes; + encodeWithoutSlashes = encodeWithoutSlashes; + encodeURIPath = encodeURIPath; + /** * Decorate the options about to be made in a request. * @@ -1024,5 +1028,50 @@ class ProgressStream extends Transform { } } +export function encodeWithSlashes(str: string, propertyName = 'resource ID field'): string { + const segments = str.split('/'); + for (const segment of segments) { + if (segment === '.' || segment === '..') { + throw new Error( + `Value for ${propertyName} must not contain segments that are exactly . or .. .`, + ); + } + } + return encodeURIComponent(str) + .replace(/%2F/gi, '/') + .replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); +} + +export function encodeWithoutSlashes(str: string, propertyName = 'resource ID field'): string { + if (str === '.' || str === '..') { + throw new Error(`Invalid value ${str} for ${propertyName}.`); + } + return encodeURIComponent(str) + .replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); +} + +export function encodeURIPath(uri: string): string { + const parts = uri.split('/'); + return parts + .map(part => { + if (part === '') { + return ''; + } + if (part.includes(':')) { + const subparts = part.split(':'); + return subparts + .map(subpart => { + if (subpart === '') { + return ''; + } + return encodeWithoutSlashes(subpart, 'path segment'); + }) + .join(':'); + } + return encodeWithoutSlashes(part, 'path segment'); + }) + .join('/'); +} + const util = new Util(); export {util}; diff --git a/core/common/test/util.ts b/core/common/test/util.ts index 6c018afd4d3d..3348b13448a9 100644 --- a/core/common/test/util.ts +++ b/core/common/test/util.ts @@ -1903,6 +1903,58 @@ describe('common/util', () => { }); }); + describe('encodeWithSlashes & encodeWithoutSlashes', () => { + it('encodeWithSlashes should allow valid path segments and encode special characters', () => { + assert.strictEqual( + util.encodeWithSlashes('foo/bar-123_~.baz'), + 'foo/bar-123_~.baz', + ); + assert.strictEqual( + util.encodeWithSlashes('foo/bar baz'), + 'foo/bar%20baz', + ); + }); + + it('encodeWithSlashes should throw if any segment is . or ..', () => { + assert.throws(() => { + util.encodeWithSlashes('foo/./bar', 'testField'); + }, /Value for testField must not contain segments that are exactly \. or \.\. \./); + + assert.throws(() => { + util.encodeWithSlashes('foo/../bar', 'testField'); + }, /Value for testField must not contain segments that are exactly \. or \.\. \./); + }); + + it('encodeWithoutSlashes should allow valid characters and encode slashes and special characters', () => { + assert.strictEqual( + util.encodeWithoutSlashes('foo-123_~.baz'), + 'foo-123_~.baz', + ); + assert.strictEqual( + util.encodeWithoutSlashes('foo/bar'), + 'foo%2Fbar', + ); + assert.strictEqual( + util.encodeWithoutSlashes('photo_😀.png'), + 'photo_%F0%9F%98%80.png', + ); + assert.strictEqual( + util.encodeWithoutSlashes('test*file!'), + 'test%2Afile%21', + ); + }); + + it('encodeWithoutSlashes should throw if the value is . or ..', () => { + assert.throws(() => { + util.encodeWithoutSlashes('.', 'testField'); + }, /Invalid value \. for testField\./); + + assert.throws(() => { + util.encodeWithoutSlashes('..', 'testField'); + }, /Invalid value \.\. for testField\./); + }); + }); + describe('maybeOptionsOrCallback', () => { it('should allow passing just a callback', () => { const optionsOrCallback = () => {}; diff --git a/handwritten/bigquery/test/dataset.ts b/handwritten/bigquery/test/dataset.ts index 26bb3ff8a831..2d1435d6032b 100644 --- a/handwritten/bigquery/test/dataset.ts +++ b/handwritten/bigquery/test/dataset.ts @@ -1104,4 +1104,31 @@ describe('BigQuery/Dataset', () => { assert.strictEqual(table.location, location); }); }); + + describe('security - URI encoding and path traversal protection', () => { + it('should throw error when dataset id or path segment is dot or dot-dot', () => { + const bigqueryMock = { + projectId: 'my-project', + request: util.noop, + } as {} as _root.BigQuery; + + assert.throws(() => { + const invalidDataset = new Dataset(bigqueryMock, '..'); + invalidDataset.getMetadata(assert.ifError); + }, /Invalid value \.\. for path segment\./); + }); + + it('should percent-encode query parameter injection payload in table name', done => { + const maliciousTableId = 'table_name?$httpMethod=DELETE#'; + const table = ds.table(maliciousTableId); + + ds.request = (reqOpts: DecorateRequestOptions) => { + assert(reqOpts.uri.includes('table_name%3F%24httpMethod%3DDELETE%23')); + assert(!reqOpts.uri.includes('?$httpMethod=DELETE#')); + done(); + }; + + table.getMetadata(assert.ifError); + }); + }); }); From 6f73654dd8aa4b1eb64af18d8ee50d147de5de24 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:04:54 +0000 Subject: [PATCH 02/21] fix(common): encode REST URI path variables and prevent path traversal --- core/common/src/service-object.ts | 26 +++++++++----- core/common/src/service.ts | 32 ++++++++++------- core/common/src/util.ts | 49 ++++++++++++++++++++++++++ core/common/test/util.ts | 52 ++++++++++++++++++++++++++++ handwritten/bigquery/test/dataset.ts | 29 ++++++++++++++++ 5 files changed, 167 insertions(+), 21 deletions(-) diff --git a/core/common/src/service-object.ts b/core/common/src/service-object.ts index 798193813f76..7874d819bc13 100644 --- a/core/common/src/service-object.ts +++ b/core/common/src/service-object.ts @@ -563,17 +563,25 @@ class ServiceObject extends EventEmitter { const uriComponents = [this.baseUrl, this.id || '', reqOpts.uri]; if (isAbsoluteUrl) { - uriComponents.splice(0, uriComponents.indexOf(reqOpts.uri)); + const url = new URL(reqOpts.uri); + const encodedPath = util.encodeURIPath(url.pathname); + url.pathname = encodedPath; + let res = url.toString(); + if (!reqOpts.uri.endsWith('/') && res.endsWith('/')) { + res = res.slice(0, -1); + } + reqOpts.uri = res; + } else { + reqOpts.uri = uriComponents + .filter(x => x!.trim()) // Limit to non-empty strings. + .map(uriComponent => { + const trimSlashesRegex = /^\/*|\/*$/g; + const trimmed = uriComponent!.replace(trimSlashesRegex, ''); + return util.encodeURIPath(trimmed); + }) + .join('/'); } - reqOpts.uri = uriComponents - .filter(x => x!.trim()) // Limit to non-empty strings. - .map(uriComponent => { - const trimSlashesRegex = /^\/*|\/*$/g; - return uriComponent!.replace(trimSlashesRegex, ''); - }) - .join('/'); - const childInterceptors = (arrify as unknown as (arg1: any) => [])( reqOpts.interceptors_!, ); diff --git a/core/common/src/service.ts b/core/common/src/service.ts index d0a179242467..a07a18a33376 100644 --- a/core/common/src/service.ts +++ b/core/common/src/service.ts @@ -212,20 +212,28 @@ export class Service { uriComponents.push(reqOpts.uri); if (isAbsoluteUrl) { - uriComponents.splice(0, uriComponents.indexOf(reqOpts.uri)); + const url = new URL(reqOpts.uri); + const encodedPath = util.encodeURIPath(url.pathname); + url.pathname = encodedPath; + let res = url.toString(); + if (!reqOpts.uri.endsWith('/') && res.endsWith('/')) { + res = res.slice(0, -1); + } + reqOpts.uri = res; + } else { + reqOpts.uri = uriComponents + .map(uriComponent => { + const trimSlashesRegex = /^\/*|\/*$/g; + const trimmed = uriComponent.replace(trimSlashesRegex, ''); + return util.encodeURIPath(trimmed); + }) + .join('/') + // Some URIs have colon separators. + // Bad: https://.../projects/:list + // Good: https://.../projects:list + .replace(/\/:/g, ':'); } - reqOpts.uri = uriComponents - .map(uriComponent => { - const trimSlashesRegex = /^\/*|\/*$/g; - return uriComponent.replace(trimSlashesRegex, ''); - }) - .join('/') - // Some URIs have colon separators. - // Bad: https://.../projects/:list - // Good: https://.../projects:list - .replace(/\/:/g, ':'); - const requestInterceptors = this.getRequestInterceptors(); (arrify as unknown as (arg1: any) => any[])(reqOpts.interceptors_!).forEach( diff --git a/core/common/src/util.ts b/core/common/src/util.ts index 322e6cfee37a..c36d0858e458 100644 --- a/core/common/src/util.ts +++ b/core/common/src/util.ts @@ -913,6 +913,10 @@ export class Util { return dup; } + encodeWithSlashes = encodeWithSlashes; + encodeWithoutSlashes = encodeWithoutSlashes; + encodeURIPath = encodeURIPath; + /** * Decorate the options about to be made in a request. * @@ -1024,5 +1028,50 @@ class ProgressStream extends Transform { } } +export function encodeWithSlashes(str: string, propertyName = 'resource ID field'): string { + const segments = str.split('/'); + for (const segment of segments) { + if (segment === '.' || segment === '..') { + throw new Error( + `Value for ${propertyName} must not contain segments that are exactly . or .. .`, + ); + } + } + return encodeURIComponent(str) + .replace(/%2F/gi, '/') + .replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); +} + +export function encodeWithoutSlashes(str: string, propertyName = 'resource ID field'): string { + if (str === '.' || str === '..') { + throw new Error(`Invalid value ${str} for ${propertyName}.`); + } + return encodeURIComponent(str) + .replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); +} + +export function encodeURIPath(uri: string): string { + const parts = uri.split('/'); + return parts + .map(part => { + if (part === '') { + return ''; + } + if (part.includes(':')) { + const subparts = part.split(':'); + return subparts + .map(subpart => { + if (subpart === '') { + return ''; + } + return encodeWithoutSlashes(subpart, 'path segment'); + }) + .join(':'); + } + return encodeWithoutSlashes(part, 'path segment'); + }) + .join('/'); +} + const util = new Util(); export {util}; diff --git a/core/common/test/util.ts b/core/common/test/util.ts index 6c018afd4d3d..3348b13448a9 100644 --- a/core/common/test/util.ts +++ b/core/common/test/util.ts @@ -1903,6 +1903,58 @@ describe('common/util', () => { }); }); + describe('encodeWithSlashes & encodeWithoutSlashes', () => { + it('encodeWithSlashes should allow valid path segments and encode special characters', () => { + assert.strictEqual( + util.encodeWithSlashes('foo/bar-123_~.baz'), + 'foo/bar-123_~.baz', + ); + assert.strictEqual( + util.encodeWithSlashes('foo/bar baz'), + 'foo/bar%20baz', + ); + }); + + it('encodeWithSlashes should throw if any segment is . or ..', () => { + assert.throws(() => { + util.encodeWithSlashes('foo/./bar', 'testField'); + }, /Value for testField must not contain segments that are exactly \. or \.\. \./); + + assert.throws(() => { + util.encodeWithSlashes('foo/../bar', 'testField'); + }, /Value for testField must not contain segments that are exactly \. or \.\. \./); + }); + + it('encodeWithoutSlashes should allow valid characters and encode slashes and special characters', () => { + assert.strictEqual( + util.encodeWithoutSlashes('foo-123_~.baz'), + 'foo-123_~.baz', + ); + assert.strictEqual( + util.encodeWithoutSlashes('foo/bar'), + 'foo%2Fbar', + ); + assert.strictEqual( + util.encodeWithoutSlashes('photo_😀.png'), + 'photo_%F0%9F%98%80.png', + ); + assert.strictEqual( + util.encodeWithoutSlashes('test*file!'), + 'test%2Afile%21', + ); + }); + + it('encodeWithoutSlashes should throw if the value is . or ..', () => { + assert.throws(() => { + util.encodeWithoutSlashes('.', 'testField'); + }, /Invalid value \. for testField\./); + + assert.throws(() => { + util.encodeWithoutSlashes('..', 'testField'); + }, /Invalid value \.\. for testField\./); + }); + }); + describe('maybeOptionsOrCallback', () => { it('should allow passing just a callback', () => { const optionsOrCallback = () => {}; diff --git a/handwritten/bigquery/test/dataset.ts b/handwritten/bigquery/test/dataset.ts index 26bb3ff8a831..f6472773e27d 100644 --- a/handwritten/bigquery/test/dataset.ts +++ b/handwritten/bigquery/test/dataset.ts @@ -1104,4 +1104,33 @@ describe('BigQuery/Dataset', () => { assert.strictEqual(table.location, location); }); }); + + describe('security - URI encoding and path traversal protection', () => { + it('should throw error when dataset id or path segment is dot or dot-dot', () => { + const bigqueryMock = { + projectId: 'my-project', + request: util.noop, + } as {} as _root.BigQuery; + + assert.throws(() => { + const invalidDataset = new Dataset(bigqueryMock, '..'); + invalidDataset.getMetadata(assert.ifError); + }, /Invalid value \.\. for path segment\./); + }); + + it('should percent-encode query parameter injection payload in table name', done => { + const maliciousTableId = 'table_name?param=value#tag'; + const table = ds.table(maliciousTableId); + + ds.request = (reqOpts: DecorateRequestOptions) => { + assert.strictEqual( + reqOpts.uri, + 'tables/table_name%3Fparam%3Dvalue%23tag', + ); + done(); + }; + + table.getMetadata(assert.ifError); + }); + }); }); From 100757ad2d955ebe5df19e634d9316825e8cd410 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 15:18:42 -0400 Subject: [PATCH 03/21] Eliminate dependency on a class --- core/common/src/service-object.ts | 5 +++-- core/common/src/service.ts | 5 +++-- core/common/src/util.ts | 4 ---- core/common/test/util.ts | 23 +++++++++++++---------- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/core/common/src/service-object.ts b/core/common/src/service-object.ts index 7874d819bc13..e410958608b9 100644 --- a/core/common/src/service-object.ts +++ b/core/common/src/service-object.ts @@ -28,6 +28,7 @@ import { BodyResponseCallback, DecorateRequestOptions, ResponseBody, + encodeURIPath, util, } from './util'; @@ -564,7 +565,7 @@ class ServiceObject extends EventEmitter { if (isAbsoluteUrl) { const url = new URL(reqOpts.uri); - const encodedPath = util.encodeURIPath(url.pathname); + const encodedPath = encodeURIPath(url.pathname); url.pathname = encodedPath; let res = url.toString(); if (!reqOpts.uri.endsWith('/') && res.endsWith('/')) { @@ -577,7 +578,7 @@ class ServiceObject extends EventEmitter { .map(uriComponent => { const trimSlashesRegex = /^\/*|\/*$/g; const trimmed = uriComponent!.replace(trimSlashesRegex, ''); - return util.encodeURIPath(trimmed); + return encodeURIPath(trimmed); }) .join('/'); } diff --git a/core/common/src/service.ts b/core/common/src/service.ts index a07a18a33376..7309a9aa7951 100644 --- a/core/common/src/service.ts +++ b/core/common/src/service.ts @@ -27,6 +27,7 @@ import { DecorateRequestOptions, MakeAuthenticatedRequest, PackageJson, + encodeURIPath, util, } from './util'; @@ -213,7 +214,7 @@ export class Service { if (isAbsoluteUrl) { const url = new URL(reqOpts.uri); - const encodedPath = util.encodeURIPath(url.pathname); + const encodedPath = encodeURIPath(url.pathname); url.pathname = encodedPath; let res = url.toString(); if (!reqOpts.uri.endsWith('/') && res.endsWith('/')) { @@ -225,7 +226,7 @@ export class Service { .map(uriComponent => { const trimSlashesRegex = /^\/*|\/*$/g; const trimmed = uriComponent.replace(trimSlashesRegex, ''); - return util.encodeURIPath(trimmed); + return encodeURIPath(trimmed); }) .join('/') // Some URIs have colon separators. diff --git a/core/common/src/util.ts b/core/common/src/util.ts index c36d0858e458..83442ea7c6c8 100644 --- a/core/common/src/util.ts +++ b/core/common/src/util.ts @@ -913,10 +913,6 @@ export class Util { return dup; } - encodeWithSlashes = encodeWithSlashes; - encodeWithoutSlashes = encodeWithoutSlashes; - encodeURIPath = encodeURIPath; - /** * Decorate the options about to be made in a request. * diff --git a/core/common/test/util.ts b/core/common/test/util.ts index 3348b13448a9..ea2c483e7c66 100644 --- a/core/common/test/util.ts +++ b/core/common/test/util.ts @@ -46,6 +46,9 @@ import { ParsedHttpRespMessage, ParsedHttpResponseBody, Util, + encodeWithSlashes, + encodeWithoutSlashes, + encodeURIPath, } from '../src/util'; import {DEFAULT_PROJECT_ID_TOKEN} from '../src/service'; @@ -1906,51 +1909,51 @@ describe('common/util', () => { describe('encodeWithSlashes & encodeWithoutSlashes', () => { it('encodeWithSlashes should allow valid path segments and encode special characters', () => { assert.strictEqual( - util.encodeWithSlashes('foo/bar-123_~.baz'), + encodeWithSlashes('foo/bar-123_~.baz'), 'foo/bar-123_~.baz', ); assert.strictEqual( - util.encodeWithSlashes('foo/bar baz'), + encodeWithSlashes('foo/bar baz'), 'foo/bar%20baz', ); }); it('encodeWithSlashes should throw if any segment is . or ..', () => { assert.throws(() => { - util.encodeWithSlashes('foo/./bar', 'testField'); + encodeWithSlashes('foo/./bar', 'testField'); }, /Value for testField must not contain segments that are exactly \. or \.\. \./); assert.throws(() => { - util.encodeWithSlashes('foo/../bar', 'testField'); + encodeWithSlashes('foo/../bar', 'testField'); }, /Value for testField must not contain segments that are exactly \. or \.\. \./); }); it('encodeWithoutSlashes should allow valid characters and encode slashes and special characters', () => { assert.strictEqual( - util.encodeWithoutSlashes('foo-123_~.baz'), + encodeWithoutSlashes('foo-123_~.baz'), 'foo-123_~.baz', ); assert.strictEqual( - util.encodeWithoutSlashes('foo/bar'), + encodeWithoutSlashes('foo/bar'), 'foo%2Fbar', ); assert.strictEqual( - util.encodeWithoutSlashes('photo_😀.png'), + encodeWithoutSlashes('photo_😀.png'), 'photo_%F0%9F%98%80.png', ); assert.strictEqual( - util.encodeWithoutSlashes('test*file!'), + encodeWithoutSlashes('test*file!'), 'test%2Afile%21', ); }); it('encodeWithoutSlashes should throw if the value is . or ..', () => { assert.throws(() => { - util.encodeWithoutSlashes('.', 'testField'); + encodeWithoutSlashes('.', 'testField'); }, /Invalid value \. for testField\./); assert.throws(() => { - util.encodeWithoutSlashes('..', 'testField'); + encodeWithoutSlashes('..', 'testField'); }, /Invalid value \.\. for testField\./); }); }); From 4ae9487310689152da39c417b0511c91ffd52172 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 15:36:36 -0400 Subject: [PATCH 04/21] Move encodeAbsoluteURI into one method --- core/common/src/service-object.ts | 10 ++-------- core/common/src/service.ts | 10 ++-------- core/common/src/util.ts | 11 +++++++++++ core/common/test/util.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/core/common/src/service-object.ts b/core/common/src/service-object.ts index e410958608b9..029976c99d07 100644 --- a/core/common/src/service-object.ts +++ b/core/common/src/service-object.ts @@ -28,6 +28,7 @@ import { BodyResponseCallback, DecorateRequestOptions, ResponseBody, + encodeAbsoluteURI, encodeURIPath, util, } from './util'; @@ -564,14 +565,7 @@ class ServiceObject extends EventEmitter { const uriComponents = [this.baseUrl, this.id || '', reqOpts.uri]; if (isAbsoluteUrl) { - const url = new URL(reqOpts.uri); - const encodedPath = encodeURIPath(url.pathname); - url.pathname = encodedPath; - let res = url.toString(); - if (!reqOpts.uri.endsWith('/') && res.endsWith('/')) { - res = res.slice(0, -1); - } - reqOpts.uri = res; + reqOpts.uri = encodeAbsoluteURI(reqOpts.uri); } else { reqOpts.uri = uriComponents .filter(x => x!.trim()) // Limit to non-empty strings. diff --git a/core/common/src/service.ts b/core/common/src/service.ts index 7309a9aa7951..2ee91f96baf1 100644 --- a/core/common/src/service.ts +++ b/core/common/src/service.ts @@ -27,6 +27,7 @@ import { DecorateRequestOptions, MakeAuthenticatedRequest, PackageJson, + encodeAbsoluteURI, encodeURIPath, util, } from './util'; @@ -213,14 +214,7 @@ export class Service { uriComponents.push(reqOpts.uri); if (isAbsoluteUrl) { - const url = new URL(reqOpts.uri); - const encodedPath = encodeURIPath(url.pathname); - url.pathname = encodedPath; - let res = url.toString(); - if (!reqOpts.uri.endsWith('/') && res.endsWith('/')) { - res = res.slice(0, -1); - } - reqOpts.uri = res; + reqOpts.uri = encodeAbsoluteURI(reqOpts.uri); } else { reqOpts.uri = uriComponents .map(uriComponent => { diff --git a/core/common/src/util.ts b/core/common/src/util.ts index 83442ea7c6c8..ad781bbcfdfd 100644 --- a/core/common/src/util.ts +++ b/core/common/src/util.ts @@ -1069,5 +1069,16 @@ export function encodeURIPath(uri: string): string { .join('/'); } +export function encodeAbsoluteURI(uri: string): string { + const url = new URL(uri); + const encodedPath = encodeURIPath(url.pathname); + url.pathname = encodedPath; + let res = url.toString(); + if (!uri.endsWith('/') && res.endsWith('/')) { + res = res.slice(0, -1); + } + return res; +} + const util = new Util(); export {util}; diff --git a/core/common/test/util.ts b/core/common/test/util.ts index ea2c483e7c66..c3cd822c2200 100644 --- a/core/common/test/util.ts +++ b/core/common/test/util.ts @@ -49,6 +49,7 @@ import { encodeWithSlashes, encodeWithoutSlashes, encodeURIPath, + encodeAbsoluteURI, } from '../src/util'; import {DEFAULT_PROJECT_ID_TOKEN} from '../src/service'; @@ -1958,6 +1959,34 @@ describe('common/util', () => { }); }); + describe('encodeAbsoluteURI', () => { + it('should handle absolute URLs with and without trailing slash', () => { + assert.strictEqual( + encodeAbsoluteURI('https://example.com/foo/bar'), + 'https://example.com/foo/bar', + ); + assert.strictEqual( + encodeAbsoluteURI('https://example.com/foo/bar/'), + 'https://example.com/foo/bar/', + ); + assert.strictEqual( + encodeAbsoluteURI('http://www.google.com'), + 'http://www.google.com', + ); + }); + + it('should encode path segments and handle colons in absolute URLs', () => { + assert.strictEqual( + encodeAbsoluteURI('https://example.com/projects:list'), + 'https://example.com/projects:list', + ); + assert.strictEqual( + encodeAbsoluteURI('https://example.com/foo/bar-123_~.baz'), + 'https://example.com/foo/bar-123_~.baz', + ); + }); + }); + describe('maybeOptionsOrCallback', () => { it('should allow passing just a callback', () => { const optionsOrCallback = () => {}; From ab44abdb54c743bc4cea2b50f465c5a41d8d0374 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 16:04:57 -0400 Subject: [PATCH 05/21] Add a comment to encodeURIPath to explain what its doing --- core/common/src/util.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/common/src/util.ts b/core/common/src/util.ts index ad781bbcfdfd..3f148f75860f 100644 --- a/core/common/src/util.ts +++ b/core/common/src/util.ts @@ -1046,6 +1046,14 @@ export function encodeWithoutSlashes(str: string, propertyName = 'resource ID fi .replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); } +/** + * Encodes each path segment in a URI string while preserving slash (`/`) and + * colon (`:`) delimiters, and validates that no path segment is `.` or `..` to + * prevent path traversal. + * + * @param {string} uri - The URI path to encode. + * @return {string} The encoded URI path. + */ export function encodeURIPath(uri: string): string { const parts = uri.split('/'); return parts From 1ff5899db999f332b070a9762db45d84d1520cca Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 16:07:32 -0400 Subject: [PATCH 06/21] Add JS doc comment for encodeAbsoluteURI --- core/common/src/util.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/core/common/src/util.ts b/core/common/src/util.ts index 3f148f75860f..91ffb34a28a5 100644 --- a/core/common/src/util.ts +++ b/core/common/src/util.ts @@ -1077,6 +1077,13 @@ export function encodeURIPath(uri: string): string { .join('/'); } +/** + * Encodes the pathname of an absolute URI string using `encodeURIPath`, + * preserving any query parameters, hash, or trailing slash formatting. + * + * @param {string} uri - The absolute URI string to encode. + * @return {string} The formatted and encoded absolute URI string. + */ export function encodeAbsoluteURI(uri: string): string { const url = new URL(uri); const encodedPath = encodeURIPath(url.pathname); From 06f2424917dc413193a7dcbcefb7366374a1ac08 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 16:19:49 -0400 Subject: [PATCH 07/21] Append the inline comments --- core/common/src/service-object.ts | 2 +- core/common/src/service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/common/src/service-object.ts b/core/common/src/service-object.ts index 029976c99d07..1b97e35e72d5 100644 --- a/core/common/src/service-object.ts +++ b/core/common/src/service-object.ts @@ -572,7 +572,7 @@ class ServiceObject extends EventEmitter { .map(uriComponent => { const trimSlashesRegex = /^\/*|\/*$/g; const trimmed = uriComponent!.replace(trimSlashesRegex, ''); - return encodeURIPath(trimmed); + return encodeURIPath(trimmed); // Encode and prevent path traversal. }) .join('/'); } diff --git a/core/common/src/service.ts b/core/common/src/service.ts index 2ee91f96baf1..7bdbc34dd328 100644 --- a/core/common/src/service.ts +++ b/core/common/src/service.ts @@ -220,7 +220,7 @@ export class Service { .map(uriComponent => { const trimSlashesRegex = /^\/*|\/*$/g; const trimmed = uriComponent.replace(trimSlashesRegex, ''); - return encodeURIPath(trimmed); + return encodeURIPath(trimmed); // Encode and prevent path traversal. }) .join('/') // Some URIs have colon separators. From a05fd58d9c28cde8270ced561b89f19f4b8b4be5 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 16:37:54 -0400 Subject: [PATCH 08/21] Explain significance of URL object --- core/common/src/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/common/src/util.ts b/core/common/src/util.ts index 91ffb34a28a5..16f5ba10be84 100644 --- a/core/common/src/util.ts +++ b/core/common/src/util.ts @@ -1085,7 +1085,7 @@ export function encodeURIPath(uri: string): string { * @return {string} The formatted and encoded absolute URI string. */ export function encodeAbsoluteURI(uri: string): string { - const url = new URL(uri); + const url = new URL(uri); // Isolate pathname from protocol, host, and query. const encodedPath = encodeURIPath(url.pathname); url.pathname = encodedPath; let res = url.toString(); From 83f4a6bf83604c3705f8ac02146e076941567eb8 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 17:21:26 -0400 Subject: [PATCH 09/21] Add comments to the else blocks --- core/common/src/service-object.ts | 2 ++ core/common/src/service.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/core/common/src/service-object.ts b/core/common/src/service-object.ts index 1b97e35e72d5..ee91cdcadef2 100644 --- a/core/common/src/service-object.ts +++ b/core/common/src/service-object.ts @@ -567,6 +567,8 @@ class ServiceObject extends EventEmitter { if (isAbsoluteUrl) { reqOpts.uri = encodeAbsoluteURI(reqOpts.uri); } else { + // Relative path components contain only path segments (no protocol or host), + // so we encode each segment directly and join them with '/'. reqOpts.uri = uriComponents .filter(x => x!.trim()) // Limit to non-empty strings. .map(uriComponent => { diff --git a/core/common/src/service.ts b/core/common/src/service.ts index 7bdbc34dd328..c21b6ce1e855 100644 --- a/core/common/src/service.ts +++ b/core/common/src/service.ts @@ -216,6 +216,8 @@ export class Service { if (isAbsoluteUrl) { reqOpts.uri = encodeAbsoluteURI(reqOpts.uri); } else { + // Relative path components contain only path segments (no protocol or host), + // so we encode each segment directly and join them with '/'. reqOpts.uri = uriComponents .map(uriComponent => { const trimSlashesRegex = /^\/*|\/*$/g; From a92100bd95f94dc943779c663be3b5b5c06e4636 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 17:35:10 -0400 Subject: [PATCH 10/21] Add comments here about percent encoding --- core/common/src/service-object.ts | 3 +++ core/common/src/service.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/core/common/src/service-object.ts b/core/common/src/service-object.ts index ee91cdcadef2..a6b36aa6486f 100644 --- a/core/common/src/service-object.ts +++ b/core/common/src/service-object.ts @@ -565,6 +565,9 @@ class ServiceObject extends EventEmitter { const uriComponents = [this.baseUrl, this.id || '', reqOpts.uri]; if (isAbsoluteUrl) { + // Encode only the pathname to preserve protocol, host, and query params. + // We cannot pass uriComponents through encodeURIPath after splicing + // because it will percent-encode parts we do not want to encode. reqOpts.uri = encodeAbsoluteURI(reqOpts.uri); } else { // Relative path components contain only path segments (no protocol or host), diff --git a/core/common/src/service.ts b/core/common/src/service.ts index c21b6ce1e855..7afa63d18a64 100644 --- a/core/common/src/service.ts +++ b/core/common/src/service.ts @@ -214,6 +214,9 @@ export class Service { uriComponents.push(reqOpts.uri); if (isAbsoluteUrl) { + // Encode only the pathname to preserve protocol, host, and query params. + // We cannot pass uriComponents through encodeURIPath after splicing + // because it will percent-encode parts we do not want to encode. reqOpts.uri = encodeAbsoluteURI(reqOpts.uri); } else { // Relative path components contain only path segments (no protocol or host), From b51c0b3a7552d0b5bbca0507e270884624d17a79 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 09:57:56 -0400 Subject: [PATCH 11/21] Move joinURIComponents into a separate method --- core/common/src/service-object.ts | 13 ++++--------- core/common/src/service.ts | 10 ++-------- core/common/src/util.ts | 17 +++++++++++++++++ core/common/test/util.ts | 20 ++++++++++++++++++++ 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/core/common/src/service-object.ts b/core/common/src/service-object.ts index a6b36aa6486f..853e0bd96540 100644 --- a/core/common/src/service-object.ts +++ b/core/common/src/service-object.ts @@ -29,7 +29,7 @@ import { DecorateRequestOptions, ResponseBody, encodeAbsoluteURI, - encodeURIPath, + joinURIComponents, util, } from './util'; @@ -572,14 +572,9 @@ class ServiceObject extends EventEmitter { } else { // Relative path components contain only path segments (no protocol or host), // so we encode each segment directly and join them with '/'. - reqOpts.uri = uriComponents - .filter(x => x!.trim()) // Limit to non-empty strings. - .map(uriComponent => { - const trimSlashesRegex = /^\/*|\/*$/g; - const trimmed = uriComponent!.replace(trimSlashesRegex, ''); - return encodeURIPath(trimmed); // Encode and prevent path traversal. - }) - .join('/'); + reqOpts.uri = joinURIComponents( + uriComponents.filter(x => x!.trim()) as string[], + ); } const childInterceptors = (arrify as unknown as (arg1: any) => [])( diff --git a/core/common/src/service.ts b/core/common/src/service.ts index 7afa63d18a64..3305d0374bdc 100644 --- a/core/common/src/service.ts +++ b/core/common/src/service.ts @@ -28,7 +28,7 @@ import { MakeAuthenticatedRequest, PackageJson, encodeAbsoluteURI, - encodeURIPath, + joinURIComponents, util, } from './util'; @@ -221,13 +221,7 @@ export class Service { } else { // Relative path components contain only path segments (no protocol or host), // so we encode each segment directly and join them with '/'. - reqOpts.uri = uriComponents - .map(uriComponent => { - const trimSlashesRegex = /^\/*|\/*$/g; - const trimmed = uriComponent.replace(trimSlashesRegex, ''); - return encodeURIPath(trimmed); // Encode and prevent path traversal. - }) - .join('/') + reqOpts.uri = joinURIComponents(uriComponents) // Some URIs have colon separators. // Bad: https://.../projects/:list // Good: https://.../projects:list diff --git a/core/common/src/util.ts b/core/common/src/util.ts index 16f5ba10be84..a046a892a0a2 100644 --- a/core/common/src/util.ts +++ b/core/common/src/util.ts @@ -1095,5 +1095,22 @@ export function encodeAbsoluteURI(uri: string): string { return res; } +/** + * Trims slashes, encodes path segments to prevent path traversal, and joins + * URI components into a single relative path. + * + * @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, ''); + return encodeURIPath(trimmed); // Encode and prevent path traversal. + }) + .join('/'); +} + const util = new Util(); export {util}; diff --git a/core/common/test/util.ts b/core/common/test/util.ts index c3cd822c2200..012662cb2060 100644 --- a/core/common/test/util.ts +++ b/core/common/test/util.ts @@ -50,6 +50,7 @@ import { encodeWithoutSlashes, encodeURIPath, encodeAbsoluteURI, + joinURIComponents, } from '../src/util'; import {DEFAULT_PROJECT_ID_TOKEN} from '../src/service'; @@ -1987,6 +1988,25 @@ describe('common/util', () => { }); }); + describe('joinURIComponents', () => { + it('should trim slashes and join components', () => { + assert.strictEqual( + joinURIComponents(['/base/', '/id/', '/path/']), + 'base/id/path', + ); + }); + + it('should encode special characters and prevent path traversal in components', () => { + assert.strictEqual( + joinURIComponents(['datasets', 'my dataset', 'tables']), + 'datasets/my%20dataset/tables', + ); + assert.throws(() => { + joinURIComponents(['datasets', '..', 'tables']); + }); + }); + }); + describe('maybeOptionsOrCallback', () => { it('should allow passing just a callback', () => { const optionsOrCallback = () => {}; From a451317b8a70fa5e4055e5a0196800c3fb83a847 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 10:20:50 -0400 Subject: [PATCH 12/21] Use the gax based utility methods verbatim --- core/common/src/util.ts | 69 ++++++++++++++++++++------- core/common/test/util.ts | 70 +++++++++++++++++----------- handwritten/bigquery/test/dataset.ts | 2 +- 3 files changed, 97 insertions(+), 44 deletions(-) diff --git a/core/common/src/util.ts b/core/common/src/util.ts index a046a892a0a2..230858bccf15 100644 --- a/core/common/src/util.ts +++ b/core/common/src/util.ts @@ -1024,26 +1024,61 @@ class ProgressStream extends Transform { } } -export function encodeWithSlashes(str: string, propertyName = 'resource ID field'): string { - const segments = str.split('/'); - for (const segment of segments) { - if (segment === '.' || segment === '..') { +// Validates a single path segment matched by a single wildcard (*). +// Checks that the segment is not exactly '.' or '..' (directory traversal indicators). +export function validateUriPathSegment(propertyName: string, value: string): void { + if (value === '.' || value === '..') { + throw new Error(`Invalid value ${value} for ${propertyName}`); + } +} + +// Validates a multi-segment path matched by a double wildcard (**). +// Splitting by slash, it checks that no individual segment is exactly '.' or '..'. +// This segment-by-segment check prevents directory traversal while allowing +// legitimate resource names containing dots (e.g., domain-scoped project IDs). +export function validateUriPath(propertyName: string, value: string): void { + if (value) { + // Split by slash and check for exact segment matches of '.' or '..' rather + // than using a simple string.includes('.') check. This avoids rejecting + // valid domain-scoped resource segments (e.g. projects/example.com:project-id). + const segments = value.split('/'); + if (segments.some(segment => segment === '.' || segment === '..')) { throw new Error( - `Value for ${propertyName} must not contain segments that are exactly . or .. .`, + `Value for ${propertyName} must not contain segments that are exactly . or ..`, ); } } - return encodeURIComponent(str) - .replace(/%2F/gi, '/') - .replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); } -export function encodeWithoutSlashes(str: string, propertyName = 'resource ID field'): string { - if (str === '.' || str === '..') { - throw new Error(`Invalid value ${str} for ${propertyName}.`); - } - return encodeURIComponent(str) - .replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); +/** + * Percent-encodes a string according to RFC 3986, preserving only unreserved + * characters (alpha-numeric, '-', '_', '.', and '~'). All other characters, + * including slashes ('/'), are percent-encoded. + * + * This is necessary because encodeURIComponent natively encodes URL-unsafe + * characters like ?, #, $, &, +, etc., but preserves !, ', (, ), and *. + * To ensure strict compliance, we manually encode those preserved characters. + * + * @param {string} str - The input string to encode. + * @returns {string} The percent-encoded string. + */ +export function encodeWithSlashes(str: string): string { + return encodeURIComponent(str).replace( + /[!'()*]/g, // Characters preserved by encodeURIComponent + character => '%' + character.charCodeAt(0).toString(16).toUpperCase(), + ); +} + +/** + * Percent-encodes a string according to RFC 3986, preserving unreserved + * characters (alpha-numeric, '-', '_', '.', and '~') and slashes ('/'). All other + * characters are percent-encoded. + * + * @param {string} str - The input string to encode. + * @returns {string} The percent-encoded string with slashes preserved. + */ +export function encodeWithoutSlashes(str: string): string { + return str.split('/').map(encodeWithSlashes).join('/'); } /** @@ -1068,11 +1103,13 @@ export function encodeURIPath(uri: string): string { if (subpart === '') { return ''; } - return encodeWithoutSlashes(subpart, 'path segment'); + validateUriPathSegment('path segment', subpart); + return encodeWithSlashes(subpart); }) .join(':'); } - return encodeWithoutSlashes(part, 'path segment'); + validateUriPathSegment('path segment', part); + return encodeWithSlashes(part); }) .join('/'); } diff --git a/core/common/test/util.ts b/core/common/test/util.ts index 012662cb2060..1a9468fa3de8 100644 --- a/core/common/test/util.ts +++ b/core/common/test/util.ts @@ -46,6 +46,8 @@ import { ParsedHttpRespMessage, ParsedHttpResponseBody, Util, + validateUriPathSegment, + validateUriPath, encodeWithSlashes, encodeWithoutSlashes, encodeURIPath, @@ -1908,36 +1910,60 @@ describe('common/util', () => { }); }); - describe('encodeWithSlashes & encodeWithoutSlashes', () => { - it('encodeWithSlashes should allow valid path segments and encode special characters', () => { - assert.strictEqual( - encodeWithSlashes('foo/bar-123_~.baz'), - 'foo/bar-123_~.baz', - ); - assert.strictEqual( - encodeWithSlashes('foo/bar baz'), - 'foo/bar%20baz', - ); + describe('validateUriPathSegment & validateUriPath', () => { + it('validateUriPathSegment should throw if the value is . or ..', () => { + assert.throws(() => { + validateUriPathSegment('testField', '.'); + }, /Invalid value \. for testField/); + + assert.throws(() => { + validateUriPathSegment('testField', '..'); + }, /Invalid value \.\. for testField/); }); - it('encodeWithSlashes should throw if any segment is . or ..', () => { + it('validateUriPath should throw if any segment is . or ..', () => { assert.throws(() => { - encodeWithSlashes('foo/./bar', 'testField'); - }, /Value for testField must not contain segments that are exactly \. or \.\. \./); + validateUriPath('testField', 'foo/./bar'); + }, /Value for testField must not contain segments that are exactly \. or \.\./); assert.throws(() => { - encodeWithSlashes('foo/../bar', 'testField'); - }, /Value for testField must not contain segments that are exactly \. or \.\. \./); + validateUriPath('testField', 'foo/../bar'); + }, /Value for testField must not contain segments that are exactly \. or \.\./); + }); + }); + + describe('encodeWithSlashes & encodeWithoutSlashes', () => { + it('encodeWithSlashes should percent-encode special characters and slashes', () => { + assert.strictEqual( + encodeWithSlashes('foo/bar'), + 'foo%2Fbar', + ); + assert.strictEqual( + encodeWithSlashes('abc-123_.~'), + 'abc-123_.~', + ); + assert.strictEqual( + encodeWithSlashes("!'()*"), + '%21%27%28%29%2A', + ); + assert.strictEqual( + encodeWithSlashes('photo_😀.png'), + 'photo_%F0%9F%98%80.png', + ); }); - it('encodeWithoutSlashes should allow valid characters and encode slashes and special characters', () => { + it('encodeWithoutSlashes should preserve slashes and encode special characters', () => { assert.strictEqual( encodeWithoutSlashes('foo-123_~.baz'), 'foo-123_~.baz', ); assert.strictEqual( encodeWithoutSlashes('foo/bar'), - 'foo%2Fbar', + 'foo/bar', + ); + assert.strictEqual( + encodeWithoutSlashes('foo/bar baz'), + 'foo/bar%20baz', ); assert.strictEqual( encodeWithoutSlashes('photo_😀.png'), @@ -1948,16 +1974,6 @@ describe('common/util', () => { 'test%2Afile%21', ); }); - - it('encodeWithoutSlashes should throw if the value is . or ..', () => { - assert.throws(() => { - encodeWithoutSlashes('.', 'testField'); - }, /Invalid value \. for testField\./); - - assert.throws(() => { - encodeWithoutSlashes('..', 'testField'); - }, /Invalid value \.\. for testField\./); - }); }); describe('encodeAbsoluteURI', () => { diff --git a/handwritten/bigquery/test/dataset.ts b/handwritten/bigquery/test/dataset.ts index f6472773e27d..caaa105149a5 100644 --- a/handwritten/bigquery/test/dataset.ts +++ b/handwritten/bigquery/test/dataset.ts @@ -1115,7 +1115,7 @@ describe('BigQuery/Dataset', () => { assert.throws(() => { const invalidDataset = new Dataset(bigqueryMock, '..'); invalidDataset.getMetadata(assert.ifError); - }, /Invalid value \.\. for path segment\./); + }, /Invalid value \.\. for path segment/); }); it('should percent-encode query parameter injection payload in table name', done => { From d083db0d4da689a042d0ee167cee9a404d650fe1 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 14:26:48 -0400 Subject: [PATCH 13/21] Move the security tests to service-object --- core/common/test/service-object.ts | 20 +++++++++++++++++++ handwritten/bigquery/test/dataset.ts | 30 +--------------------------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/core/common/test/service-object.ts b/core/common/test/service-object.ts index 1d3ed35a5fee..b38ee8cb9472 100644 --- a/core/common/test/service-object.ts +++ b/core/common/test/service-object.ts @@ -1096,6 +1096,26 @@ describe('ServiceObject', () => { }); }); + it('should throw error when id or uri contains path traversal segments', () => { + serviceObject.id = '..'; + assert.throws(() => { + asInternal(serviceObject).request_(reqOpts, () => {}); + }, /Invalid value \.\. for path segment/); + }); + + it('should percent-encode query parameter injection payloads in path components', done => { + const maliciousId = 'table_name?param=value#tag'; + serviceObject.id = maliciousId; + serviceObject.parent.request = (reqOpts_, callback) => { + assert.strictEqual( + reqOpts_.uri, + `${serviceObject.baseUrl}/table_name%3Fparam%3Dvalue%23tag/${reqOpts.uri}`, + ); + callback(null, null, {} as r.Response); + }; + asInternal(serviceObject).request_(reqOpts, () => done()); + }); + it('should extend interceptors from child ServiceObjects', async () => { const parent = new ServiceObject(CONFIG) as FakeServiceObject; parent.interceptors.push({ diff --git a/handwritten/bigquery/test/dataset.ts b/handwritten/bigquery/test/dataset.ts index caaa105149a5..c729c13157f8 100644 --- a/handwritten/bigquery/test/dataset.ts +++ b/handwritten/bigquery/test/dataset.ts @@ -1104,33 +1104,5 @@ describe('BigQuery/Dataset', () => { assert.strictEqual(table.location, location); }); }); - - describe('security - URI encoding and path traversal protection', () => { - it('should throw error when dataset id or path segment is dot or dot-dot', () => { - const bigqueryMock = { - projectId: 'my-project', - request: util.noop, - } as {} as _root.BigQuery; - - assert.throws(() => { - const invalidDataset = new Dataset(bigqueryMock, '..'); - invalidDataset.getMetadata(assert.ifError); - }, /Invalid value \.\. for path segment/); - }); - - it('should percent-encode query parameter injection payload in table name', done => { - const maliciousTableId = 'table_name?param=value#tag'; - const table = ds.table(maliciousTableId); - - ds.request = (reqOpts: DecorateRequestOptions) => { - assert.strictEqual( - reqOpts.uri, - 'tables/table_name%3Fparam%3Dvalue%23tag', - ); - done(); - }; - - table.getMetadata(assert.ifError); - }); - }); }); + From 812fc63ae4525eabe1b7803a2cd1a1b9cfb06c01 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 14:45:31 -0400 Subject: [PATCH 14/21] generate tests for Bigquery in the common library --- core/common/test/service-object.ts | 53 ++++++++++++++++++++++++++++ handwritten/bigquery/test/dataset.ts | 32 +++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/core/common/test/service-object.ts b/core/common/test/service-object.ts index b38ee8cb9472..edba3e8e31ec 100644 --- a/core/common/test/service-object.ts +++ b/core/common/test/service-object.ts @@ -1255,4 +1255,57 @@ describe('ServiceObject', () => { serviceObject.requestStream(fakeOptions); }); }); + + // Temporary test suite pulling in BigQuery to verify end-to-end path traversal + // protection and URI encoding with the local @google-cloud/common implementation. + // Note: We will delete these tests after the corresponding tests in BigQuery + // (handwritten/bigquery/test/dataset.ts) are unskipped upon the release of @google-cloud/common. + describe('BigQuery Dataset integration (security - URI encoding and path traversal protection)', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let BigQueryDataset: any; + + before(() => { + BigQueryDataset = proxyquire( + '../../../../handwritten/bigquery/build/src/dataset', + { + '@google-cloud/common': { + ServiceObject, + util, + }, + }, + ).Dataset; + }); + + it('should throw error when dataset id or path segment is dot or dot-dot', () => { + const bigqueryMock = { + projectId: 'my-project', + request: util.noop, + }; + + assert.throws(() => { + const invalidDataset = new BigQueryDataset(bigqueryMock, '..'); + invalidDataset.getMetadata(assert.ifError); + }, /Invalid value \.\. for path segment/); + }); + + it('should percent-encode query parameter injection payload in table name', done => { + const bigqueryMock = { + projectId: 'my-project', + request: util.noop, + }; + const ds = new BigQueryDataset(bigqueryMock, 'kittens'); + const maliciousTableId = 'table_name?param=value#tag'; + const table = ds.table(maliciousTableId); + + ds.request = (reqOpts: DecorateRequestOptions) => { + assert.strictEqual( + reqOpts.uri, + 'tables/table_name%3Fparam%3Dvalue%23tag', + ); + done(); + }; + + table.getMetadata(assert.ifError); + }); + }); }); diff --git a/handwritten/bigquery/test/dataset.ts b/handwritten/bigquery/test/dataset.ts index c729c13157f8..dfb1ef0a1a4a 100644 --- a/handwritten/bigquery/test/dataset.ts +++ b/handwritten/bigquery/test/dataset.ts @@ -1104,5 +1104,37 @@ describe('BigQuery/Dataset', () => { assert.strictEqual(table.location, location); }); }); + + // Skipped for now: Waiting for the release of the updated @google-cloud/common + // library containing the path traversal validation and URI encoding fixes. + describe.skip('security - URI encoding and path traversal protection', () => { + it('should throw error when dataset id or path segment is dot or dot-dot', () => { + const bigqueryMock = { + projectId: 'my-project', + request: util.noop, + } as {} as _root.BigQuery; + + assert.throws(() => { + const invalidDataset = new Dataset(bigqueryMock, '..'); + invalidDataset.getMetadata(assert.ifError); + }, /Invalid value \.\. for path segment/); + }); + + it('should percent-encode query parameter injection payload in table name', done => { + const maliciousTableId = 'table_name?param=value#tag'; + const table = ds.table(maliciousTableId); + + ds.request = (reqOpts: DecorateRequestOptions) => { + assert.strictEqual( + reqOpts.uri, + 'tables/table_name%3Fparam%3Dvalue%23tag', + ); + done(); + }; + + table.getMetadata(assert.ifError); + }); + }); }); + From f93b18e383b842cc53fab4b843f9687fdd4957f2 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 16:19:00 -0400 Subject: [PATCH 15/21] Add the system tests for bigquery. --- core/common/system-test/bigquery.ts | 113 ++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 core/common/system-test/bigquery.ts diff --git a/core/common/system-test/bigquery.ts b/core/common/system-test/bigquery.ts new file mode 100644 index 000000000000..f013b45291b5 --- /dev/null +++ b/core/common/system-test/bigquery.ts @@ -0,0 +1,113 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import * as http from 'http'; +import * as net from 'net'; +import {describe, it, before, after} from 'mocha'; +import * as proxyquire from 'proxyquire'; + +import * as common from '../src'; +import {GoogleAuth} from 'google-auth-library'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let BigQuery: any; + +describe('BigQuery System Integration (URI encoding and path traversal protection)', () => { + before(() => { + // Inject the local @google-cloud/common build into @google-cloud/bigquery + BigQuery = proxyquire('../../../../handwritten/bigquery/build/src', { + '@google-cloud/common': common, + }).BigQuery; + }); + + const fakeAuthClient = Object.assign(new GoogleAuth(), { + getCredentials: async () => ({}), + authorizeRequest: async (reqOpts: common.DecorateRequestOptions) => reqOpts, + getProjectId: async () => 'test-project', + }); + + it('should throw an error when dataset id is a path traversal segment (..)', async () => { + const bigquery = new BigQuery({ + projectId: 'test-project', + authClient: fakeAuthClient, + }); + + const invalidDataset = bigquery.dataset('..'); + + await assert.rejects( + async () => { + await invalidDataset.getMetadata(); + }, + /Invalid value \.\. for path segment/, + ); + }); + + it('should throw an error when table id is a path traversal segment (.)', async () => { + const bigquery = new BigQuery({ + projectId: 'test-project', + authClient: fakeAuthClient, + }); + + const invalidTable = bigquery.dataset('valid-dataset').table('.'); + + await assert.rejects( + async () => { + await invalidTable.getMetadata(); + }, + /Invalid value \. for path segment/, + ); + }); + + it('should percent-encode query parameter and fragment characters in table names', async () => { + const bigquery = new BigQuery({ + projectId: 'test-project', + authClient: fakeAuthClient, + }); + + let interceptedUri: string | undefined; + bigquery.interceptors.push({ + request(reqOpts: common.DecorateRequestOptions) { + interceptedUri = reqOpts.uri; + return reqOpts; + }, + }); + + // Mock makeAuthenticatedRequest to return mock metadata without network calls + bigquery.makeAuthenticatedRequest = ( + reqOpts: common.DecorateRequestOptions, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback?: any, + ) => { + const response = {kind: 'bigquery#table', id: 'mock-table'}; + if (typeof callback === 'function') { + callback(null, response, response); + } + return undefined; + }; + + const maliciousTableId = 'table_name?param=value#tag'; + const table = bigquery.dataset('my_dataset').table(maliciousTableId); + + await table.getMetadata(); + + assert.strictEqual(typeof interceptedUri, 'string'); + assert.ok( + (interceptedUri as unknown as string).includes( + 'datasets/my_dataset/tables/table_name%3Fparam%3Dvalue%23tag', + ), + `Expected URI to contain encoded table ID, but received: ${interceptedUri}`, + ); + }); +}); From 647558dff3354b3d82bc432f33f76d177af6c918 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 16:30:28 -0400 Subject: [PATCH 16/21] Introduce the processSegment method --- core/common/src/util.ts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/core/common/src/util.ts b/core/common/src/util.ts index 230858bccf15..a9410f3b5495 100644 --- a/core/common/src/util.ts +++ b/core/common/src/util.ts @@ -1090,26 +1090,27 @@ export function encodeWithoutSlashes(str: string): string { * @return {string} The encoded URI path. */ export function encodeURIPath(uri: string): string { + const processSegment = (segment: string): string => { + if (segment === '') { + return ''; + } + let decoded = segment; + try { + decoded = decodeURIComponent(segment); + } catch { + // Fallback to raw segment if decoding fails (e.g. malformed '%') + } + validateUriPathSegment('path segment', decoded); + return encodeWithSlashes(decoded); + }; + const parts = uri.split('/'); return parts .map(part => { - if (part === '') { - return ''; - } if (part.includes(':')) { - const subparts = part.split(':'); - return subparts - .map(subpart => { - if (subpart === '') { - return ''; - } - validateUriPathSegment('path segment', subpart); - return encodeWithSlashes(subpart); - }) - .join(':'); + return part.split(':').map(processSegment).join(':'); } - validateUriPathSegment('path segment', part); - return encodeWithSlashes(part); + return processSegment(part); }) .join('/'); } From a501715b1b57f43c322991d098847e51cf04ee5c Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 16:31:17 -0400 Subject: [PATCH 17/21] Move the tests to bigquery where they belong and mock out proxyquire --- .../bigquery/system-test/security.ts | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) rename core/common/system-test/bigquery.ts => handwritten/bigquery/system-test/security.ts (71%) diff --git a/core/common/system-test/bigquery.ts b/handwritten/bigquery/system-test/security.ts similarity index 71% rename from core/common/system-test/bigquery.ts rename to handwritten/bigquery/system-test/security.ts index f013b45291b5..bc6609705455 100644 --- a/core/common/system-test/bigquery.ts +++ b/handwritten/bigquery/system-test/security.ts @@ -13,28 +13,38 @@ // limitations under the License. import * as assert from 'assert'; -import * as http from 'http'; -import * as net from 'net'; -import {describe, it, before, after} from 'mocha'; +import {describe, it, before} from 'mocha'; import * as proxyquire from 'proxyquire'; - -import * as common from '../src'; import {GoogleAuth} from 'google-auth-library'; +// Load the local build of @google-cloud/common from this branch +// eslint-disable-next-line @typescript-eslint/no-var-requires +const common = require('../../../../core/common/build/src'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any let BigQuery: any; -describe('BigQuery System Integration (URI encoding and path traversal protection)', () => { +describe('BigQuery System Security (URI encoding and path traversal protection)', () => { before(() => { - // Inject the local @google-cloud/common build into @google-cloud/bigquery - BigQuery = proxyquire('../../../../handwritten/bigquery/build/src', { + // Inject the local common build prototypes and utilities + // eslint-disable-next-line @typescript-eslint/no-var-requires + const oldCommon = require('@google-cloud/common'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const oldUtil = require('@google-cloud/common/build/src/util'); + Object.assign(oldCommon.ServiceObject.prototype, common.ServiceObject.prototype); + Object.assign(oldCommon.Service.prototype, common.Service.prototype); + Object.assign(oldCommon.util, common.util); + Object.assign(oldUtil, common.util); + + BigQuery = proxyquire('../src', { '@google-cloud/common': common, }).BigQuery; }); const fakeAuthClient = Object.assign(new GoogleAuth(), { getCredentials: async () => ({}), - authorizeRequest: async (reqOpts: common.DecorateRequestOptions) => reqOpts, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authorizeRequest: async (reqOpts: any) => reqOpts, getProjectId: async () => 'test-project', }); @@ -78,7 +88,8 @@ describe('BigQuery System Integration (URI encoding and path traversal protectio let interceptedUri: string | undefined; bigquery.interceptors.push({ - request(reqOpts: common.DecorateRequestOptions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request(reqOpts: any) { interceptedUri = reqOpts.uri; return reqOpts; }, @@ -86,7 +97,8 @@ describe('BigQuery System Integration (URI encoding and path traversal protectio // Mock makeAuthenticatedRequest to return mock metadata without network calls bigquery.makeAuthenticatedRequest = ( - reqOpts: common.DecorateRequestOptions, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reqOpts: any, // eslint-disable-next-line @typescript-eslint/no-explicit-any callback?: any, ) => { From 766df9b4bf1b72a63c4dde6209409aedc2118540 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 16:47:19 -0400 Subject: [PATCH 18/21] Move the tests to traversal.ts --- handwritten/bigquery/system-test/security.ts | 125 ----------------- handwritten/bigquery/system-test/traversal.ts | 128 ++++++++++++++++++ 2 files changed, 128 insertions(+), 125 deletions(-) delete mode 100644 handwritten/bigquery/system-test/security.ts create mode 100644 handwritten/bigquery/system-test/traversal.ts diff --git a/handwritten/bigquery/system-test/security.ts b/handwritten/bigquery/system-test/security.ts deleted file mode 100644 index bc6609705455..000000000000 --- a/handwritten/bigquery/system-test/security.ts +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import * as assert from 'assert'; -import {describe, it, before} from 'mocha'; -import * as proxyquire from 'proxyquire'; -import {GoogleAuth} from 'google-auth-library'; - -// Load the local build of @google-cloud/common from this branch -// eslint-disable-next-line @typescript-eslint/no-var-requires -const common = require('../../../../core/common/build/src'); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let BigQuery: any; - -describe('BigQuery System Security (URI encoding and path traversal protection)', () => { - before(() => { - // Inject the local common build prototypes and utilities - // eslint-disable-next-line @typescript-eslint/no-var-requires - const oldCommon = require('@google-cloud/common'); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const oldUtil = require('@google-cloud/common/build/src/util'); - Object.assign(oldCommon.ServiceObject.prototype, common.ServiceObject.prototype); - Object.assign(oldCommon.Service.prototype, common.Service.prototype); - Object.assign(oldCommon.util, common.util); - Object.assign(oldUtil, common.util); - - BigQuery = proxyquire('../src', { - '@google-cloud/common': common, - }).BigQuery; - }); - - const fakeAuthClient = Object.assign(new GoogleAuth(), { - getCredentials: async () => ({}), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - authorizeRequest: async (reqOpts: any) => reqOpts, - getProjectId: async () => 'test-project', - }); - - it('should throw an error when dataset id is a path traversal segment (..)', async () => { - const bigquery = new BigQuery({ - projectId: 'test-project', - authClient: fakeAuthClient, - }); - - const invalidDataset = bigquery.dataset('..'); - - await assert.rejects( - async () => { - await invalidDataset.getMetadata(); - }, - /Invalid value \.\. for path segment/, - ); - }); - - it('should throw an error when table id is a path traversal segment (.)', async () => { - const bigquery = new BigQuery({ - projectId: 'test-project', - authClient: fakeAuthClient, - }); - - const invalidTable = bigquery.dataset('valid-dataset').table('.'); - - await assert.rejects( - async () => { - await invalidTable.getMetadata(); - }, - /Invalid value \. for path segment/, - ); - }); - - it('should percent-encode query parameter and fragment characters in table names', async () => { - const bigquery = new BigQuery({ - projectId: 'test-project', - authClient: fakeAuthClient, - }); - - let interceptedUri: string | undefined; - bigquery.interceptors.push({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - request(reqOpts: any) { - interceptedUri = reqOpts.uri; - return reqOpts; - }, - }); - - // Mock makeAuthenticatedRequest to return mock metadata without network calls - bigquery.makeAuthenticatedRequest = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - reqOpts: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - callback?: any, - ) => { - const response = {kind: 'bigquery#table', id: 'mock-table'}; - if (typeof callback === 'function') { - callback(null, response, response); - } - return undefined; - }; - - const maliciousTableId = 'table_name?param=value#tag'; - const table = bigquery.dataset('my_dataset').table(maliciousTableId); - - await table.getMetadata(); - - assert.strictEqual(typeof interceptedUri, 'string'); - assert.ok( - (interceptedUri as unknown as string).includes( - 'datasets/my_dataset/tables/table_name%3Fparam%3Dvalue%23tag', - ), - `Expected URI to contain encoded table ID, but received: ${interceptedUri}`, - ); - }); -}); diff --git a/handwritten/bigquery/system-test/traversal.ts b/handwritten/bigquery/system-test/traversal.ts new file mode 100644 index 000000000000..c88b0461e7fa --- /dev/null +++ b/handwritten/bigquery/system-test/traversal.ts @@ -0,0 +1,128 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import {describe, it, before} from 'mocha'; +import * as proxyquire from 'proxyquire'; +import {GoogleAuth} from 'google-auth-library'; + +// Load the local build of @google-cloud/common from this branch +// eslint-disable-next-line @typescript-eslint/no-var-requires +const common = require('../../../../core/common/build/src'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let BigQuery: any; + +describe('BigQuery URI path handling and traversal', () => { + before(() => { + // Inject the local common build prototypes and utilities + // eslint-disable-next-line @typescript-eslint/no-var-requires + const oldCommon = require('@google-cloud/common'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const oldUtil = require('@google-cloud/common/build/src/util'); + Object.assign(oldCommon.ServiceObject.prototype, common.ServiceObject.prototype); + Object.assign(oldCommon.Service.prototype, common.Service.prototype); + Object.assign(oldCommon.util, common.util); + Object.assign(oldUtil, common.util); + + BigQuery = proxyquire('../src', { + '@google-cloud/common': common, + }).BigQuery; + }); + + const fakeAuthClient = Object.assign(new GoogleAuth(), { + getCredentials: async () => ({}), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authorizeRequest: async (reqOpts: any) => reqOpts, + getProjectId: async () => 'test-project', + }); + + const testCases = [ + { + description: 'should reject dot segment (.)', + datasetId: '.', + expectedError: /Invalid value \. for path segment/, + }, + { + description: 'should reject dot-dot segment (..)', + datasetId: '..', + expectedError: /Invalid value \.\. for path segment/, + }, + { + description: 'should reject percent-encoded dot-dot (%2e%2e)', + datasetId: '%2e%2e', + expectedError: /Invalid value \.\. for path segment/, + }, + { + description: 'should reject uppercase percent-encoded dot-dot (%2E%2E)', + datasetId: '%2E%2E', + expectedError: /Invalid value \.\. for path segment/, + }, + { + description: 'should reject paths containing dot-dot segment (foo/../bar)', + datasetId: 'foo/../bar', + expectedError: /Invalid value \.\. for path segment/, + }, + { + description: + 'should attempt request and encode query parameter and fragment characters', + datasetId: 'dataset_name?param=value#tag', + expectedError: + /Not found: Dataset.*datasets\/dataset_name%3Fparam%3Dvalue%23tag/, + }, + { + description: + 'should attempt request and preserve pre-encoded characters without double encoding', + datasetId: 'my%20dataset', + expectedError: /Not found: Dataset.*datasets\/my%20dataset/, + }, + { + description: 'should attempt request for standard dataset name', + datasetId: 'valid_dataset_123', + expectedError: /Not found: Dataset.*datasets\/valid_dataset_123/, + }, + ]; + + for (const {description, datasetId, expectedError} of testCases) { + it(description, async () => { + const bigquery = new BigQuery({ + projectId: 'test-project', + authClient: fakeAuthClient, + }); + + // Mock makeAuthenticatedRequest to return a 404 error containing the requested URI + bigquery.makeAuthenticatedRequest = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reqOpts: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback?: any, + ) => { + const notFoundError = new common.ApiError({ + message: `Not found: Dataset ${reqOpts.uri}`, + code: 404, + }); + if (typeof callback === 'function') { + callback(notFoundError, null, null); + } + return undefined; + }; + + const dataset = bigquery.dataset(datasetId); + + await assert.rejects(async () => { + await dataset.getMetadata(); + }, expectedError); + }); + } +}); From 873359fc2d925de0c3c2ab2dacd0d87fdd9b6f4f Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 16:50:40 -0400 Subject: [PATCH 19/21] removed these tests. They are not needed anymore. --- handwritten/bigquery/test/dataset.ts | 33 ---------------------------- 1 file changed, 33 deletions(-) diff --git a/handwritten/bigquery/test/dataset.ts b/handwritten/bigquery/test/dataset.ts index dfb1ef0a1a4a..26bb3ff8a831 100644 --- a/handwritten/bigquery/test/dataset.ts +++ b/handwritten/bigquery/test/dataset.ts @@ -1104,37 +1104,4 @@ describe('BigQuery/Dataset', () => { assert.strictEqual(table.location, location); }); }); - - // Skipped for now: Waiting for the release of the updated @google-cloud/common - // library containing the path traversal validation and URI encoding fixes. - describe.skip('security - URI encoding and path traversal protection', () => { - it('should throw error when dataset id or path segment is dot or dot-dot', () => { - const bigqueryMock = { - projectId: 'my-project', - request: util.noop, - } as {} as _root.BigQuery; - - assert.throws(() => { - const invalidDataset = new Dataset(bigqueryMock, '..'); - invalidDataset.getMetadata(assert.ifError); - }, /Invalid value \.\. for path segment/); - }); - - it('should percent-encode query parameter injection payload in table name', done => { - const maliciousTableId = 'table_name?param=value#tag'; - const table = ds.table(maliciousTableId); - - ds.request = (reqOpts: DecorateRequestOptions) => { - assert.strictEqual( - reqOpts.uri, - 'tables/table_name%3Fparam%3Dvalue%23tag', - ); - done(); - }; - - table.getMetadata(assert.ifError); - }); - }); }); - - From 94f4a1de8c045d0f65da48308eed39f6720318ac Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 16:53:28 -0400 Subject: [PATCH 20/21] Add a TODO for after common is released --- handwritten/bigquery/system-test/traversal.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/handwritten/bigquery/system-test/traversal.ts b/handwritten/bigquery/system-test/traversal.ts index c88b0461e7fa..61fc462a6403 100644 --- a/handwritten/bigquery/system-test/traversal.ts +++ b/handwritten/bigquery/system-test/traversal.ts @@ -17,6 +17,10 @@ import {describe, it, before} from 'mocha'; import * as proxyquire from 'proxyquire'; import {GoogleAuth} from 'google-auth-library'; +// TODO: Remove proxyquire and the local @google-cloud/common injection below +// after the new version of @google-cloud/common is released to npm and bumped in package.json. +// Once released, standard `import {BigQuery} from '../src'` can be used directly. + // Load the local build of @google-cloud/common from this branch // eslint-disable-next-line @typescript-eslint/no-var-requires const common = require('../../../../core/common/build/src'); From 8ac4dfcf401f4c87eeb7c1dd6a52f1913aa80ca7 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 16:59:36 -0400 Subject: [PATCH 21/21] Make encodings explicit --- handwritten/bigquery/system-test/traversal.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/handwritten/bigquery/system-test/traversal.ts b/handwritten/bigquery/system-test/traversal.ts index 61fc462a6403..6aec81758591 100644 --- a/handwritten/bigquery/system-test/traversal.ts +++ b/handwritten/bigquery/system-test/traversal.ts @@ -64,12 +64,26 @@ describe('BigQuery URI path handling and traversal', () => { expectedError: /Invalid value \.\. for path segment/, }, { - description: 'should reject percent-encoded dot-dot (%2e%2e)', + description: + 'should reject percent-encoded dot (period . encoded as %2e)', + datasetId: '%2e', + expectedError: /Invalid value \. for path segment/, + }, + { + description: + 'should reject uppercase percent-encoded dot (period . encoded as %2E)', + datasetId: '%2E', + expectedError: /Invalid value \. for path segment/, + }, + { + description: + 'should reject percent-encoded dot-dot (.. encoded as %2e%2e)', datasetId: '%2e%2e', expectedError: /Invalid value \.\. for path segment/, }, { - description: 'should reject uppercase percent-encoded dot-dot (%2E%2E)', + description: + 'should reject uppercase percent-encoded dot-dot (.. encoded as %2E%2E)', datasetId: '%2E%2E', expectedError: /Invalid value \.\. for path segment/, }, @@ -80,14 +94,14 @@ describe('BigQuery URI path handling and traversal', () => { }, { description: - 'should attempt request and encode query parameter and fragment characters', + 'should attempt request and encode query parameter (?) and fragment (#) characters', datasetId: 'dataset_name?param=value#tag', expectedError: /Not found: Dataset.*datasets\/dataset_name%3Fparam%3Dvalue%23tag/, }, { description: - 'should attempt request and preserve pre-encoded characters without double encoding', + 'should attempt request and preserve pre-encoded space (space encoded as %20) without double encoding', datasetId: 'my%20dataset', expectedError: /Not found: Dataset.*datasets\/my%20dataset/, },