From f2031d9d97461e222fca792cbca37c7983f03ec4 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Tue, 18 Aug 2026 10:42:58 -0400 Subject: [PATCH 01/36] Do . and .. check and percent encode --- .../src/apirequest.ts | 15 + .../nodejs-googleapis-common/src/http2.ts | 2 +- .../nodejs-googleapis-common/src/index.ts | 1 + .../src/transcoding.ts | 217 ++++++++++++++ .../test/test.apirequest.ts | 130 +++++++++ .../test/test.transcoding.ts | 272 ++++++++++++++++++ .../nodejs-googleapis-common/tsconfig.json | 3 +- 7 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 core/packages/nodejs-googleapis-common/src/transcoding.ts create mode 100644 core/packages/nodejs-googleapis-common/test/test.transcoding.ts diff --git a/core/packages/nodejs-googleapis-common/src/apirequest.ts b/core/packages/nodejs-googleapis-common/src/apirequest.ts index 37811edf7b14..13aafcece550 100644 --- a/core/packages/nodejs-googleapis-common/src/apirequest.ts +++ b/core/packages/nodejs-googleapis-common/src/apirequest.ts @@ -24,6 +24,7 @@ import {SchemaParameters} from './schema'; import * as h2 from './http2'; import {GaxiosResponseWithHTTP2} from './http2'; import {headersToClassicHeaders, marshallGaxiosResponse} from './util'; +import {validateAndEncodePathParams} from './transcoding'; // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('../../package.json'); @@ -164,6 +165,20 @@ async function createAPIRequestAsync( throw new Error('Missing required parameters: ' + missingParams.join(', ')); } + // Validate and encode path params to prevent traversal and injection attacks + validateAndEncodePathParams( + [ + options.url !== undefined && options.url !== null + ? typeof options.url === 'object' + ? options.url.toString() + : options.url + : undefined, + parameters.mediaUrl ?? undefined, + ], + params, + parameters.pathParams, + ); + // Parse urls if (options.url) { let url = options.url; diff --git a/core/packages/nodejs-googleapis-common/src/http2.ts b/core/packages/nodejs-googleapis-common/src/http2.ts index 33a9af182910..d71979d1ab6e 100644 --- a/core/packages/nodejs-googleapis-common/src/http2.ts +++ b/core/packages/nodejs-googleapis-common/src/http2.ts @@ -67,7 +67,7 @@ export async function request( opts.validateStatus = opts.validateStatus || validateStatus; opts.responseType = opts.responseType || 'json'; - const url = new URL(opts.url!); + const url = new URL(opts.url!.toString()); // Check for an existing session to this host, or go create a new one. const sessionData = _getClient(url.host); diff --git a/core/packages/nodejs-googleapis-common/src/index.ts b/core/packages/nodejs-googleapis-common/src/index.ts index b8cec5e464c2..81d9211256b4 100644 --- a/core/packages/nodejs-googleapis-common/src/index.ts +++ b/core/packages/nodejs-googleapis-common/src/index.ts @@ -70,3 +70,4 @@ export { export {GaxiosResponseWithHTTP2} from './http2'; export * from './util'; +export * from './transcoding'; diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts new file mode 100644 index 000000000000..481b6c75b75e --- /dev/null +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -0,0 +1,217 @@ +// 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. + +/** + * Validates a single path segment matched by a single wildcard (*) or {param}. + * Checks that the segment is not exactly '.' or '..' (directory traversal indicators). + * + * @param propertyName Name of the parameter being validated + * @param value Value of the path segment + */ +export function validateSingleSegment( + 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 (**) or {+param}. + * 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). + * + * @param propertyName Name of the parameter being validated + * @param value Value of the multi-segment path + */ +export function validateMultiSegment( + propertyName: string, + value: string, +): void { + if (value) { + const segments = value.split('/'); + if (segments.some(segment => segment === '.' || segment === '..')) { + throw new Error( + `Value for ${propertyName} must not contain segments that are exactly . or ..`, + ); + } + } +} + +/** + * Strictly percent-encodes a string according to RFC 3986. + * 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 str The input string to encode + * @returns The strictly percent-encoded string + */ +export function strictEncodeURIComponent(str: string): string { + return encodeURIComponent(str).replace( + /[!'()*]/g, + character => '%' + character.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. + * + * @param str The input string to encode + * @returns The percent-encoded string + */ +export function encodeWithSlashes(str: string): string { + return [...str] + .map(c => (c.match(/[-_.~0-9a-zA-Z]/) ? c : strictEncodeURIComponent(c))) + .join(''); +} + +/** + * Percent-encodes a string according to RFC 3986, preserving unreserved + * characters (alpha-numeric, '-', '_', '.', and '~') and slashes ('/'). All other + * characters are percent-encoded. + * + * @param str The input string to encode + * @returns The percent-encoded string with slashes preserved + */ +export function encodeWithoutSlashes(str: string): string { + return [...str] + .map(c => (c.match(/[-_.~0-9a-zA-Z/]/) ? c : strictEncodeURIComponent(c))) + .join(''); +} + +/** + * Extracts template parameter names and classifies them as multi-segment or single-segment. + * + * @param urlTemplate The RFC 6570 URI template string + */ +export function extractTemplateParams(urlTemplate: string): { + multiSegmentParams: Set; + singleSegmentParams: Set; +} { + const multiSegmentParams = new Set(); + const singleSegmentParams = new Set(); + const regex = /\{([^}]+)\}/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(urlTemplate)) !== null) { + const expression = match[1]; + if (expression.startsWith('+')) { + const vars = expression.slice(1).split(','); + for (const v of vars) { + const paramName = v.replace(/^([^:*]+).*/, '$1').trim(); + if (paramName) { + multiSegmentParams.add(paramName); + } + } + } else { + const firstChar = expression.charAt(0); + const rawExpr = ['#', '.', '/', ';', '?', '&'].includes(firstChar) + ? expression.slice(1) + : expression; + const vars = rawExpr.split(','); + for (const v of vars) { + const paramName = v.replace(/^([^:*]+).*/, '$1').trim(); + if (paramName) { + singleSegmentParams.add(paramName); + } + } + } + } + + return {multiSegmentParams, singleSegmentParams}; +} + +/** + * Validates path parameters against traversal attacks ('.' and '..') and encodes + * multi-segment parameters so that reserved characters (query params, fragments, etc.) + * cannot be injected into the path. + * + * @param urlTemplates List of URL templates associated with the request (e.g. url, mediaUrl) + * @param params Request parameters dictionary (modified in-place) + * @param pathParams List of path parameter names + */ +export function validateAndEncodePathParams( + urlTemplates: (string | undefined)[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: Record, + pathParams?: string[], +): void { + if (!params || typeof params !== 'object') { + return; + } + const multiSegmentParams = new Set(); + const singleSegmentParams = new Set(); + + for (const tmpl of urlTemplates) { + if (tmpl) { + const extracted = extractTemplateParams(tmpl); + for (const p of extracted.multiSegmentParams) { + multiSegmentParams.add(p); + } + for (const p of extracted.singleSegmentParams) { + singleSegmentParams.add(p); + } + } + } + + if (pathParams && Array.isArray(pathParams)) { + for (const p of pathParams) { + const normalizedP = p.replace(/_$/, ''); + if (!multiSegmentParams.has(p) && !multiSegmentParams.has(normalizedP)) { + singleSegmentParams.add(p); + if (normalizedP !== p) { + singleSegmentParams.add(normalizedP); + } + } + } + } + + // Validate and encode multi-segment parameters + for (const param of multiSegmentParams) { + const val = params[param]; + if (val !== undefined && val !== null) { + if (Array.isArray(val)) { + for (const item of val) { + validateMultiSegment(param, String(item)); + } + params[param] = val.map(item => encodeWithoutSlashes(String(item))); + } else { + validateMultiSegment(param, String(val)); + params[param] = encodeWithoutSlashes(String(val)); + } + } + } + + // Validate single-segment parameters + for (const param of singleSegmentParams) { + if (multiSegmentParams.has(param)) { + continue; + } + const val = params[param]; + if (val !== undefined && val !== null) { + if (Array.isArray(val)) { + for (const item of val) { + validateSingleSegment(param, String(item)); + } + } else { + validateSingleSegment(param, String(val)); + } + } + } +} diff --git a/core/packages/nodejs-googleapis-common/test/test.apirequest.ts b/core/packages/nodejs-googleapis-common/test/test.apirequest.ts index 8e2e3b153cde..0338366874a6 100644 --- a/core/packages/nodejs-googleapis-common/test/test.apirequest.ts +++ b/core/packages/nodejs-googleapis-common/test/test.apirequest.ts @@ -763,4 +763,134 @@ describe('createAPIRequest', () => { ); }); }); + + describe('path parameter validation and security', () => { + it('should throw an error for single-segment path traversal containing "." or ".."', async () => { + await assert.rejects( + createAPIRequest({ + options: {url: 'https://example.com/drive/v3/files/{fileId}'}, + params: {fileId: '.'}, + requiredParams: [], + pathParams: ['fileId'], + context: fakeContext, + }), + /Invalid value \. for fileId/, + ); + + await assert.rejects( + createAPIRequest({ + options: {url: 'https://example.com/drive/v3/files/{fileId}'}, + params: {fileId: '..'}, + requiredParams: [], + pathParams: ['fileId'], + context: fakeContext, + }), + /Invalid value \.\. for fileId/, + ); + }); + + it('should throw an error for multi-segment path traversal containing "." or ".." segments', async () => { + await assert.rejects( + createAPIRequest({ + options: { + url: 'https://dialogflow.googleapis.com/v3/{+session}:detectIntent', + }, + params: { + session: + 'projects/p/locations/l/agents/a/sessions/agents/../subagent', + }, + requiredParams: [], + pathParams: ['session'], + context: fakeContext, + }), + /Value for session must not contain segments that are exactly \. or \.\./, + ); + + await assert.rejects( + createAPIRequest({ + options: { + url: 'https://dialogflow.googleapis.com/v3/{+session}:detectIntent', + }, + params: { + session: + 'projects/p/locations/l/agents/a/sessions/agents/./subagent', + }, + requiredParams: [], + pathParams: ['session'], + context: fakeContext, + }), + /Value for session must not contain segments that are exactly \. or \.\./, + ); + }); + + it('should protect against query parameter and fragment injection by percent-encoding in path parameters', async () => { + const p = + '/v3/projects/p/locations/l/agents/a/sessions/my-session%3F%24httpMethod%3DDELETE%23:detectIntent'; + const scope = nock('https://dialogflow.googleapis.com') + .post(p) + .reply(200, {}); + + const res = await createAPIRequest({ + options: { + url: 'https://dialogflow.googleapis.com/v3/{+session}:detectIntent', + method: 'POST', + }, + params: { + session: + 'projects/p/locations/l/agents/a/sessions/my-session?$httpMethod=DELETE#', + }, + requiredParams: [], + pathParams: ['session'], + context: fakeContext, + }); + + assert.ok(res.config.url?.toString().endsWith(p)); + scope.done(); + }); + + it('should strictly percent-encode reserved characters while preserving unreserved characters and slashes in reserved parameters', async () => { + const p = + '/v3/projects/p/locations/l/agents/a/sessions/%20%21%40%24%26%27%28%29%2A%2B%2C%3B%3D%3A%25:detectIntent'; + const scope = nock('https://dialogflow.googleapis.com') + .post(p) + .reply(200, {}); + + const res = await createAPIRequest({ + options: { + url: 'https://dialogflow.googleapis.com/v3/{+session}:detectIntent', + method: 'POST', + }, + params: { + session: "projects/p/locations/l/agents/a/sessions/ !@$&'()*+,;=:%", + }, + requiredParams: [], + pathParams: ['session'], + context: fakeContext, + }); + + assert.ok(res.config.url?.toString().endsWith(p)); + scope.done(); + }); + + it('should allow valid domain-scoped resource paths containing dots and colon', async () => { + const p = '/v1/projects/example.com%3Amy-project/locations/us-central1'; + const scope = nock('https://example.com').get(p).reply(200, {}); + + const res = await createAPIRequest({ + options: { + url: 'https://example.com/v1/{+parent}', + method: 'GET', + }, + params: { + parent: 'projects/example.com:my-project/locations/us-central1', + }, + requiredParams: [], + pathParams: ['parent'], + context: fakeContext, + }); + + assert.ok(res.config.url?.toString().endsWith(p)); + scope.done(); + }); + }); }); diff --git a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts new file mode 100644 index 000000000000..3d8fd098e2a8 --- /dev/null +++ b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts @@ -0,0 +1,272 @@ +// 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} from 'mocha'; +import { + validateSingleSegment, + validateMultiSegment, + strictEncodeURIComponent, + encodeWithSlashes, + encodeWithoutSlashes, + extractTemplateParams, + validateAndEncodePathParams, +} from '../src/transcoding'; + +describe('transcoding', () => { + describe('validateSingleSegment', () => { + it('should throw for "."', () => { + assert.throws(() => { + validateSingleSegment('fileId', '.'); + }, /Invalid value \. for fileId/); + }); + + it('should throw for ".."', () => { + assert.throws(() => { + validateSingleSegment('fileId', '..'); + }, /Invalid value \.\. for fileId/); + }); + + it('should allow valid single segment names', () => { + assert.doesNotThrow(() => { + validateSingleSegment('fileId', 'valid-id'); + validateSingleSegment('fileId', 'file.txt'); + validateSingleSegment('fileId', 'example.com'); + }); + }); + }); + + describe('validateMultiSegment', () => { + it('should throw for ".." segment in multi-segment path', () => { + assert.throws(() => { + validateMultiSegment( + 'session', + 'projects/p/locations/l/agents/a/sessions/agents/../subagent', + ); + }, /Value for session must not contain segments that are exactly \. or \.\./); + + assert.throws(() => { + validateMultiSegment('name', '..'); + }, /Value for name must not contain segments that are exactly \. or \.\./); + + assert.throws(() => { + validateMultiSegment('name', 'a/b/..'); + }, /Value for name must not contain segments that are exactly \. or \.\./); + }); + + it('should throw for "." segment in multi-segment path', () => { + assert.throws(() => { + validateMultiSegment( + 'session', + 'projects/p/locations/l/agents/a/sessions/agents/./subagent', + ); + }, /Value for session must not contain segments that are exactly \. or \.\./); + + assert.throws(() => { + validateMultiSegment('name', '.'); + }, /Value for name must not contain segments that are exactly \. or \.\./); + + assert.throws(() => { + validateMultiSegment('name', './a/b'); + }, /Value for name must not contain segments that are exactly \. or \.\./); + }); + + it('should allow valid domain-scoped and resource paths with dots', () => { + assert.doesNotThrow(() => { + validateMultiSegment( + 'parent', + 'projects/example.com:custom-project/locations/global', + ); + validateMultiSegment( + 'session', + 'projects/p/locations/l/agents/a/sessions/123.456', + ); + validateMultiSegment('name', 'a/b/c'); + }); + }); + + it('should allow empty/falsy values', () => { + assert.doesNotThrow(() => { + validateMultiSegment('name', ''); + }); + }); + }); + + describe('strictEncodeURIComponent and encodeWithSlashes', () => { + it('should preserve unreserved characters', () => { + const unreserved = 'abc-123_.~XYZ'; + assert.strictEqual(strictEncodeURIComponent(unreserved), unreserved); + assert.strictEqual(encodeWithSlashes(unreserved), unreserved); + }); + + it("should strictly percent-encode !'()* and slashes", () => { + assert.strictEqual(strictEncodeURIComponent("!'()*"), '%21%27%28%29%2A'); + assert.strictEqual(encodeWithSlashes('/'), '%2F'); + }); + + it('should properly encode Unicode surrogate pairs / emojis', () => { + assert.strictEqual(strictEncodeURIComponent('😊'), '%F0%9F%98%8A'); + assert.strictEqual(encodeWithSlashes('😊'), '%F0%9F%98%8A'); + }); + }); + + describe('encodeWithoutSlashes', () => { + it('should preserve slashes and unreserved characters', () => { + assert.strictEqual( + encodeWithoutSlashes('projects/my-proj_1.0~v2/locations/us-central1'), + 'projects/my-proj_1.0~v2/locations/us-central1', + ); + }); + + it('should percent-encode query, fragment, and special characters', () => { + const input = + 'projects/p/locations/l/agents/a/sessions/my-session?$httpMethod=DELETE#'; + const expected = + 'projects/p/locations/l/agents/a/sessions/my-session%3F%24httpMethod%3DDELETE%23'; + assert.strictEqual(encodeWithoutSlashes(input), expected); + }); + + it('should percent-encode all reserved characters while preserving slashes', () => { + const input = "projects/p/locations/l/agents/a/sessions/ !@$&'()*+,;=:%"; + const expected = + 'projects/p/locations/l/agents/a/sessions/%20%21%40%24%26%27%28%29%2A%2B%2C%3B%3D%3A%25'; + assert.strictEqual(encodeWithoutSlashes(input), expected); + }); + + it('should handle Unicode surrogate pairs in paths', () => { + assert.strictEqual( + encodeWithoutSlashes('projects/p/sessions/😊'), + 'projects/p/sessions/%F0%9F%98%8A', + ); + }); + }); + + describe('extractTemplateParams', () => { + it('should identify multi-segment parameters from {+param}', () => { + const res = extractTemplateParams( + 'https://example.com/v1/{+name}:approve', + ); + assert.deepStrictEqual(Array.from(res.multiSegmentParams), ['name']); + assert.deepStrictEqual(Array.from(res.singleSegmentParams), []); + }); + + it('should identify single-segment parameters from {param}', () => { + const res = extractTemplateParams( + 'https://example.com/drive/v3/files/{fileId}', + ); + assert.deepStrictEqual(Array.from(res.multiSegmentParams), []); + assert.deepStrictEqual(Array.from(res.singleSegmentParams), ['fileId']); + }); + + it('should identify mixed templates with multiple parameters', () => { + const res = extractTemplateParams( + 'https://example.com/v1/{+parent}/databases/{databaseId}/documents/{+documentPath}', + ); + assert.deepStrictEqual(Array.from(res.multiSegmentParams), [ + 'parent', + 'documentPath', + ]); + assert.deepStrictEqual(Array.from(res.singleSegmentParams), [ + 'databaseId', + ]); + }); + + it('should handle comma-separated template variables', () => { + const res = extractTemplateParams( + 'https://example.com/v1/{var1,var2}/{+multi1,multi2}', + ); + assert.deepStrictEqual(Array.from(res.singleSegmentParams), [ + 'var1', + 'var2', + ]); + assert.deepStrictEqual(Array.from(res.multiSegmentParams), [ + 'multi1', + 'multi2', + ]); + }); + }); + + describe('validateAndEncodePathParams', () => { + it('should validate and encode multi-segment params and validate single-segment params', () => { + const params: Record = { + parent: + 'projects/p/locations/l/agents/a/sessions/my-session?$httpMethod=DELETE#', + fileId: 'file-123', + }; + validateAndEncodePathParams( + ['https://example.com/v1/{+parent}/files/{fileId}'], + params, + ['parent', 'fileId'], + ); + assert.strictEqual( + params.parent, + 'projects/p/locations/l/agents/a/sessions/my-session%3F%24httpMethod%3DDELETE%23', + ); + assert.strictEqual(params.fileId, 'file-123'); + }); + + it('should throw on path traversal in multi-segment params', () => { + const params: Record = { + name: 'projects/p/locations/l/agents/a/sessions/agents/../subagent', + }; + assert.throws(() => { + validateAndEncodePathParams( + ['https://example.com/v1/{+name}'], + params, + ['name'], + ); + }, /Value for name must not contain segments that are exactly \. or \.\./); + }); + + it('should throw on path traversal in single-segment params', () => { + const params: Record = { + fileId: '..', + }; + assert.throws(() => { + validateAndEncodePathParams( + ['https://example.com/drive/v3/files/{fileId}'], + params, + ['fileId'], + ); + }, /Invalid value \.\. for fileId/); + }); + + it('should handle array path parameters', () => { + const params: Record = { + names: ['projects/p/loc/l/a/1?$foo=bar#', 'projects/p/loc/l/a/2'], + }; + validateAndEncodePathParams(['https://example.com/v1/{+names}'], params, [ + 'names', + ]); + assert.deepStrictEqual(params.names, [ + 'projects/p/loc/l/a/1%3F%24foo%3Dbar%23', + 'projects/p/loc/l/a/2', + ]); + }); + + it('should handle missing and null params gracefully', () => { + const params: Record = { + name: null, + fileId: undefined, + }; + assert.doesNotThrow(() => { + validateAndEncodePathParams( + ['https://example.com/v1/{+name}/files/{fileId}'], + params, + ['name', 'fileId'], + ); + }); + }); + }); +}); diff --git a/core/packages/nodejs-googleapis-common/tsconfig.json b/core/packages/nodejs-googleapis-common/tsconfig.json index b183f738a00d..ccbb3c0eff20 100644 --- a/core/packages/nodejs-googleapis-common/tsconfig.json +++ b/core/packages/nodejs-googleapis-common/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "lib": ["es2023", "dom"], "rootDir": ".", - "outDir": "build" + "outDir": "build", + "typeRoots": ["./node_modules/@types"] }, "include": [ "src/*.ts", From 2cef1c50e91c485f1e0a394f150d657a56ab8826 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Tue, 18 Aug 2026 10:45:53 -0400 Subject: [PATCH 02/36] clean the tsconfig file --- core/packages/nodejs-googleapis-common/tsconfig.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/tsconfig.json b/core/packages/nodejs-googleapis-common/tsconfig.json index ccbb3c0eff20..b183f738a00d 100644 --- a/core/packages/nodejs-googleapis-common/tsconfig.json +++ b/core/packages/nodejs-googleapis-common/tsconfig.json @@ -3,8 +3,7 @@ "compilerOptions": { "lib": ["es2023", "dom"], "rootDir": ".", - "outDir": "build", - "typeRoots": ["./node_modules/@types"] + "outDir": "build" }, "include": [ "src/*.ts", From 133114fcb997df53c3bcbad418ee4e3587037749 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Tue, 18 Aug 2026 10:55:05 -0400 Subject: [PATCH 03/36] Add a comment about aliases --- core/packages/nodejs-googleapis-common/src/transcoding.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 481b6c75b75e..7e86eea6705a 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -51,6 +51,12 @@ export function validateMultiSegment( } } +/** + * Aliases for compatibility with GAX naming conventions. + */ +export const validateUriPathSegment = validateSingleSegment; +export const validateUriPath = validateMultiSegment; + /** * Strictly percent-encodes a string according to RFC 3986. * This is necessary because encodeURIComponent natively encodes URL-unsafe From 2e935977ad0468303012d4bc63ce206066921bc9 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Tue, 18 Aug 2026 11:05:22 -0400 Subject: [PATCH 04/36] Add some comments to the encoding --- .../src/transcoding.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 7e86eea6705a..6f0f3a6bc440 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -158,12 +158,20 @@ export function validateAndEncodePathParams( params: Record, pathParams?: string[], ): void { + // Early return if params is undefined, null, or not an object if (!params || typeof params !== 'object') { return; } + + // Track which parameters are multi-segment ({+param}) vs single-segment ({param}). + // - Multi-segment parameters allow slashes ('/') for hierarchical resource paths, + // requiring segment-by-segment traversal checks and strict percent-encoding with slashes preserved. + // - Single-segment parameters disallow slashes and are automatically percent-encoded by url-template, + // requiring only direct '.' and '..' traversal validation. const multiSegmentParams = new Set(); const singleSegmentParams = new Set(); + // 1. Scan provided URL templates (options.url and parameters.mediaUrl) to extract parameter names for (const tmpl of urlTemplates) { if (tmpl) { const extracted = extractTemplateParams(tmpl); @@ -176,6 +184,8 @@ export function validateAndEncodePathParams( } } + // 2. Include any declared pathParams from the API metadata that were not found in template expressions. + // Also check for un-aliased names (e.g. 'resource_' -> 'resource') used to avoid JavaScript reserved words. if (pathParams && Array.isArray(pathParams)) { for (const p of pathParams) { const normalizedP = p.replace(/_$/, ''); @@ -188,7 +198,12 @@ export function validateAndEncodePathParams( } } - // Validate and encode multi-segment parameters + // 3. Process multi-segment parameters ({+param}): + // - Validate that no individual path segment is '.' or '..' (rejecting path traversal while + // permitting valid domain-scoped names like 'projects/example.com:my-project'). + // - Pre-encode with encodeWithoutSlashes so that reserved characters ('?', '#', '$', '&', '=') + // are strictly percent-encoded according to RFC 3986 before url-template reserved expansion runs, + // preventing query parameter and fragment injection while keeping slashes ('/') intact. for (const param of multiSegmentParams) { const val = params[param]; if (val !== undefined && val !== null) { @@ -204,8 +219,13 @@ export function validateAndEncodePathParams( } } - // Validate single-segment parameters + // 4. Process single-segment parameters ({param}): + // - Validate that the segment is not exactly '.' or '..' to block path traversal. + // - Note: We do NOT pre-encode single-segment values here because url-template standard expansion + // ({param}) automatically applies strict percent-encoding to all reserved characters; + // pre-encoding would lead to double percent-encoding (%25...). for (const param of singleSegmentParams) { + // Skip if already processed under multiSegmentParams if (multiSegmentParams.has(param)) { continue; } From 0f2977c29c340610e3408f905a3d11ef1d84df11 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Tue, 18 Aug 2026 11:29:57 -0400 Subject: [PATCH 05/36] Eliminate the url tostring change --- core/packages/nodejs-googleapis-common/src/http2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/packages/nodejs-googleapis-common/src/http2.ts b/core/packages/nodejs-googleapis-common/src/http2.ts index d71979d1ab6e..33a9af182910 100644 --- a/core/packages/nodejs-googleapis-common/src/http2.ts +++ b/core/packages/nodejs-googleapis-common/src/http2.ts @@ -67,7 +67,7 @@ export async function request( opts.validateStatus = opts.validateStatus || validateStatus; opts.responseType = opts.responseType || 'json'; - const url = new URL(opts.url!.toString()); + const url = new URL(opts.url!); // Check for an existing session to this host, or go create a new one. const sessionData = _getClient(url.host); From cf8edea419ec25fd046baa5e294246c819b03b8b Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Tue, 18 Aug 2026 11:33:44 -0400 Subject: [PATCH 06/36] Remove the transcoding import --- core/packages/nodejs-googleapis-common/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/core/packages/nodejs-googleapis-common/src/index.ts b/core/packages/nodejs-googleapis-common/src/index.ts index 81d9211256b4..b8cec5e464c2 100644 --- a/core/packages/nodejs-googleapis-common/src/index.ts +++ b/core/packages/nodejs-googleapis-common/src/index.ts @@ -70,4 +70,3 @@ export { export {GaxiosResponseWithHTTP2} from './http2'; export * from './util'; -export * from './transcoding'; From d6ce21fc9f8af8ed2b2f4d402c2bc3aa7b3f6d78 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Tue, 18 Aug 2026 11:59:22 -0400 Subject: [PATCH 07/36] Separate the two methods --- .../src/apirequest.ts | 8 +- .../src/transcoding.ts | 57 ++++++++----- .../test/test.transcoding.ts | 79 +++++++++++++++++++ 3 files changed, 120 insertions(+), 24 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/apirequest.ts b/core/packages/nodejs-googleapis-common/src/apirequest.ts index 13aafcece550..8c79f7449ae5 100644 --- a/core/packages/nodejs-googleapis-common/src/apirequest.ts +++ b/core/packages/nodejs-googleapis-common/src/apirequest.ts @@ -24,7 +24,7 @@ import {SchemaParameters} from './schema'; import * as h2 from './http2'; import {GaxiosResponseWithHTTP2} from './http2'; import {headersToClassicHeaders, marshallGaxiosResponse} from './util'; -import {validateAndEncodePathParams} from './transcoding'; +import {normalizePathParams, validateAndEncodeParams} from './transcoding'; // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('../../package.json'); @@ -157,6 +157,9 @@ async function createAPIRequestAsync( } }); + // Un-alias path parameters that were modified due to conflicts with reserved names + normalizePathParams(parameters.pathParams); + // Check for missing required parameters in the API request const missingParams = getMissingParams(params, parameters.requiredParams); if (missingParams) { @@ -166,7 +169,7 @@ async function createAPIRequestAsync( } // Validate and encode path params to prevent traversal and injection attacks - validateAndEncodePathParams( + validateAndEncodeParams( [ options.url !== undefined && options.url !== null ? typeof options.url === 'object' @@ -176,7 +179,6 @@ async function createAPIRequestAsync( parameters.mediaUrl ?? undefined, ], params, - parameters.pathParams, ); // Parse urls diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 6f0f3a6bc440..244022750c4d 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -143,20 +143,36 @@ export function extractTemplateParams(urlTemplate: string): { return {multiSegmentParams, singleSegmentParams}; } +/** + * Modifies the pathParams array in-place to normalize / un-alias parameters + * that have trailing underscores (e.g. 'resource_' -> 'resource') due to + * conflicts with JavaScript reserved words. + * + * @param pathParams List of path parameter names to normalize in-place + */ +export function normalizePathParams(pathParams?: string[]): void { + if (!pathParams || !Array.isArray(pathParams)) { + return; + } + for (let i = 0; i < pathParams.length; i++) { + if (pathParams[i].slice(-1) === '_') { + pathParams[i] = pathParams[i].slice(0, -1); + } + } +} + /** * Validates path parameters against traversal attacks ('.' and '..') and encodes - * multi-segment parameters so that reserved characters (query params, fragments, etc.) - * cannot be injected into the path. + * multi-segment parameters in params so that reserved characters (query params, fragments, etc.) + * cannot be injected into the path. Modifies params in-place. * * @param urlTemplates List of URL templates associated with the request (e.g. url, mediaUrl) * @param params Request parameters dictionary (modified in-place) - * @param pathParams List of path parameter names */ -export function validateAndEncodePathParams( +export function validateAndEncodeParams( urlTemplates: (string | undefined)[], // eslint-disable-next-line @typescript-eslint/no-explicit-any params: Record, - pathParams?: string[], ): void { // Early return if params is undefined, null, or not an object if (!params || typeof params !== 'object') { @@ -184,21 +200,7 @@ export function validateAndEncodePathParams( } } - // 2. Include any declared pathParams from the API metadata that were not found in template expressions. - // Also check for un-aliased names (e.g. 'resource_' -> 'resource') used to avoid JavaScript reserved words. - if (pathParams && Array.isArray(pathParams)) { - for (const p of pathParams) { - const normalizedP = p.replace(/_$/, ''); - if (!multiSegmentParams.has(p) && !multiSegmentParams.has(normalizedP)) { - singleSegmentParams.add(p); - if (normalizedP !== p) { - singleSegmentParams.add(normalizedP); - } - } - } - } - - // 3. Process multi-segment parameters ({+param}): + // 2. Process multi-segment parameters ({+param}): // - Validate that no individual path segment is '.' or '..' (rejecting path traversal while // permitting valid domain-scoped names like 'projects/example.com:my-project'). // - Pre-encode with encodeWithoutSlashes so that reserved characters ('?', '#', '$', '&', '=') @@ -219,7 +221,7 @@ export function validateAndEncodePathParams( } } - // 4. Process single-segment parameters ({param}): + // 3. Process single-segment parameters ({param}): // - Validate that the segment is not exactly '.' or '..' to block path traversal. // - Note: We do NOT pre-encode single-segment values here because url-template standard expansion // ({param}) automatically applies strict percent-encoding to all reserved characters; @@ -241,3 +243,16 @@ export function validateAndEncodePathParams( } } } + +/** + * Backward compatibility helper combining pathParams normalization and params validation/encoding. + */ +export function validateAndEncodePathParams( + urlTemplates: (string | undefined)[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: Record, + pathParams?: string[], +): void { + normalizePathParams(pathParams); + validateAndEncodeParams(urlTemplates, params); +} diff --git a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts index 3d8fd098e2a8..4dec3038ccbd 100644 --- a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts +++ b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts @@ -21,6 +21,8 @@ import { encodeWithSlashes, encodeWithoutSlashes, extractTemplateParams, + normalizePathParams, + validateAndEncodeParams, validateAndEncodePathParams, } from '../src/transcoding'; @@ -197,6 +199,83 @@ describe('transcoding', () => { }); }); + describe('normalizePathParams', () => { + it('should un-alias trailing underscores in pathParams', () => { + const pathParams = ['resource_', 'project_', 'fileId']; + normalizePathParams(pathParams); + assert.deepStrictEqual(pathParams, ['resource', 'project', 'fileId']); + }); + + it('should handle undefined, null, or empty pathParams safely', () => { + assert.doesNotThrow(() => normalizePathParams(undefined)); + assert.doesNotThrow(() => normalizePathParams([])); + }); + }); + + describe('validateAndEncodeParams', () => { + it('should validate and encode multi-segment params and validate single-segment params', () => { + const params: Record = { + parent: + 'projects/p/locations/l/agents/a/sessions/my-session?$httpMethod=DELETE#', + fileId: 'file-123', + }; + validateAndEncodeParams( + ['https://example.com/v1/{+parent}/files/{fileId}'], + params, + ); + assert.strictEqual( + params.parent, + 'projects/p/locations/l/agents/a/sessions/my-session%3F%24httpMethod%3DDELETE%23', + ); + assert.strictEqual(params.fileId, 'file-123'); + }); + + it('should throw on path traversal in multi-segment params', () => { + const params: Record = { + name: 'projects/p/locations/l/agents/a/sessions/agents/../subagent', + }; + assert.throws(() => { + validateAndEncodeParams(['https://example.com/v1/{+name}'], params); + }, /Value for name must not contain segments that are exactly \. or \.\./); + }); + + it('should throw on path traversal in single-segment params', () => { + const params: Record = { + fileId: '..', + }; + assert.throws(() => { + validateAndEncodeParams( + ['https://example.com/drive/v3/files/{fileId}'], + params, + ); + }, /Invalid value \.\. for fileId/); + }); + + it('should handle array path parameters', () => { + const params: Record = { + names: ['projects/p/loc/l/a/1?$foo=bar#', 'projects/p/loc/l/a/2'], + }; + validateAndEncodeParams(['https://example.com/v1/{+names}'], params); + assert.deepStrictEqual(params.names, [ + 'projects/p/loc/l/a/1%3F%24foo%3Dbar%23', + 'projects/p/loc/l/a/2', + ]); + }); + + it('should handle missing and null params gracefully', () => { + const params: Record = { + name: null, + fileId: undefined, + }; + assert.doesNotThrow(() => { + validateAndEncodeParams( + ['https://example.com/v1/{+name}/files/{fileId}'], + params, + ); + }); + }); + }); + describe('validateAndEncodePathParams', () => { it('should validate and encode multi-segment params and validate single-segment params', () => { const params: Record = { From 0be0c314558f5948ece738b181db0a31f1c2b042 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Tue, 18 Aug 2026 13:03:35 -0400 Subject: [PATCH 08/36] Get rid of the backwards compatibility helper --- .../src/transcoding.ts | 13 ---- .../test/test.transcoding.ts | 74 ------------------- 2 files changed, 87 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 244022750c4d..5b4a85cfd0db 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -243,16 +243,3 @@ export function validateAndEncodeParams( } } } - -/** - * Backward compatibility helper combining pathParams normalization and params validation/encoding. - */ -export function validateAndEncodePathParams( - urlTemplates: (string | undefined)[], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - params: Record, - pathParams?: string[], -): void { - normalizePathParams(pathParams); - validateAndEncodeParams(urlTemplates, params); -} diff --git a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts index 4dec3038ccbd..033460295b06 100644 --- a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts +++ b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts @@ -23,7 +23,6 @@ import { extractTemplateParams, normalizePathParams, validateAndEncodeParams, - validateAndEncodePathParams, } from '../src/transcoding'; describe('transcoding', () => { @@ -275,77 +274,4 @@ describe('transcoding', () => { }); }); }); - - describe('validateAndEncodePathParams', () => { - it('should validate and encode multi-segment params and validate single-segment params', () => { - const params: Record = { - parent: - 'projects/p/locations/l/agents/a/sessions/my-session?$httpMethod=DELETE#', - fileId: 'file-123', - }; - validateAndEncodePathParams( - ['https://example.com/v1/{+parent}/files/{fileId}'], - params, - ['parent', 'fileId'], - ); - assert.strictEqual( - params.parent, - 'projects/p/locations/l/agents/a/sessions/my-session%3F%24httpMethod%3DDELETE%23', - ); - assert.strictEqual(params.fileId, 'file-123'); - }); - - it('should throw on path traversal in multi-segment params', () => { - const params: Record = { - name: 'projects/p/locations/l/agents/a/sessions/agents/../subagent', - }; - assert.throws(() => { - validateAndEncodePathParams( - ['https://example.com/v1/{+name}'], - params, - ['name'], - ); - }, /Value for name must not contain segments that are exactly \. or \.\./); - }); - - it('should throw on path traversal in single-segment params', () => { - const params: Record = { - fileId: '..', - }; - assert.throws(() => { - validateAndEncodePathParams( - ['https://example.com/drive/v3/files/{fileId}'], - params, - ['fileId'], - ); - }, /Invalid value \.\. for fileId/); - }); - - it('should handle array path parameters', () => { - const params: Record = { - names: ['projects/p/loc/l/a/1?$foo=bar#', 'projects/p/loc/l/a/2'], - }; - validateAndEncodePathParams(['https://example.com/v1/{+names}'], params, [ - 'names', - ]); - assert.deepStrictEqual(params.names, [ - 'projects/p/loc/l/a/1%3F%24foo%3Dbar%23', - 'projects/p/loc/l/a/2', - ]); - }); - - it('should handle missing and null params gracefully', () => { - const params: Record = { - name: null, - fileId: undefined, - }; - assert.doesNotThrow(() => { - validateAndEncodePathParams( - ['https://example.com/v1/{+name}/files/{fileId}'], - params, - ['name', 'fileId'], - ); - }); - }); - }); }); From 05f3b8ee0e1114349f6cf61e007decf7a6028153 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Tue, 18 Aug 2026 16:55:58 -0400 Subject: [PATCH 09/36] Adopt changes from other vulnerability PR --- .../src/transcoding.ts | 33 ++++--------------- .../test/test.apirequest.ts | 8 ++--- .../test/test.transcoding.ts | 20 +++++------ 3 files changed, 18 insertions(+), 43 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 5b4a85cfd0db..672985eb21c0 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -58,47 +58,26 @@ export const validateUriPathSegment = validateSingleSegment; export const validateUriPath = validateMultiSegment; /** - * Strictly percent-encodes a string according to RFC 3986. - * 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 str The input string to encode - * @returns The strictly percent-encoded string - */ -export function strictEncodeURIComponent(str: string): string { - return encodeURIComponent(str).replace( - /[!'()*]/g, - character => '%' + character.charCodeAt(0).toString(16).toUpperCase(), - ); -} - -/** - * Percent-encodes a string according to RFC 3986, preserving only unreserved - * characters (alpha-numeric, '-', '_', '.', and '~'). All other characters, + * Percent-encodes a string, preserving only unreserved characters + * (alpha-numeric, '-', '_', '.', and '~'). All other characters, * including slashes ('/'), are percent-encoded. * * @param str The input string to encode * @returns The percent-encoded string */ export function encodeWithSlashes(str: string): string { - return [...str] - .map(c => (c.match(/[-_.~0-9a-zA-Z]/) ? c : strictEncodeURIComponent(c))) - .join(''); + return encodeURIComponent(str); } /** - * Percent-encodes a string according to RFC 3986, preserving unreserved - * characters (alpha-numeric, '-', '_', '.', and '~') and slashes ('/'). All other - * characters are percent-encoded. + * Percent-encodes a string, preserving unreserved characters and slashes ('/'). + * All other characters are percent-encoded. * * @param str The input string to encode * @returns The percent-encoded string with slashes preserved */ export function encodeWithoutSlashes(str: string): string { - return [...str] - .map(c => (c.match(/[-_.~0-9a-zA-Z/]/) ? c : strictEncodeURIComponent(c))) - .join(''); + return str.split('/').map(encodeURIComponent).join('/'); } /** diff --git a/core/packages/nodejs-googleapis-common/test/test.apirequest.ts b/core/packages/nodejs-googleapis-common/test/test.apirequest.ts index 0338366874a6..e07603759057 100644 --- a/core/packages/nodejs-googleapis-common/test/test.apirequest.ts +++ b/core/packages/nodejs-googleapis-common/test/test.apirequest.ts @@ -825,7 +825,7 @@ describe('createAPIRequest', () => { it('should protect against query parameter and fragment injection by percent-encoding in path parameters', async () => { const p = - '/v3/projects/p/locations/l/agents/a/sessions/my-session%3F%24httpMethod%3DDELETE%23:detectIntent'; + '/v3/projects/p/locations/l/agents/a/sessions/my-session%3F%24foo%3DBAR%23:detectIntent'; const scope = nock('https://dialogflow.googleapis.com') .post(p) .reply(200, {}); @@ -837,7 +837,7 @@ describe('createAPIRequest', () => { }, params: { session: - 'projects/p/locations/l/agents/a/sessions/my-session?$httpMethod=DELETE#', + 'projects/p/locations/l/agents/a/sessions/my-session?$foo=BAR#', }, requiredParams: [], pathParams: ['session'], @@ -848,9 +848,9 @@ describe('createAPIRequest', () => { scope.done(); }); - it('should strictly percent-encode reserved characters while preserving unreserved characters and slashes in reserved parameters', async () => { + it('should percent-encode reserved characters while preserving unreserved characters and slashes in reserved parameters', async () => { const p = - '/v3/projects/p/locations/l/agents/a/sessions/%20%21%40%24%26%27%28%29%2A%2B%2C%3B%3D%3A%25:detectIntent'; + "/v3/projects/p/locations/l/agents/a/sessions/%20!%40%24%26'()*%2B%2C%3B%3D%3A%25:detectIntent"; const scope = nock('https://dialogflow.googleapis.com') .post(p) .reply(200, {}); diff --git a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts index 033460295b06..e5402ed34255 100644 --- a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts +++ b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts @@ -17,7 +17,6 @@ import {describe, it} from 'mocha'; import { validateSingleSegment, validateMultiSegment, - strictEncodeURIComponent, encodeWithSlashes, encodeWithoutSlashes, extractTemplateParams, @@ -104,20 +103,17 @@ describe('transcoding', () => { }); }); - describe('strictEncodeURIComponent and encodeWithSlashes', () => { + describe('encodeWithSlashes', () => { it('should preserve unreserved characters', () => { const unreserved = 'abc-123_.~XYZ'; - assert.strictEqual(strictEncodeURIComponent(unreserved), unreserved); assert.strictEqual(encodeWithSlashes(unreserved), unreserved); }); - it("should strictly percent-encode !'()* and slashes", () => { - assert.strictEqual(strictEncodeURIComponent("!'()*"), '%21%27%28%29%2A'); + it('should percent-encode slashes', () => { assert.strictEqual(encodeWithSlashes('/'), '%2F'); }); it('should properly encode Unicode surrogate pairs / emojis', () => { - assert.strictEqual(strictEncodeURIComponent('😊'), '%F0%9F%98%8A'); assert.strictEqual(encodeWithSlashes('😊'), '%F0%9F%98%8A'); }); }); @@ -132,16 +128,16 @@ describe('transcoding', () => { it('should percent-encode query, fragment, and special characters', () => { const input = - 'projects/p/locations/l/agents/a/sessions/my-session?$httpMethod=DELETE#'; + 'projects/p/locations/l/agents/a/sessions/my-session?$foo=BAR#'; const expected = - 'projects/p/locations/l/agents/a/sessions/my-session%3F%24httpMethod%3DDELETE%23'; + 'projects/p/locations/l/agents/a/sessions/my-session%3F%24foo%3DBAR%23'; assert.strictEqual(encodeWithoutSlashes(input), expected); }); - it('should percent-encode all reserved characters while preserving slashes', () => { + it('should percent-encode reserved characters while preserving slashes', () => { const input = "projects/p/locations/l/agents/a/sessions/ !@$&'()*+,;=:%"; const expected = - 'projects/p/locations/l/agents/a/sessions/%20%21%40%24%26%27%28%29%2A%2B%2C%3B%3D%3A%25'; + "projects/p/locations/l/agents/a/sessions/%20!%40%24%26'()*%2B%2C%3B%3D%3A%25"; assert.strictEqual(encodeWithoutSlashes(input), expected); }); @@ -215,7 +211,7 @@ describe('transcoding', () => { it('should validate and encode multi-segment params and validate single-segment params', () => { const params: Record = { parent: - 'projects/p/locations/l/agents/a/sessions/my-session?$httpMethod=DELETE#', + 'projects/p/locations/l/agents/a/sessions/my-session?$foo=BAR#', fileId: 'file-123', }; validateAndEncodeParams( @@ -224,7 +220,7 @@ describe('transcoding', () => { ); assert.strictEqual( params.parent, - 'projects/p/locations/l/agents/a/sessions/my-session%3F%24httpMethod%3DDELETE%23', + 'projects/p/locations/l/agents/a/sessions/my-session%3F%24foo%3DBAR%23', ); assert.strictEqual(params.fileId, 'file-123'); }); From 7c622cd9dda8b1a7e54732d39fe450507da3e3c2 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Tue, 18 Aug 2026 17:27:52 -0400 Subject: [PATCH 10/36] Add dialogflow tests --- .../test/test.dialogflow.ts | 605 ++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 core/packages/nodejs-googleapis-common/test/test.dialogflow.ts diff --git a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts new file mode 100644 index 000000000000..b35344f82d63 --- /dev/null +++ b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts @@ -0,0 +1,605 @@ +// Copyright 2024 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, afterEach} from 'mocha'; +import * as nock from 'nock'; +import { + createAPIRequest, + APIRequestContext, + GlobalOptions, + MethodOptions, + BodyResponseCallback, + GaxiosResponseWithHTTP2, +} from '../src'; + +nock.disableNetConnect(); + +/** + * The Dialogflow CX Apiary Client (dialogflow_v3) generated classes as structured in `googleapis`. + * This allows the test suite to simulate a user directly calling methods on the Dialogflow Apiary client. + */ +export namespace dialogflow_v3 { + export interface Options extends GlobalOptions { + version: 'v3'; + } + + export class Dialogflow { + context: APIRequestContext; + projects: Resource$Projects; + + constructor(options: GlobalOptions = {}) { + this.context = { + _options: options, + }; + this.projects = new Resource$Projects(this.context); + } + } + + export class Resource$Projects { + context: APIRequestContext; + locations: Resource$Projects$Locations; + + constructor(context: APIRequestContext) { + this.context = context; + this.locations = new Resource$Projects$Locations(this.context); + } + } + + export class Resource$Projects$Locations { + context: APIRequestContext; + agents: Resource$Projects$Locations$Agents; + + constructor(context: APIRequestContext) { + this.context = context; + this.agents = new Resource$Projects$Locations$Agents(this.context); + } + } + + export class Resource$Projects$Locations$Agents { + context: APIRequestContext; + sessions: Resource$Projects$Locations$Agents$Sessions; + + constructor(context: APIRequestContext) { + this.context = context; + this.sessions = new Resource$Projects$Locations$Agents$Sessions(this.context); + } + } + + export interface Params$DetectIntent { + session?: string; + requestBody?: { + queryInput?: { + text?: {text?: string}; + languageCode?: string; + }; + queryParams?: Record; + }; + } + + export interface Params$EntityTypes$Create { + parent?: string; + entityTypeId?: string; + requestBody?: { + displayName?: string; + entityOverrideMode?: string; + entities?: Array<{value?: string; synonyms?: string[]}>; + }; + } + + export interface Params$EntityTypes$Get { + name?: string; + languageCode?: string; + } + + export class Resource$Projects$Locations$Agents$Sessions { + context: APIRequestContext; + entityTypes: Resource$Projects$Locations$Agents$Sessions$EntityTypes; + + constructor(context: APIRequestContext) { + this.context = context; + this.entityTypes = + new Resource$Projects$Locations$Agents$Sessions$EntityTypes(this.context); + } + + detectIntent( + params?: Params$DetectIntent, + options?: MethodOptions, + ): Promise>; + detectIntent( + params: Params$DetectIntent, + callback: BodyResponseCallback, + ): void; + detectIntent( + params: Params$DetectIntent, + options: MethodOptions, + callback: BodyResponseCallback, + ): void; + detectIntent( + paramsOrCallback?: Params$DetectIntent | BodyResponseCallback, + optionsOrCallback?: MethodOptions | BodyResponseCallback, + callback?: BodyResponseCallback, + ): void | Promise> { + let params = (paramsOrCallback || {}) as Params$DetectIntent; + let options = (optionsOrCallback || {}) as MethodOptions; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = {} as Params$DetectIntent; + options = {}; + } + + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } + + const rootUrl = options.rootUrl || 'https://dialogflow.googleapis.com/'; + const parameters = { + options: Object.assign( + { + url: (rootUrl + '/v3/{+session}:detectIntent').replace( + /([^:]\/)\/+/g, + '$1', + ), + method: 'POST', + apiVersion: '', + }, + options, + ), + params, + requiredParams: ['session'], + pathParams: ['session'], + context: this.context, + }; + + if (callback) { + createAPIRequest(parameters, callback as BodyResponseCallback); + } else { + return createAPIRequest(parameters); + } + } + } + + export class Resource$Projects$Locations$Agents$Sessions$EntityTypes { + context: APIRequestContext; + + constructor(context: APIRequestContext) { + this.context = context; + } + + create( + params?: Params$EntityTypes$Create, + options?: MethodOptions, + ): Promise>; + create( + params: Params$EntityTypes$Create, + callback: BodyResponseCallback, + ): void; + create( + params: Params$EntityTypes$Create, + options: MethodOptions, + callback: BodyResponseCallback, + ): void; + create( + paramsOrCallback?: Params$EntityTypes$Create | BodyResponseCallback, + optionsOrCallback?: MethodOptions | BodyResponseCallback, + callback?: BodyResponseCallback, + ): void | Promise> { + let params = (paramsOrCallback || {}) as Params$EntityTypes$Create; + let options = (optionsOrCallback || {}) as MethodOptions; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = {} as Params$EntityTypes$Create; + options = {}; + } + + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } + + const rootUrl = options.rootUrl || 'https://dialogflow.googleapis.com/'; + const parameters = { + options: Object.assign( + { + url: (rootUrl + '/v3/{+parent}/entityTypes').replace( + /([^:]\/)\/+/g, + '$1', + ), + method: 'POST', + apiVersion: '', + }, + options, + ), + params, + requiredParams: ['parent'], + pathParams: ['parent'], + context: this.context, + }; + + if (callback) { + createAPIRequest(parameters, callback as BodyResponseCallback); + } else { + return createAPIRequest(parameters); + } + } + + get( + params?: Params$EntityTypes$Get, + options?: MethodOptions, + ): Promise>; + get( + params: Params$EntityTypes$Get, + callback: BodyResponseCallback, + ): void; + get( + params: Params$EntityTypes$Get, + options: MethodOptions, + callback: BodyResponseCallback, + ): void; + get( + paramsOrCallback?: Params$EntityTypes$Get | BodyResponseCallback, + optionsOrCallback?: MethodOptions | BodyResponseCallback, + callback?: BodyResponseCallback, + ): void | Promise> { + let params = (paramsOrCallback || {}) as Params$EntityTypes$Get; + let options = (optionsOrCallback || {}) as MethodOptions; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = {} as Params$EntityTypes$Get; + options = {}; + } + + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } + + const rootUrl = options.rootUrl || 'https://dialogflow.googleapis.com/'; + const parameters = { + options: Object.assign( + { + url: (rootUrl + '/v3/{+name}').replace(/([^:]\/)\/+/g, '$1'), + method: 'GET', + apiVersion: '', + }, + options, + ), + params, + requiredParams: ['name'], + pathParams: ['name'], + context: this.context, + }; + + if (callback) { + createAPIRequest(parameters, callback as BodyResponseCallback); + } else { + return createAPIRequest(parameters); + } + } + } +} + +describe('Dialogflow Apiary Client User Simulation', () => { + afterEach(() => { + nock.cleanAll(); + }); + + it('detectIntent: user sends a session ID containing unreserved characters and RFC 3986 sub-delims (!\'()*)', async () => { + // 1. User initializes the Dialogflow Apiary client + const dialogflow = new dialogflow_v3.Dialogflow(); + + // 2. User provides a resource name containing characters modified by this branch: ! ' ( ) * + const session = + "projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-(session-1)*!'"; + + // ========================================================================================= + // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest + // ========================================================================================= + // When the user calls dialogflow.projects.locations.agents.sessions.detectIntent(...), + // the Apiary client method constructs and passes the following APIRequestParams object: + // + // parameters: { + // options: { + // url: 'https://dialogflow.googleapis.com/v3/{+session}:detectIntent', + // method: 'POST', + // apiVersion: '' + // }, + // params: { + // session: "projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-(session-1)*!'", + // requestBody: { + // queryInput: { + // text: { text: 'Hello, book a flight' }, + // languageCode: 'en' + // } + // } + // }, + // requiredParams: ['session'], + // pathParams: ['session'], + // context: dialogflow.context + // } + // + // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: + // ----------------------------------------------------- + // - transcoding.ts now uses encodeWithoutSlashes (via encodeURIComponent), which: + // - Preserves slashes '/' (due to {+session} multi-segment template). + // - Preserves characters '!', '\'', '(', ')', '*' as literals (whereas previously + // strictEncodeURIComponent converted them to %21, %27, %28, %29, %2A). + // - params.requestBody is moved to options.data. + // - Resulting HTTP POST URL: + // https://dialogflow.googleapis.com/v3/projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-(session-1)*!':detectIntent + // ========================================================================================= + const expectedPath = + "/v3/projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-(session-1)*!':detectIntent"; + + const scope = nock('https://dialogflow.googleapis.com') + .post(expectedPath, { + queryInput: { + text: {text: 'Hello, book a flight'}, + languageCode: 'en', + }, + }) + .reply(200, { + responseId: 'resp-abc-123', + queryResult: { + text: 'Hello, book a flight', + fulfillmentText: 'Where would you like to fly?', + }, + }); + + // 3. User invokes the method on the Apiary client instance + const res = await dialogflow.projects.locations.agents.sessions.detectIntent({ + session, + requestBody: { + queryInput: { + text: {text: 'Hello, book a flight'}, + languageCode: 'en', + }, + }, + }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.responseId, 'resp-abc-123'); + assert.ok(res.config.url?.toString().endsWith(expectedPath)); + scope.done(); + }); + + it('entityTypes.create: user specifies {+parent} in path, query parameter, and requestBody', async () => { + // 1. User initializes the Dialogflow Apiary client + const dialogflow = new dialogflow_v3.Dialogflow(); + + const parent = + "projects/my-prj/locations/global/agents/agent-1/sessions/user-(42)*'"; + const entityTypeId = 'custom_currency'; + + // ========================================================================================= + // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest + // ========================================================================================= + // When the user calls dialogflow.projects.locations.agents.sessions.entityTypes.create(...), + // the Apiary client method constructs and passes: + // + // parameters: { + // options: { + // url: 'https://dialogflow.googleapis.com/v3/{+parent}/entityTypes', + // method: 'POST', + // apiVersion: '' + // }, + // params: { + // parent: "projects/my-prj/locations/global/agents/agent-1/sessions/user-(42)*'", + // entityTypeId: 'custom_currency', + // requestBody: { + // displayName: 'CustomCurrency', + // entityOverrideMode: 'ENTITY_OVERRIDE_MODE_OVERRIDE', + // entities: [{ value: 'USD', synonyms: ['dollar', 'bucks'] }] + // } + // }, + // requiredParams: ['parent'], + // pathParams: ['parent'], + // context: dialogflow.context + // } + // + // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: + // ----------------------------------------------------- + // - params.parent is matched against {+parent} and encoded with encodeWithoutSlashes, + // preserving slashes and (42)*' characters. + // - params.entityTypeId is NOT in the path template, so it remains in params and gets + // serialized as a query string parameter ?entityTypeId=custom_currency. + // - params.requestBody is extracted and assigned to options.data. + // ========================================================================================= + const expectedPath = + "/v3/projects/my-prj/locations/global/agents/agent-1/sessions/user-(42)*'/entityTypes"; + + const scope = nock('https://dialogflow.googleapis.com') + .post(expectedPath, { + displayName: 'CustomCurrency', + entityOverrideMode: 'ENTITY_OVERRIDE_MODE_OVERRIDE', + entities: [{value: 'USD', synonyms: ['dollar', 'bucks']}], + }) + .query({ + entityTypeId: 'custom_currency', + }) + .reply(200, { + name: `${parent}/entityTypes/custom_currency`, + displayName: 'CustomCurrency', + }); + + // 2. User invokes the create method + const res = + await dialogflow.projects.locations.agents.sessions.entityTypes.create({ + parent, + entityTypeId, + requestBody: { + displayName: 'CustomCurrency', + entityOverrideMode: 'ENTITY_OVERRIDE_MODE_OVERRIDE', + entities: [{value: 'USD', synonyms: ['dollar', 'bucks']}], + }, + }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.displayName, 'CustomCurrency'); + scope.done(); + }); + + it('entityTypes.get: user looks up resource where path contains URI-reserved characters and query params', async () => { + // 1. User initializes the Dialogflow Apiary client + const dialogflow = new dialogflow_v3.Dialogflow(); + + const name = + "projects/p/locations/l/agents/a/sessions/s-(99)*/entityTypes/type-1"; + + // ========================================================================================= + // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest + // ========================================================================================= + // When the user calls dialogflow.projects.locations.agents.sessions.entityTypes.get(...): + // + // parameters: { + // options: { + // url: 'https://dialogflow.googleapis.com/v3/{+name}', + // method: 'GET', + // apiVersion: '' + // }, + // params: { + // name: "projects/p/locations/l/agents/a/sessions/s-(99)*/entityTypes/type-1", + // languageCode: 'en' + // }, + // requiredParams: ['name'], + // pathParams: ['name'], + // context: dialogflow.context + // } + // + // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: + // ----------------------------------------------------- + // - name expands into /v3/projects/p/locations/l/agents/a/sessions/s-(99)*/entityTypes/type-1 + // preserving (, ), * unencoded while validating no traversal segments. + // - languageCode is appended as ?languageCode=en. + // ========================================================================================= + const expectedPath = + '/v3/projects/p/locations/l/agents/a/sessions/s-(99)*/entityTypes/type-1'; + + const scope = nock('https://dialogflow.googleapis.com') + .get(expectedPath) + .query({languageCode: 'en'}) + .reply(200, { + name, + displayName: 'EntityTypeOne', + }); + + const res = + await dialogflow.projects.locations.agents.sessions.entityTypes.get({ + name, + languageCode: 'en', + }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.name, name); + scope.done(); + }); + + it('detectIntent: prevents query injection when user supplies a session containing "?" and "$"', async () => { + // 1. User initializes the Dialogflow Apiary client + const dialogflow = new dialogflow_v3.Dialogflow(); + + // Session containing characters that could attempt to inject query parameters or URL fragment + const injectionSession = + 'projects/p/locations/l/agents/a/sessions/session-(1)*?$foo=BAR&admin=1#frag'; + + // ========================================================================================= + // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest + // ========================================================================================= + // parameters: { + // options: { + // url: 'https://dialogflow.googleapis.com/v3/{+session}:detectIntent', + // method: 'POST', + // apiVersion: '' + // }, + // params: { + // session: 'projects/p/locations/l/agents/a/sessions/session-(1)*?$foo=BAR&admin=1#frag', + // requestBody: {} + // }, + // requiredParams: ['session'], + // pathParams: ['session'], + // context: dialogflow.context + // } + // + // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: + // ----------------------------------------------------- + // - encodeWithoutSlashes runs encodeURIComponent per slash segment: + // - '?' becomes '%3F' + // - '$' becomes '%24' + // - '=' becomes '%3D' + // - '&' becomes '%26' + // - '#' becomes '%23' + // - '(', ')', '*' are preserved without double-escaping. + // - This prevents $foo=BAR and admin=1 from becoming actual query parameters. + // ========================================================================================= + const expectedPath = + '/v3/projects/p/locations/l/agents/a/sessions/session-(1)*%3F%24foo%3DBAR%26admin%3D1%23frag:detectIntent'; + + const scope = nock('https://dialogflow.googleapis.com') + .post(expectedPath, {}) + .reply(200, {responseId: 'safe-response'}); + + const res = await dialogflow.projects.locations.agents.sessions.detectIntent({ + session: injectionSession, + requestBody: {}, + }); + + assert.strictEqual(res.status, 200); + assert.ok(res.config.url?.toString().endsWith(expectedPath)); + scope.done(); + }); + + it('detectIntent: throws validation error when session path contains path traversal segments', async () => { + // 1. User initializes the Dialogflow Apiary client + const dialogflow = new dialogflow_v3.Dialogflow(); + + const traversalSession = + 'projects/p/locations/l/agents/a/sessions/agents/../subagent'; + + // ========================================================================================= + // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest + // ========================================================================================= + // parameters: { + // options: { + // url: 'https://dialogflow.googleapis.com/v3/{+session}:detectIntent', + // method: 'POST', + // apiVersion: '' + // }, + // params: { + // session: 'projects/p/locations/l/agents/a/sessions/agents/../subagent', + // requestBody: {} + // }, + // requiredParams: ['session'], + // pathParams: ['session'], + // context: dialogflow.context + // } + // + // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: + // ----------------------------------------------------- + // - validateAndEncodeParams detects the '..' traversal path segment. + // - createAPIRequest immediately rejects with an Error before sending any HTTP request: + // /Value for session must not contain segments that are exactly \. or \.\./ + // ========================================================================================= + await assert.rejects( + dialogflow.projects.locations.agents.sessions.detectIntent({ + session: traversalSession, + requestBody: {}, + }), + /Value for session must not contain segments that are exactly \. or \.\./, + ); + }); +}); From ff9fdaa693cb173b7cc826eaf769915ec604c127 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 09:51:51 -0400 Subject: [PATCH 11/36] Introduce proper method names --- .../src/transcoding.ts | 38 ++++++++------ .../test/test.apirequest.ts | 2 +- .../test/test.dialogflow.ts | 6 +-- .../test/test.transcoding.ts | 52 ++++++++++++------- 4 files changed, 59 insertions(+), 39 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 672985eb21c0..7461acdf6b55 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -19,7 +19,7 @@ * @param propertyName Name of the parameter being validated * @param value Value of the path segment */ -export function validateSingleSegment( +export function validateUriPathSegment( propertyName: string, value: string, ): void { @@ -37,7 +37,7 @@ export function validateSingleSegment( * @param propertyName Name of the parameter being validated * @param value Value of the multi-segment path */ -export function validateMultiSegment( +export function validateUriPath( propertyName: string, value: string, ): void { @@ -52,32 +52,40 @@ export function validateMultiSegment( } /** - * Aliases for compatibility with GAX naming conventions. + * Aliases for backwards compatibility. */ -export const validateUriPathSegment = validateSingleSegment; -export const validateUriPath = validateMultiSegment; +export const validateSingleSegment = validateUriPathSegment; +export const validateMultiSegment = validateUriPath; /** - * Percent-encodes a string, preserving only unreserved characters - * (alpha-numeric, '-', '_', '.', and '~'). All other characters, + * 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 str The input string to encode * @returns The percent-encoded string */ export function encodeWithSlashes(str: string): string { - return encodeURIComponent(str); + return encodeURIComponent(str).replace( + /[!'()*]/g, // Characters preserved by encodeURIComponent + character => '%' + character.charCodeAt(0).toString(16).toUpperCase(), + ); } /** - * Percent-encodes a string, preserving unreserved characters and slashes ('/'). - * All other characters are percent-encoded. + * Percent-encodes a string according to RFC 3986, preserving unreserved + * characters (alpha-numeric, '-', '_', '.', and '~') and slashes ('/'). All other + * characters are percent-encoded. * * @param str The input string to encode * @returns The percent-encoded string with slashes preserved */ export function encodeWithoutSlashes(str: string): string { - return str.split('/').map(encodeURIComponent).join('/'); + return str.split('/').map(encodeWithSlashes).join('/'); } /** @@ -190,11 +198,11 @@ export function validateAndEncodeParams( if (val !== undefined && val !== null) { if (Array.isArray(val)) { for (const item of val) { - validateMultiSegment(param, String(item)); + validateUriPath(param, String(item)); } params[param] = val.map(item => encodeWithoutSlashes(String(item))); } else { - validateMultiSegment(param, String(val)); + validateUriPath(param, String(val)); params[param] = encodeWithoutSlashes(String(val)); } } @@ -214,10 +222,10 @@ export function validateAndEncodeParams( if (val !== undefined && val !== null) { if (Array.isArray(val)) { for (const item of val) { - validateSingleSegment(param, String(item)); + validateUriPathSegment(param, String(item)); } } else { - validateSingleSegment(param, String(val)); + validateUriPathSegment(param, String(val)); } } } diff --git a/core/packages/nodejs-googleapis-common/test/test.apirequest.ts b/core/packages/nodejs-googleapis-common/test/test.apirequest.ts index e07603759057..c2d3848c387e 100644 --- a/core/packages/nodejs-googleapis-common/test/test.apirequest.ts +++ b/core/packages/nodejs-googleapis-common/test/test.apirequest.ts @@ -850,7 +850,7 @@ describe('createAPIRequest', () => { it('should percent-encode reserved characters while preserving unreserved characters and slashes in reserved parameters', async () => { const p = - "/v3/projects/p/locations/l/agents/a/sessions/%20!%40%24%26'()*%2B%2C%3B%3D%3A%25:detectIntent"; + '/v3/projects/p/locations/l/agents/a/sessions/%20%21%40%24%26%27%28%29%2A%2B%2C%3B%3D%3A%25:detectIntent'; const scope = nock('https://dialogflow.googleapis.com') .post(p) .reply(200, {}); diff --git a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts index b35344f82d63..5ac492a05176 100644 --- a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts +++ b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts @@ -537,17 +537,17 @@ describe('Dialogflow Apiary Client User Simulation', () => { // // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: // ----------------------------------------------------- - // - encodeWithoutSlashes runs encodeURIComponent per slash segment: + // - encodeWithoutSlashes runs RFC 3986 percent-encoding per slash segment: // - '?' becomes '%3F' // - '$' becomes '%24' // - '=' becomes '%3D' // - '&' becomes '%26' // - '#' becomes '%23' - // - '(', ')', '*' are preserved without double-escaping. + // - '(', ')', '*' are percent-encoded strictly as '%28', '%29', '%2A'. // - This prevents $foo=BAR and admin=1 from becoming actual query parameters. // ========================================================================================= const expectedPath = - '/v3/projects/p/locations/l/agents/a/sessions/session-(1)*%3F%24foo%3DBAR%26admin%3D1%23frag:detectIntent'; + '/v3/projects/p/locations/l/agents/a/sessions/session-%281%29%2A%3F%24foo%3DBAR%26admin%3D1%23frag:detectIntent'; const scope = nock('https://dialogflow.googleapis.com') .post(expectedPath, {}) diff --git a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts index e5402ed34255..4a4455a0bf8b 100644 --- a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts +++ b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts @@ -15,8 +15,8 @@ import * as assert from 'assert'; import {describe, it} from 'mocha'; import { - validateSingleSegment, - validateMultiSegment, + validateUriPathSegment, + validateUriPath, encodeWithSlashes, encodeWithoutSlashes, extractTemplateParams, @@ -25,80 +25,80 @@ import { } from '../src/transcoding'; describe('transcoding', () => { - describe('validateSingleSegment', () => { + describe('validateUriPathSegment', () => { it('should throw for "."', () => { assert.throws(() => { - validateSingleSegment('fileId', '.'); + validateUriPathSegment('fileId', '.'); }, /Invalid value \. for fileId/); }); it('should throw for ".."', () => { assert.throws(() => { - validateSingleSegment('fileId', '..'); + validateUriPathSegment('fileId', '..'); }, /Invalid value \.\. for fileId/); }); it('should allow valid single segment names', () => { assert.doesNotThrow(() => { - validateSingleSegment('fileId', 'valid-id'); - validateSingleSegment('fileId', 'file.txt'); - validateSingleSegment('fileId', 'example.com'); + validateUriPathSegment('fileId', 'valid-id'); + validateUriPathSegment('fileId', 'file.txt'); + validateUriPathSegment('fileId', 'example.com'); }); }); }); - describe('validateMultiSegment', () => { + describe('validateUriPath', () => { it('should throw for ".." segment in multi-segment path', () => { assert.throws(() => { - validateMultiSegment( + validateUriPath( 'session', 'projects/p/locations/l/agents/a/sessions/agents/../subagent', ); }, /Value for session must not contain segments that are exactly \. or \.\./); assert.throws(() => { - validateMultiSegment('name', '..'); + validateUriPath('name', '..'); }, /Value for name must not contain segments that are exactly \. or \.\./); assert.throws(() => { - validateMultiSegment('name', 'a/b/..'); + validateUriPath('name', 'a/b/..'); }, /Value for name must not contain segments that are exactly \. or \.\./); }); it('should throw for "." segment in multi-segment path', () => { assert.throws(() => { - validateMultiSegment( + validateUriPath( 'session', 'projects/p/locations/l/agents/a/sessions/agents/./subagent', ); }, /Value for session must not contain segments that are exactly \. or \.\./); assert.throws(() => { - validateMultiSegment('name', '.'); + validateUriPath('name', '.'); }, /Value for name must not contain segments that are exactly \. or \.\./); assert.throws(() => { - validateMultiSegment('name', './a/b'); + validateUriPath('name', './a/b'); }, /Value for name must not contain segments that are exactly \. or \.\./); }); it('should allow valid domain-scoped and resource paths with dots', () => { assert.doesNotThrow(() => { - validateMultiSegment( + validateUriPath( 'parent', 'projects/example.com:custom-project/locations/global', ); - validateMultiSegment( + validateUriPath( 'session', 'projects/p/locations/l/agents/a/sessions/123.456', ); - validateMultiSegment('name', 'a/b/c'); + validateUriPath('name', 'a/b/c'); }); }); it('should allow empty/falsy values', () => { assert.doesNotThrow(() => { - validateMultiSegment('name', ''); + validateUriPath('name', ''); }); }); }); @@ -116,6 +116,18 @@ describe('transcoding', () => { it('should properly encode Unicode surrogate pairs / emojis', () => { assert.strictEqual(encodeWithSlashes('😊'), '%F0%9F%98%8A'); }); + + it('should preserve unreserved characters while strictly percent-encoding all other characters in encodeWithSlashes', () => { + // Standard RFC unreserved characters: [-_.~0-9a-zA-Z] + const unreserved = 'abc-123_.~XYZ'; + assert.strictEqual(encodeWithSlashes(unreserved), unreserved); + + // Reserved and special characters: should be percent encoded, including !\'()* + const specialChars = "!'()*"; + const encoded = encodeWithSlashes(specialChars); + // ! -> %21, ' -> %27, ( -> %28, ) -> %29, * -> %2A + assert.strictEqual(encoded, '%21%27%28%29%2A'); + }); }); describe('encodeWithoutSlashes', () => { @@ -137,7 +149,7 @@ describe('transcoding', () => { it('should percent-encode reserved characters while preserving slashes', () => { const input = "projects/p/locations/l/agents/a/sessions/ !@$&'()*+,;=:%"; const expected = - "projects/p/locations/l/agents/a/sessions/%20!%40%24%26'()*%2B%2C%3B%3D%3A%25"; + 'projects/p/locations/l/agents/a/sessions/%20%21%40%24%26%27%28%29%2A%2B%2C%3B%3D%3A%25'; assert.strictEqual(encodeWithoutSlashes(input), expected); }); From 39b911d35cee731ecbbcd439462168ed8431df9c Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 10:21:45 -0400 Subject: [PATCH 12/36] Update the dialogflow tests --- .../nodejs-googleapis-common/package.json | 1 + .../test/test.dialogflow.ts | 310 +----------------- 2 files changed, 18 insertions(+), 293 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/package.json b/core/packages/nodejs-googleapis-common/package.json index 8489c6225130..1301e7a09c47 100644 --- a/core/packages/nodejs-googleapis-common/package.json +++ b/core/packages/nodejs-googleapis-common/package.json @@ -44,6 +44,7 @@ "url-template": "^2.0.8" }, "devDependencies": { + "@googleapis/dialogflow": "^1.0.0", "@babel/plugin-proposal-private-methods": "^7.18.6", "@types/extend": "^3.0.1", "@types/mocha": "^10.0.10", diff --git a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts index 5ac492a05176..f08d8fdcb9a4 100644 --- a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts +++ b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts @@ -15,293 +15,18 @@ import * as assert from 'assert'; import {describe, it, afterEach} from 'mocha'; import * as nock from 'nock'; -import { - createAPIRequest, - APIRequestContext, - GlobalOptions, - MethodOptions, - BodyResponseCallback, - GaxiosResponseWithHTTP2, -} from '../src'; +import {dialogflow_v3} from '@googleapis/dialogflow'; nock.disableNetConnect(); -/** - * The Dialogflow CX Apiary Client (dialogflow_v3) generated classes as structured in `googleapis`. - * This allows the test suite to simulate a user directly calling methods on the Dialogflow Apiary client. - */ -export namespace dialogflow_v3 { - export interface Options extends GlobalOptions { - version: 'v3'; - } - - export class Dialogflow { - context: APIRequestContext; - projects: Resource$Projects; - - constructor(options: GlobalOptions = {}) { - this.context = { - _options: options, - }; - this.projects = new Resource$Projects(this.context); - } - } - - export class Resource$Projects { - context: APIRequestContext; - locations: Resource$Projects$Locations; - - constructor(context: APIRequestContext) { - this.context = context; - this.locations = new Resource$Projects$Locations(this.context); - } - } - - export class Resource$Projects$Locations { - context: APIRequestContext; - agents: Resource$Projects$Locations$Agents; - - constructor(context: APIRequestContext) { - this.context = context; - this.agents = new Resource$Projects$Locations$Agents(this.context); - } - } - - export class Resource$Projects$Locations$Agents { - context: APIRequestContext; - sessions: Resource$Projects$Locations$Agents$Sessions; - - constructor(context: APIRequestContext) { - this.context = context; - this.sessions = new Resource$Projects$Locations$Agents$Sessions(this.context); - } - } - - export interface Params$DetectIntent { - session?: string; - requestBody?: { - queryInput?: { - text?: {text?: string}; - languageCode?: string; - }; - queryParams?: Record; - }; - } - - export interface Params$EntityTypes$Create { - parent?: string; - entityTypeId?: string; - requestBody?: { - displayName?: string; - entityOverrideMode?: string; - entities?: Array<{value?: string; synonyms?: string[]}>; - }; - } - - export interface Params$EntityTypes$Get { - name?: string; - languageCode?: string; - } - - export class Resource$Projects$Locations$Agents$Sessions { - context: APIRequestContext; - entityTypes: Resource$Projects$Locations$Agents$Sessions$EntityTypes; - - constructor(context: APIRequestContext) { - this.context = context; - this.entityTypes = - new Resource$Projects$Locations$Agents$Sessions$EntityTypes(this.context); - } - - detectIntent( - params?: Params$DetectIntent, - options?: MethodOptions, - ): Promise>; - detectIntent( - params: Params$DetectIntent, - callback: BodyResponseCallback, - ): void; - detectIntent( - params: Params$DetectIntent, - options: MethodOptions, - callback: BodyResponseCallback, - ): void; - detectIntent( - paramsOrCallback?: Params$DetectIntent | BodyResponseCallback, - optionsOrCallback?: MethodOptions | BodyResponseCallback, - callback?: BodyResponseCallback, - ): void | Promise> { - let params = (paramsOrCallback || {}) as Params$DetectIntent; - let options = (optionsOrCallback || {}) as MethodOptions; - - if (typeof paramsOrCallback === 'function') { - callback = paramsOrCallback; - params = {} as Params$DetectIntent; - options = {}; - } - - if (typeof optionsOrCallback === 'function') { - callback = optionsOrCallback; - options = {}; - } - - const rootUrl = options.rootUrl || 'https://dialogflow.googleapis.com/'; - const parameters = { - options: Object.assign( - { - url: (rootUrl + '/v3/{+session}:detectIntent').replace( - /([^:]\/)\/+/g, - '$1', - ), - method: 'POST', - apiVersion: '', - }, - options, - ), - params, - requiredParams: ['session'], - pathParams: ['session'], - context: this.context, - }; - - if (callback) { - createAPIRequest(parameters, callback as BodyResponseCallback); - } else { - return createAPIRequest(parameters); - } - } - } - - export class Resource$Projects$Locations$Agents$Sessions$EntityTypes { - context: APIRequestContext; - - constructor(context: APIRequestContext) { - this.context = context; - } - - create( - params?: Params$EntityTypes$Create, - options?: MethodOptions, - ): Promise>; - create( - params: Params$EntityTypes$Create, - callback: BodyResponseCallback, - ): void; - create( - params: Params$EntityTypes$Create, - options: MethodOptions, - callback: BodyResponseCallback, - ): void; - create( - paramsOrCallback?: Params$EntityTypes$Create | BodyResponseCallback, - optionsOrCallback?: MethodOptions | BodyResponseCallback, - callback?: BodyResponseCallback, - ): void | Promise> { - let params = (paramsOrCallback || {}) as Params$EntityTypes$Create; - let options = (optionsOrCallback || {}) as MethodOptions; - - if (typeof paramsOrCallback === 'function') { - callback = paramsOrCallback; - params = {} as Params$EntityTypes$Create; - options = {}; - } - - if (typeof optionsOrCallback === 'function') { - callback = optionsOrCallback; - options = {}; - } - - const rootUrl = options.rootUrl || 'https://dialogflow.googleapis.com/'; - const parameters = { - options: Object.assign( - { - url: (rootUrl + '/v3/{+parent}/entityTypes').replace( - /([^:]\/)\/+/g, - '$1', - ), - method: 'POST', - apiVersion: '', - }, - options, - ), - params, - requiredParams: ['parent'], - pathParams: ['parent'], - context: this.context, - }; - - if (callback) { - createAPIRequest(parameters, callback as BodyResponseCallback); - } else { - return createAPIRequest(parameters); - } - } - - get( - params?: Params$EntityTypes$Get, - options?: MethodOptions, - ): Promise>; - get( - params: Params$EntityTypes$Get, - callback: BodyResponseCallback, - ): void; - get( - params: Params$EntityTypes$Get, - options: MethodOptions, - callback: BodyResponseCallback, - ): void; - get( - paramsOrCallback?: Params$EntityTypes$Get | BodyResponseCallback, - optionsOrCallback?: MethodOptions | BodyResponseCallback, - callback?: BodyResponseCallback, - ): void | Promise> { - let params = (paramsOrCallback || {}) as Params$EntityTypes$Get; - let options = (optionsOrCallback || {}) as MethodOptions; - - if (typeof paramsOrCallback === 'function') { - callback = paramsOrCallback; - params = {} as Params$EntityTypes$Get; - options = {}; - } - - if (typeof optionsOrCallback === 'function') { - callback = optionsOrCallback; - options = {}; - } - - const rootUrl = options.rootUrl || 'https://dialogflow.googleapis.com/'; - const parameters = { - options: Object.assign( - { - url: (rootUrl + '/v3/{+name}').replace(/([^:]\/)\/+/g, '$1'), - method: 'GET', - apiVersion: '', - }, - options, - ), - params, - requiredParams: ['name'], - pathParams: ['name'], - context: this.context, - }; - - if (callback) { - createAPIRequest(parameters, callback as BodyResponseCallback); - } else { - return createAPIRequest(parameters); - } - } - } -} - describe('Dialogflow Apiary Client User Simulation', () => { afterEach(() => { nock.cleanAll(); }); - it('detectIntent: user sends a session ID containing unreserved characters and RFC 3986 sub-delims (!\'()*)', async () => { + it("detectIntent: user sends a session ID containing unreserved characters and RFC 3986 sub-delims (!'()*)", async () => { // 1. User initializes the Dialogflow Apiary client - const dialogflow = new dialogflow_v3.Dialogflow(); + const dialogflow = new dialogflow_v3.Dialogflow({}); // 2. User provides a resource name containing characters modified by this branch: ! ' ( ) * const session = @@ -335,16 +60,15 @@ describe('Dialogflow Apiary Client User Simulation', () => { // // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: // ----------------------------------------------------- - // - transcoding.ts now uses encodeWithoutSlashes (via encodeURIComponent), which: + // - transcoding.ts now uses encodeWithoutSlashes (via RFC 3986 percent-encoding), which: // - Preserves slashes '/' (due to {+session} multi-segment template). - // - Preserves characters '!', '\'', '(', ')', '*' as literals (whereas previously - // strictEncodeURIComponent converted them to %21, %27, %28, %29, %2A). + // - Strictly percent-encodes characters '!', '\'', '(', ')', '*' into %21, %27, %28, %29, %2A. // - params.requestBody is moved to options.data. // - Resulting HTTP POST URL: - // https://dialogflow.googleapis.com/v3/projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-(session-1)*!':detectIntent + // https://dialogflow.googleapis.com/v3/projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-%28session-1%29%2A%21%27:detectIntent // ========================================================================================= const expectedPath = - "/v3/projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-(session-1)*!':detectIntent"; + '/v3/projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-%28session-1%29%2A%21%27:detectIntent'; const scope = nock('https://dialogflow.googleapis.com') .post(expectedPath, { @@ -380,7 +104,7 @@ describe('Dialogflow Apiary Client User Simulation', () => { it('entityTypes.create: user specifies {+parent} in path, query parameter, and requestBody', async () => { // 1. User initializes the Dialogflow Apiary client - const dialogflow = new dialogflow_v3.Dialogflow(); + const dialogflow = new dialogflow_v3.Dialogflow({}); const parent = "projects/my-prj/locations/global/agents/agent-1/sessions/user-(42)*'"; @@ -415,13 +139,13 @@ describe('Dialogflow Apiary Client User Simulation', () => { // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: // ----------------------------------------------------- // - params.parent is matched against {+parent} and encoded with encodeWithoutSlashes, - // preserving slashes and (42)*' characters. + // preserving slashes and percent-encoding (42)*' characters strictly. // - params.entityTypeId is NOT in the path template, so it remains in params and gets // serialized as a query string parameter ?entityTypeId=custom_currency. // - params.requestBody is extracted and assigned to options.data. // ========================================================================================= const expectedPath = - "/v3/projects/my-prj/locations/global/agents/agent-1/sessions/user-(42)*'/entityTypes"; + '/v3/projects/my-prj/locations/global/agents/agent-1/sessions/user-%2842%29%2A%27/entityTypes'; const scope = nock('https://dialogflow.googleapis.com') .post(expectedPath, { @@ -456,10 +180,10 @@ describe('Dialogflow Apiary Client User Simulation', () => { it('entityTypes.get: user looks up resource where path contains URI-reserved characters and query params', async () => { // 1. User initializes the Dialogflow Apiary client - const dialogflow = new dialogflow_v3.Dialogflow(); + const dialogflow = new dialogflow_v3.Dialogflow({}); const name = - "projects/p/locations/l/agents/a/sessions/s-(99)*/entityTypes/type-1"; + 'projects/p/locations/l/agents/a/sessions/s-(99)*/entityTypes/type-1'; // ========================================================================================= // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest @@ -483,12 +207,12 @@ describe('Dialogflow Apiary Client User Simulation', () => { // // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: // ----------------------------------------------------- - // - name expands into /v3/projects/p/locations/l/agents/a/sessions/s-(99)*/entityTypes/type-1 - // preserving (, ), * unencoded while validating no traversal segments. + // - name expands into /v3/projects/p/locations/l/agents/a/sessions/s-%2899%29%2A/entityTypes/type-1 + // percent-encoding (, ), * while validating no traversal segments. // - languageCode is appended as ?languageCode=en. // ========================================================================================= const expectedPath = - '/v3/projects/p/locations/l/agents/a/sessions/s-(99)*/entityTypes/type-1'; + '/v3/projects/p/locations/l/agents/a/sessions/s-%2899%29%2A/entityTypes/type-1'; const scope = nock('https://dialogflow.googleapis.com') .get(expectedPath) @@ -511,7 +235,7 @@ describe('Dialogflow Apiary Client User Simulation', () => { it('detectIntent: prevents query injection when user supplies a session containing "?" and "$"', async () => { // 1. User initializes the Dialogflow Apiary client - const dialogflow = new dialogflow_v3.Dialogflow(); + const dialogflow = new dialogflow_v3.Dialogflow({}); // Session containing characters that could attempt to inject query parameters or URL fragment const injectionSession = @@ -565,7 +289,7 @@ describe('Dialogflow Apiary Client User Simulation', () => { it('detectIntent: throws validation error when session path contains path traversal segments', async () => { // 1. User initializes the Dialogflow Apiary client - const dialogflow = new dialogflow_v3.Dialogflow(); + const dialogflow = new dialogflow_v3.Dialogflow({}); const traversalSession = 'projects/p/locations/l/agents/a/sessions/agents/../subagent'; From 899a8221e5165bc570d9b0aca86521f836175839 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 10:40:31 -0400 Subject: [PATCH 13/36] Reduce test size for demonstration purposes --- .../test/test.dialogflow.ts | 270 +----------------- 1 file changed, 15 insertions(+), 255 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts index f08d8fdcb9a4..449c62d11526 100644 --- a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts +++ b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts @@ -24,20 +24,16 @@ describe('Dialogflow Apiary Client User Simulation', () => { nock.cleanAll(); }); - it("detectIntent: user sends a session ID containing unreserved characters and RFC 3986 sub-delims (!'()*)", async () => { + it('detectIntent: throws validation error when session path contains path traversal segments', async () => { // 1. User initializes the Dialogflow Apiary client const dialogflow = new dialogflow_v3.Dialogflow({}); - // 2. User provides a resource name containing characters modified by this branch: ! ' ( ) * - const session = - "projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-(session-1)*!'"; + const traversalSession = + 'projects/p/locations/l/agents/a/sessions/agents/../subagent'; // ========================================================================================= // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest // ========================================================================================= - // When the user calls dialogflow.projects.locations.agents.sessions.detectIntent(...), - // the Apiary client method constructs and passes the following APIRequestParams object: - // // parameters: { // options: { // url: 'https://dialogflow.googleapis.com/v3/{+session}:detectIntent', @@ -45,13 +41,8 @@ describe('Dialogflow Apiary Client User Simulation', () => { // apiVersion: '' // }, // params: { - // session: "projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-(session-1)*!'", - // requestBody: { - // queryInput: { - // text: { text: 'Hello, book a flight' }, - // languageCode: 'en' - // } - // } + // session: 'projects/p/locations/l/agents/a/sessions/agents/../subagent', + // requestBody: {} // }, // requiredParams: ['session'], // pathParams: ['session'], @@ -60,216 +51,25 @@ describe('Dialogflow Apiary Client User Simulation', () => { // // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: // ----------------------------------------------------- - // - transcoding.ts now uses encodeWithoutSlashes (via RFC 3986 percent-encoding), which: - // - Preserves slashes '/' (due to {+session} multi-segment template). - // - Strictly percent-encodes characters '!', '\'', '(', ')', '*' into %21, %27, %28, %29, %2A. - // - params.requestBody is moved to options.data. - // - Resulting HTTP POST URL: - // https://dialogflow.googleapis.com/v3/projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-%28session-1%29%2A%21%27:detectIntent - // ========================================================================================= - const expectedPath = - '/v3/projects/my-prj/locations/us-central1/agents/agent-007/sessions/user-%28session-1%29%2A%21%27:detectIntent'; - - const scope = nock('https://dialogflow.googleapis.com') - .post(expectedPath, { - queryInput: { - text: {text: 'Hello, book a flight'}, - languageCode: 'en', - }, - }) - .reply(200, { - responseId: 'resp-abc-123', - queryResult: { - text: 'Hello, book a flight', - fulfillmentText: 'Where would you like to fly?', - }, - }); - - // 3. User invokes the method on the Apiary client instance - const res = await dialogflow.projects.locations.agents.sessions.detectIntent({ - session, - requestBody: { - queryInput: { - text: {text: 'Hello, book a flight'}, - languageCode: 'en', - }, - }, - }); - - assert.strictEqual(res.status, 200); - assert.strictEqual(res.data.responseId, 'resp-abc-123'); - assert.ok(res.config.url?.toString().endsWith(expectedPath)); - scope.done(); - }); - - it('entityTypes.create: user specifies {+parent} in path, query parameter, and requestBody', async () => { - // 1. User initializes the Dialogflow Apiary client - const dialogflow = new dialogflow_v3.Dialogflow({}); - - const parent = - "projects/my-prj/locations/global/agents/agent-1/sessions/user-(42)*'"; - const entityTypeId = 'custom_currency'; - - // ========================================================================================= - // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest - // ========================================================================================= - // When the user calls dialogflow.projects.locations.agents.sessions.entityTypes.create(...), - // the Apiary client method constructs and passes: - // - // parameters: { - // options: { - // url: 'https://dialogflow.googleapis.com/v3/{+parent}/entityTypes', - // method: 'POST', - // apiVersion: '' - // }, - // params: { - // parent: "projects/my-prj/locations/global/agents/agent-1/sessions/user-(42)*'", - // entityTypeId: 'custom_currency', - // requestBody: { - // displayName: 'CustomCurrency', - // entityOverrideMode: 'ENTITY_OVERRIDE_MODE_OVERRIDE', - // entities: [{ value: 'USD', synonyms: ['dollar', 'bucks'] }] - // } - // }, - // requiredParams: ['parent'], - // pathParams: ['parent'], - // context: dialogflow.context - // } - // - // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: - // ----------------------------------------------------- - // - params.parent is matched against {+parent} and encoded with encodeWithoutSlashes, - // preserving slashes and percent-encoding (42)*' characters strictly. - // - params.entityTypeId is NOT in the path template, so it remains in params and gets - // serialized as a query string parameter ?entityTypeId=custom_currency. - // - params.requestBody is extracted and assigned to options.data. - // ========================================================================================= - const expectedPath = - '/v3/projects/my-prj/locations/global/agents/agent-1/sessions/user-%2842%29%2A%27/entityTypes'; - - const scope = nock('https://dialogflow.googleapis.com') - .post(expectedPath, { - displayName: 'CustomCurrency', - entityOverrideMode: 'ENTITY_OVERRIDE_MODE_OVERRIDE', - entities: [{value: 'USD', synonyms: ['dollar', 'bucks']}], - }) - .query({ - entityTypeId: 'custom_currency', - }) - .reply(200, { - name: `${parent}/entityTypes/custom_currency`, - displayName: 'CustomCurrency', - }); - - // 2. User invokes the create method - const res = - await dialogflow.projects.locations.agents.sessions.entityTypes.create({ - parent, - entityTypeId, - requestBody: { - displayName: 'CustomCurrency', - entityOverrideMode: 'ENTITY_OVERRIDE_MODE_OVERRIDE', - entities: [{value: 'USD', synonyms: ['dollar', 'bucks']}], - }, - }); - - assert.strictEqual(res.status, 200); - assert.strictEqual(res.data.displayName, 'CustomCurrency'); - scope.done(); - }); - - it('entityTypes.get: user looks up resource where path contains URI-reserved characters and query params', async () => { - // 1. User initializes the Dialogflow Apiary client - const dialogflow = new dialogflow_v3.Dialogflow({}); - - const name = - 'projects/p/locations/l/agents/a/sessions/s-(99)*/entityTypes/type-1'; - - // ========================================================================================= - // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest - // ========================================================================================= - // When the user calls dialogflow.projects.locations.agents.sessions.entityTypes.get(...): - // - // parameters: { - // options: { - // url: 'https://dialogflow.googleapis.com/v3/{+name}', - // method: 'GET', - // apiVersion: '' - // }, - // params: { - // name: "projects/p/locations/l/agents/a/sessions/s-(99)*/entityTypes/type-1", - // languageCode: 'en' - // }, - // requiredParams: ['name'], - // pathParams: ['name'], - // context: dialogflow.context - // } - // - // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: - // ----------------------------------------------------- - // - name expands into /v3/projects/p/locations/l/agents/a/sessions/s-%2899%29%2A/entityTypes/type-1 - // percent-encoding (, ), * while validating no traversal segments. - // - languageCode is appended as ?languageCode=en. + // - validateAndEncodeParams detects the '..' traversal path segment. + // - createAPIRequest immediately rejects with an Error before sending any HTTP request: + // /Value for session must not contain segments that are exactly \. or \.\./ // ========================================================================================= - const expectedPath = - '/v3/projects/p/locations/l/agents/a/sessions/s-%2899%29%2A/entityTypes/type-1'; - - const scope = nock('https://dialogflow.googleapis.com') - .get(expectedPath) - .query({languageCode: 'en'}) - .reply(200, { - name, - displayName: 'EntityTypeOne', - }); - - const res = - await dialogflow.projects.locations.agents.sessions.entityTypes.get({ - name, - languageCode: 'en', - }); - - assert.strictEqual(res.status, 200); - assert.strictEqual(res.data.name, name); - scope.done(); + await assert.rejects( + dialogflow.projects.locations.agents.sessions.detectIntent({ + session: traversalSession, + requestBody: {}, + }), + /Value for session must not contain segments that are exactly \. or \.\./, + ); }); it('detectIntent: prevents query injection when user supplies a session containing "?" and "$"', async () => { - // 1. User initializes the Dialogflow Apiary client const dialogflow = new dialogflow_v3.Dialogflow({}); - // Session containing characters that could attempt to inject query parameters or URL fragment const injectionSession = 'projects/p/locations/l/agents/a/sessions/session-(1)*?$foo=BAR&admin=1#frag'; - // ========================================================================================= - // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest - // ========================================================================================= - // parameters: { - // options: { - // url: 'https://dialogflow.googleapis.com/v3/{+session}:detectIntent', - // method: 'POST', - // apiVersion: '' - // }, - // params: { - // session: 'projects/p/locations/l/agents/a/sessions/session-(1)*?$foo=BAR&admin=1#frag', - // requestBody: {} - // }, - // requiredParams: ['session'], - // pathParams: ['session'], - // context: dialogflow.context - // } - // - // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: - // ----------------------------------------------------- - // - encodeWithoutSlashes runs RFC 3986 percent-encoding per slash segment: - // - '?' becomes '%3F' - // - '$' becomes '%24' - // - '=' becomes '%3D' - // - '&' becomes '%26' - // - '#' becomes '%23' - // - '(', ')', '*' are percent-encoded strictly as '%28', '%29', '%2A'. - // - This prevents $foo=BAR and admin=1 from becoming actual query parameters. - // ========================================================================================= const expectedPath = '/v3/projects/p/locations/l/agents/a/sessions/session-%281%29%2A%3F%24foo%3DBAR%26admin%3D1%23frag:detectIntent'; @@ -286,44 +86,4 @@ describe('Dialogflow Apiary Client User Simulation', () => { assert.ok(res.config.url?.toString().endsWith(expectedPath)); scope.done(); }); - - it('detectIntent: throws validation error when session path contains path traversal segments', async () => { - // 1. User initializes the Dialogflow Apiary client - const dialogflow = new dialogflow_v3.Dialogflow({}); - - const traversalSession = - 'projects/p/locations/l/agents/a/sessions/agents/../subagent'; - - // ========================================================================================= - // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest - // ========================================================================================= - // parameters: { - // options: { - // url: 'https://dialogflow.googleapis.com/v3/{+session}:detectIntent', - // method: 'POST', - // apiVersion: '' - // }, - // params: { - // session: 'projects/p/locations/l/agents/a/sessions/agents/../subagent', - // requestBody: {} - // }, - // requiredParams: ['session'], - // pathParams: ['session'], - // context: dialogflow.context - // } - // - // BEHAVIORAL EFFECT OF THIS BRANCH ON createAPIRequest: - // ----------------------------------------------------- - // - validateAndEncodeParams detects the '..' traversal path segment. - // - createAPIRequest immediately rejects with an Error before sending any HTTP request: - // /Value for session must not contain segments that are exactly \. or \.\./ - // ========================================================================================= - await assert.rejects( - dialogflow.projects.locations.agents.sessions.detectIntent({ - session: traversalSession, - requestBody: {}, - }), - /Value for session must not contain segments that are exactly \. or \.\./, - ); - }); }); From ff7bc4fa6b11e4eb704d190e4572493a910cdcd3 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 10:43:38 -0400 Subject: [PATCH 14/36] move comment to bottom --- .../test/test.dialogflow.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts index 449c62d11526..501ac8647df8 100644 --- a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts +++ b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts @@ -28,8 +28,13 @@ describe('Dialogflow Apiary Client User Simulation', () => { // 1. User initializes the Dialogflow Apiary client const dialogflow = new dialogflow_v3.Dialogflow({}); - const traversalSession = - 'projects/p/locations/l/agents/a/sessions/agents/../subagent'; + await assert.rejects( + dialogflow.projects.locations.agents.sessions.detectIntent({ + session: 'projects/p/locations/l/agents/a/sessions/agents/../subagent', + requestBody: {}, + }), + /Value for session must not contain segments that are exactly \. or \.\./, + ); // ========================================================================================= // UNDER THE HOOD: VALUES PASSED INTO createAPIRequest @@ -55,13 +60,6 @@ describe('Dialogflow Apiary Client User Simulation', () => { // - createAPIRequest immediately rejects with an Error before sending any HTTP request: // /Value for session must not contain segments that are exactly \. or \.\./ // ========================================================================================= - await assert.rejects( - dialogflow.projects.locations.agents.sessions.detectIntent({ - session: traversalSession, - requestBody: {}, - }), - /Value for session must not contain segments that are exactly \. or \.\./, - ); }); it('detectIntent: prevents query injection when user supplies a session containing "?" and "$"', async () => { From ddb099183bb0e5354e8697cbf57f29ae3a116fe6 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 11:04:10 -0400 Subject: [PATCH 15/36] Simplify validateAndEncode --- .../src/apirequest.ts | 18 +++++------ .../nodejs-googleapis-common/src/http2.ts | 2 +- .../src/transcoding.ts | 31 +++++-------------- .../test/test.dialogflow.ts | 6 ++-- .../test/test.transcoding.ts | 10 +++--- .../nodejs-googleapis-common/tsconfig.json | 3 +- 6 files changed, 26 insertions(+), 44 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/apirequest.ts b/core/packages/nodejs-googleapis-common/src/apirequest.ts index 8c79f7449ae5..f0e49276e7c0 100644 --- a/core/packages/nodejs-googleapis-common/src/apirequest.ts +++ b/core/packages/nodejs-googleapis-common/src/apirequest.ts @@ -169,17 +169,13 @@ async function createAPIRequestAsync( } // Validate and encode path params to prevent traversal and injection attacks - validateAndEncodeParams( - [ - options.url !== undefined && options.url !== null - ? typeof options.url === 'object' - ? options.url.toString() - : options.url - : undefined, - parameters.mediaUrl ?? undefined, - ], - params, - ); + const urlTemplateString = + options.url !== undefined && options.url !== null + ? typeof options.url === 'object' + ? options.url.toString() + : options.url + : parameters.mediaUrl ?? undefined; + validateAndEncodeParams(urlTemplateString, params); // Parse urls if (options.url) { diff --git a/core/packages/nodejs-googleapis-common/src/http2.ts b/core/packages/nodejs-googleapis-common/src/http2.ts index 33a9af182910..d71979d1ab6e 100644 --- a/core/packages/nodejs-googleapis-common/src/http2.ts +++ b/core/packages/nodejs-googleapis-common/src/http2.ts @@ -67,7 +67,7 @@ export async function request( opts.validateStatus = opts.validateStatus || validateStatus; opts.responseType = opts.responseType || 'json'; - const url = new URL(opts.url!); + const url = new URL(opts.url!.toString()); // Check for an existing session to this host, or go create a new one. const sessionData = _getClient(url.host); diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 7461acdf6b55..ddb34c317abc 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -153,39 +153,22 @@ export function normalizePathParams(pathParams?: string[]): void { * multi-segment parameters in params so that reserved characters (query params, fragments, etc.) * cannot be injected into the path. Modifies params in-place. * - * @param urlTemplates List of URL templates associated with the request (e.g. url, mediaUrl) + * @param urlTemplate URL template associated with the request (e.g. url, mediaUrl) * @param params Request parameters dictionary (modified in-place) */ export function validateAndEncodeParams( - urlTemplates: (string | undefined)[], + urlTemplate: string | undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any params: Record, ): void { - // Early return if params is undefined, null, or not an object - if (!params || typeof params !== 'object') { + // Early return if params is undefined, null, or not an object, or if urlTemplate is missing + if (!params || typeof params !== 'object' || !urlTemplate) { return; } - // Track which parameters are multi-segment ({+param}) vs single-segment ({param}). - // - Multi-segment parameters allow slashes ('/') for hierarchical resource paths, - // requiring segment-by-segment traversal checks and strict percent-encoding with slashes preserved. - // - Single-segment parameters disallow slashes and are automatically percent-encoded by url-template, - // requiring only direct '.' and '..' traversal validation. - const multiSegmentParams = new Set(); - const singleSegmentParams = new Set(); - - // 1. Scan provided URL templates (options.url and parameters.mediaUrl) to extract parameter names - for (const tmpl of urlTemplates) { - if (tmpl) { - const extracted = extractTemplateParams(tmpl); - for (const p of extracted.multiSegmentParams) { - multiSegmentParams.add(p); - } - for (const p of extracted.singleSegmentParams) { - singleSegmentParams.add(p); - } - } - } + // 1. Scan provided URL template to extract parameter names + const {multiSegmentParams, singleSegmentParams} = + extractTemplateParams(urlTemplate); // 2. Process multi-segment parameters ({+param}): // - Validate that no individual path segment is '.' or '..' (rejecting path traversal while diff --git a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts index 501ac8647df8..436ec0938185 100644 --- a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts +++ b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts @@ -24,7 +24,8 @@ describe('Dialogflow Apiary Client User Simulation', () => { nock.cleanAll(); }); - it('detectIntent: throws validation error when session path contains path traversal segments', async () => { + it.skip('detectIntent: throws validation error when session path contains path traversal segments', async () => { + // TODO: Re-enable this test when the googleapis-common version with the new encoding is released. // 1. User initializes the Dialogflow Apiary client const dialogflow = new dialogflow_v3.Dialogflow({}); @@ -62,7 +63,8 @@ describe('Dialogflow Apiary Client User Simulation', () => { // ========================================================================================= }); - it('detectIntent: prevents query injection when user supplies a session containing "?" and "$"', async () => { + it.skip('detectIntent: prevents query injection when user supplies a session containing "?" and "$"', async () => { + // TODO: Re-enable this test when the googleapis-common version with the new encoding is released. const dialogflow = new dialogflow_v3.Dialogflow({}); const injectionSession = diff --git a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts index 4a4455a0bf8b..7abbcf4714a6 100644 --- a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts +++ b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts @@ -227,7 +227,7 @@ describe('transcoding', () => { fileId: 'file-123', }; validateAndEncodeParams( - ['https://example.com/v1/{+parent}/files/{fileId}'], + 'https://example.com/v1/{+parent}/files/{fileId}', params, ); assert.strictEqual( @@ -242,7 +242,7 @@ describe('transcoding', () => { name: 'projects/p/locations/l/agents/a/sessions/agents/../subagent', }; assert.throws(() => { - validateAndEncodeParams(['https://example.com/v1/{+name}'], params); + validateAndEncodeParams('https://example.com/v1/{+name}', params); }, /Value for name must not contain segments that are exactly \. or \.\./); }); @@ -252,7 +252,7 @@ describe('transcoding', () => { }; assert.throws(() => { validateAndEncodeParams( - ['https://example.com/drive/v3/files/{fileId}'], + 'https://example.com/drive/v3/files/{fileId}', params, ); }, /Invalid value \.\. for fileId/); @@ -262,7 +262,7 @@ describe('transcoding', () => { const params: Record = { names: ['projects/p/loc/l/a/1?$foo=bar#', 'projects/p/loc/l/a/2'], }; - validateAndEncodeParams(['https://example.com/v1/{+names}'], params); + validateAndEncodeParams('https://example.com/v1/{+names}', params); assert.deepStrictEqual(params.names, [ 'projects/p/loc/l/a/1%3F%24foo%3Dbar%23', 'projects/p/loc/l/a/2', @@ -276,7 +276,7 @@ describe('transcoding', () => { }; assert.doesNotThrow(() => { validateAndEncodeParams( - ['https://example.com/v1/{+name}/files/{fileId}'], + 'https://example.com/v1/{+name}/files/{fileId}', params, ); }); diff --git a/core/packages/nodejs-googleapis-common/tsconfig.json b/core/packages/nodejs-googleapis-common/tsconfig.json index b183f738a00d..84d1567ec2cf 100644 --- a/core/packages/nodejs-googleapis-common/tsconfig.json +++ b/core/packages/nodejs-googleapis-common/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "lib": ["es2023", "dom"], "rootDir": ".", - "outDir": "build" + "outDir": "build", + "skipLibCheck": true }, "include": [ "src/*.ts", From 0ac0aa579cd51125ce780a5c30048ccf98e1ce93 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 12:29:19 -0400 Subject: [PATCH 16/36] Undo unnecessary changes --- .../nodejs-googleapis-common/src/http2.ts | 2 +- .../test/test.dialogflow.ts | 21 +++++++++++++++---- .../nodejs-googleapis-common/tsconfig.json | 3 +-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/http2.ts b/core/packages/nodejs-googleapis-common/src/http2.ts index d71979d1ab6e..33a9af182910 100644 --- a/core/packages/nodejs-googleapis-common/src/http2.ts +++ b/core/packages/nodejs-googleapis-common/src/http2.ts @@ -67,7 +67,7 @@ export async function request( opts.validateStatus = opts.validateStatus || validateStatus; opts.responseType = opts.responseType || 'json'; - const url = new URL(opts.url!.toString()); + const url = new URL(opts.url!); // Check for an existing session to this host, or go create a new one. const sessionData = _getClient(url.host); diff --git a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts index 436ec0938185..079ba5ee3132 100644 --- a/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts +++ b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts @@ -13,8 +13,23 @@ // limitations under the License. import * as assert from 'assert'; +import * as path from 'path'; import {describe, it, afterEach} from 'mocha'; import * as nock from 'nock'; + +// Ensure @googleapis/dialogflow uses our local development version of googleapis-common +const localCommon = require('../src'); +const dfPath = require.resolve('@googleapis/dialogflow'); +const commonPath = require.resolve('googleapis-common', { + paths: [path.dirname(dfPath)], +}); +require.cache[commonPath] = { + id: commonPath, + filename: commonPath, + loaded: true, + exports: localCommon, +} as NodeModule; + import {dialogflow_v3} from '@googleapis/dialogflow'; nock.disableNetConnect(); @@ -24,8 +39,7 @@ describe('Dialogflow Apiary Client User Simulation', () => { nock.cleanAll(); }); - it.skip('detectIntent: throws validation error when session path contains path traversal segments', async () => { - // TODO: Re-enable this test when the googleapis-common version with the new encoding is released. + it('detectIntent: throws validation error when session path contains path traversal segments', async () => { // 1. User initializes the Dialogflow Apiary client const dialogflow = new dialogflow_v3.Dialogflow({}); @@ -63,8 +77,7 @@ describe('Dialogflow Apiary Client User Simulation', () => { // ========================================================================================= }); - it.skip('detectIntent: prevents query injection when user supplies a session containing "?" and "$"', async () => { - // TODO: Re-enable this test when the googleapis-common version with the new encoding is released. + it('detectIntent: prevents query injection when user supplies a session containing "?" and "$"', async () => { const dialogflow = new dialogflow_v3.Dialogflow({}); const injectionSession = diff --git a/core/packages/nodejs-googleapis-common/tsconfig.json b/core/packages/nodejs-googleapis-common/tsconfig.json index 84d1567ec2cf..b183f738a00d 100644 --- a/core/packages/nodejs-googleapis-common/tsconfig.json +++ b/core/packages/nodejs-googleapis-common/tsconfig.json @@ -3,8 +3,7 @@ "compilerOptions": { "lib": ["es2023", "dom"], "rootDir": ".", - "outDir": "build", - "skipLibCheck": true + "outDir": "build" }, "include": [ "src/*.ts", From bc35c8fba967d41bbfdebbb4aadfd624be077fba Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 13:48:15 -0400 Subject: [PATCH 17/36] Consolidate the code into multi and single path segments --- .../src/transcoding.ts | 58 ++++++++----------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index ddb34c317abc..64a1b6692617 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -170,46 +170,34 @@ export function validateAndEncodeParams( const {multiSegmentParams, singleSegmentParams} = extractTemplateParams(urlTemplate); - // 2. Process multi-segment parameters ({+param}): - // - Validate that no individual path segment is '.' or '..' (rejecting path traversal while - // permitting valid domain-scoped names like 'projects/example.com:my-project'). - // - Pre-encode with encodeWithoutSlashes so that reserved characters ('?', '#', '$', '&', '=') - // are strictly percent-encoded according to RFC 3986 before url-template reserved expansion runs, - // preventing query parameter and fragment injection while keeping slashes ('/') intact. - for (const param of multiSegmentParams) { + // 2. Validate and encode parameters: + // - Multi-segment parameters ({+param}) allow slashes for hierarchical resource paths; + // each slash-separated segment is validated against traversal ('.' or '..') and pre-encoded + // with encodeWithoutSlashes to prevent query parameter/fragment injection. + // - Single-segment parameters ({param}) disallow '.' and '..'; url-template standard expansion + // handles character percent-encoding automatically. + const allParams = new Set([...multiSegmentParams, ...singleSegmentParams]); + for (const param of allParams) { const val = params[param]; - if (val !== undefined && val !== null) { - if (Array.isArray(val)) { - for (const item of val) { - validateUriPath(param, String(item)); - } - params[param] = val.map(item => encodeWithoutSlashes(String(item))); - } else { - validateUriPath(param, String(val)); - params[param] = encodeWithoutSlashes(String(val)); - } - } - } - - // 3. Process single-segment parameters ({param}): - // - Validate that the segment is not exactly '.' or '..' to block path traversal. - // - Note: We do NOT pre-encode single-segment values here because url-template standard expansion - // ({param}) automatically applies strict percent-encoding to all reserved characters; - // pre-encoding would lead to double percent-encoding (%25...). - for (const param of singleSegmentParams) { - // Skip if already processed under multiSegmentParams - if (multiSegmentParams.has(param)) { + if (val === undefined || val === null) { continue; } - const val = params[param]; - if (val !== undefined && val !== null) { - if (Array.isArray(val)) { - for (const item of val) { - validateUriPathSegment(param, String(item)); - } + const isMultiSegment = multiSegmentParams.has(param); + const isArray = Array.isArray(val); + const items: unknown[] = isArray ? val : [val]; + + for (const item of items) { + if (isMultiSegment) { + validateUriPath(param, String(item)); } else { - validateUriPathSegment(param, String(val)); + validateUriPathSegment(param, String(item)); } } + + if (isMultiSegment) { + params[param] = isArray + ? val.map(item => encodeWithoutSlashes(String(item))) + : encodeWithoutSlashes(String(val)); + } } } From 8f111004f20797730f58795e352f26ec626bd91d Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 13:50:37 -0400 Subject: [PATCH 18/36] inline urlTemplateString --- core/packages/nodejs-googleapis-common/src/apirequest.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/apirequest.ts b/core/packages/nodejs-googleapis-common/src/apirequest.ts index f0e49276e7c0..738adeeb420b 100644 --- a/core/packages/nodejs-googleapis-common/src/apirequest.ts +++ b/core/packages/nodejs-googleapis-common/src/apirequest.ts @@ -169,13 +169,14 @@ async function createAPIRequestAsync( } // Validate and encode path params to prevent traversal and injection attacks - const urlTemplateString = + validateAndEncodeParams( options.url !== undefined && options.url !== null ? typeof options.url === 'object' ? options.url.toString() : options.url - : parameters.mediaUrl ?? undefined; - validateAndEncodeParams(urlTemplateString, params); + : parameters.mediaUrl ?? undefined, + params, + ); // Parse urls if (options.url) { From 4a315135562fc670f3c2df2082d8c4481f1d038b Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 13:56:44 -0400 Subject: [PATCH 19/36] reduce api surface for code change --- .../src/transcoding.ts | 16 +- .../test/test.transcoding.ts | 285 ------------------ 2 files changed, 5 insertions(+), 296 deletions(-) delete mode 100644 core/packages/nodejs-googleapis-common/test/test.transcoding.ts diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 64a1b6692617..f962c8478379 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -19,7 +19,7 @@ * @param propertyName Name of the parameter being validated * @param value Value of the path segment */ -export function validateUriPathSegment( +function validateUriPathSegment( propertyName: string, value: string, ): void { @@ -37,7 +37,7 @@ export function validateUriPathSegment( * @param propertyName Name of the parameter being validated * @param value Value of the multi-segment path */ -export function validateUriPath( +function validateUriPath( propertyName: string, value: string, ): void { @@ -51,12 +51,6 @@ export function validateUriPath( } } -/** - * Aliases for backwards compatibility. - */ -export const validateSingleSegment = validateUriPathSegment; -export const validateMultiSegment = validateUriPath; - /** * Percent-encodes a string according to RFC 3986, preserving only unreserved * characters (alpha-numeric, '-', '_', '.', and '~'). All other characters, @@ -69,7 +63,7 @@ export const validateMultiSegment = validateUriPath; * @param str The input string to encode * @returns The percent-encoded string */ -export function encodeWithSlashes(str: string): string { +function encodeWithSlashes(str: string): string { return encodeURIComponent(str).replace( /[!'()*]/g, // Characters preserved by encodeURIComponent character => '%' + character.charCodeAt(0).toString(16).toUpperCase(), @@ -84,7 +78,7 @@ export function encodeWithSlashes(str: string): string { * @param str The input string to encode * @returns The percent-encoded string with slashes preserved */ -export function encodeWithoutSlashes(str: string): string { +function encodeWithoutSlashes(str: string): string { return str.split('/').map(encodeWithSlashes).join('/'); } @@ -93,7 +87,7 @@ export function encodeWithoutSlashes(str: string): string { * * @param urlTemplate The RFC 6570 URI template string */ -export function extractTemplateParams(urlTemplate: string): { +function extractTemplateParams(urlTemplate: string): { multiSegmentParams: Set; singleSegmentParams: Set; } { diff --git a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts b/core/packages/nodejs-googleapis-common/test/test.transcoding.ts deleted file mode 100644 index 7abbcf4714a6..000000000000 --- a/core/packages/nodejs-googleapis-common/test/test.transcoding.ts +++ /dev/null @@ -1,285 +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} from 'mocha'; -import { - validateUriPathSegment, - validateUriPath, - encodeWithSlashes, - encodeWithoutSlashes, - extractTemplateParams, - normalizePathParams, - validateAndEncodeParams, -} from '../src/transcoding'; - -describe('transcoding', () => { - describe('validateUriPathSegment', () => { - it('should throw for "."', () => { - assert.throws(() => { - validateUriPathSegment('fileId', '.'); - }, /Invalid value \. for fileId/); - }); - - it('should throw for ".."', () => { - assert.throws(() => { - validateUriPathSegment('fileId', '..'); - }, /Invalid value \.\. for fileId/); - }); - - it('should allow valid single segment names', () => { - assert.doesNotThrow(() => { - validateUriPathSegment('fileId', 'valid-id'); - validateUriPathSegment('fileId', 'file.txt'); - validateUriPathSegment('fileId', 'example.com'); - }); - }); - }); - - describe('validateUriPath', () => { - it('should throw for ".." segment in multi-segment path', () => { - assert.throws(() => { - validateUriPath( - 'session', - 'projects/p/locations/l/agents/a/sessions/agents/../subagent', - ); - }, /Value for session must not contain segments that are exactly \. or \.\./); - - assert.throws(() => { - validateUriPath('name', '..'); - }, /Value for name must not contain segments that are exactly \. or \.\./); - - assert.throws(() => { - validateUriPath('name', 'a/b/..'); - }, /Value for name must not contain segments that are exactly \. or \.\./); - }); - - it('should throw for "." segment in multi-segment path', () => { - assert.throws(() => { - validateUriPath( - 'session', - 'projects/p/locations/l/agents/a/sessions/agents/./subagent', - ); - }, /Value for session must not contain segments that are exactly \. or \.\./); - - assert.throws(() => { - validateUriPath('name', '.'); - }, /Value for name must not contain segments that are exactly \. or \.\./); - - assert.throws(() => { - validateUriPath('name', './a/b'); - }, /Value for name must not contain segments that are exactly \. or \.\./); - }); - - it('should allow valid domain-scoped and resource paths with dots', () => { - assert.doesNotThrow(() => { - validateUriPath( - 'parent', - 'projects/example.com:custom-project/locations/global', - ); - validateUriPath( - 'session', - 'projects/p/locations/l/agents/a/sessions/123.456', - ); - validateUriPath('name', 'a/b/c'); - }); - }); - - it('should allow empty/falsy values', () => { - assert.doesNotThrow(() => { - validateUriPath('name', ''); - }); - }); - }); - - describe('encodeWithSlashes', () => { - it('should preserve unreserved characters', () => { - const unreserved = 'abc-123_.~XYZ'; - assert.strictEqual(encodeWithSlashes(unreserved), unreserved); - }); - - it('should percent-encode slashes', () => { - assert.strictEqual(encodeWithSlashes('/'), '%2F'); - }); - - it('should properly encode Unicode surrogate pairs / emojis', () => { - assert.strictEqual(encodeWithSlashes('😊'), '%F0%9F%98%8A'); - }); - - it('should preserve unreserved characters while strictly percent-encoding all other characters in encodeWithSlashes', () => { - // Standard RFC unreserved characters: [-_.~0-9a-zA-Z] - const unreserved = 'abc-123_.~XYZ'; - assert.strictEqual(encodeWithSlashes(unreserved), unreserved); - - // Reserved and special characters: should be percent encoded, including !\'()* - const specialChars = "!'()*"; - const encoded = encodeWithSlashes(specialChars); - // ! -> %21, ' -> %27, ( -> %28, ) -> %29, * -> %2A - assert.strictEqual(encoded, '%21%27%28%29%2A'); - }); - }); - - describe('encodeWithoutSlashes', () => { - it('should preserve slashes and unreserved characters', () => { - assert.strictEqual( - encodeWithoutSlashes('projects/my-proj_1.0~v2/locations/us-central1'), - 'projects/my-proj_1.0~v2/locations/us-central1', - ); - }); - - it('should percent-encode query, fragment, and special characters', () => { - const input = - 'projects/p/locations/l/agents/a/sessions/my-session?$foo=BAR#'; - const expected = - 'projects/p/locations/l/agents/a/sessions/my-session%3F%24foo%3DBAR%23'; - assert.strictEqual(encodeWithoutSlashes(input), expected); - }); - - it('should percent-encode reserved characters while preserving slashes', () => { - const input = "projects/p/locations/l/agents/a/sessions/ !@$&'()*+,;=:%"; - const expected = - 'projects/p/locations/l/agents/a/sessions/%20%21%40%24%26%27%28%29%2A%2B%2C%3B%3D%3A%25'; - assert.strictEqual(encodeWithoutSlashes(input), expected); - }); - - it('should handle Unicode surrogate pairs in paths', () => { - assert.strictEqual( - encodeWithoutSlashes('projects/p/sessions/😊'), - 'projects/p/sessions/%F0%9F%98%8A', - ); - }); - }); - - describe('extractTemplateParams', () => { - it('should identify multi-segment parameters from {+param}', () => { - const res = extractTemplateParams( - 'https://example.com/v1/{+name}:approve', - ); - assert.deepStrictEqual(Array.from(res.multiSegmentParams), ['name']); - assert.deepStrictEqual(Array.from(res.singleSegmentParams), []); - }); - - it('should identify single-segment parameters from {param}', () => { - const res = extractTemplateParams( - 'https://example.com/drive/v3/files/{fileId}', - ); - assert.deepStrictEqual(Array.from(res.multiSegmentParams), []); - assert.deepStrictEqual(Array.from(res.singleSegmentParams), ['fileId']); - }); - - it('should identify mixed templates with multiple parameters', () => { - const res = extractTemplateParams( - 'https://example.com/v1/{+parent}/databases/{databaseId}/documents/{+documentPath}', - ); - assert.deepStrictEqual(Array.from(res.multiSegmentParams), [ - 'parent', - 'documentPath', - ]); - assert.deepStrictEqual(Array.from(res.singleSegmentParams), [ - 'databaseId', - ]); - }); - - it('should handle comma-separated template variables', () => { - const res = extractTemplateParams( - 'https://example.com/v1/{var1,var2}/{+multi1,multi2}', - ); - assert.deepStrictEqual(Array.from(res.singleSegmentParams), [ - 'var1', - 'var2', - ]); - assert.deepStrictEqual(Array.from(res.multiSegmentParams), [ - 'multi1', - 'multi2', - ]); - }); - }); - - describe('normalizePathParams', () => { - it('should un-alias trailing underscores in pathParams', () => { - const pathParams = ['resource_', 'project_', 'fileId']; - normalizePathParams(pathParams); - assert.deepStrictEqual(pathParams, ['resource', 'project', 'fileId']); - }); - - it('should handle undefined, null, or empty pathParams safely', () => { - assert.doesNotThrow(() => normalizePathParams(undefined)); - assert.doesNotThrow(() => normalizePathParams([])); - }); - }); - - describe('validateAndEncodeParams', () => { - it('should validate and encode multi-segment params and validate single-segment params', () => { - const params: Record = { - parent: - 'projects/p/locations/l/agents/a/sessions/my-session?$foo=BAR#', - fileId: 'file-123', - }; - validateAndEncodeParams( - 'https://example.com/v1/{+parent}/files/{fileId}', - params, - ); - assert.strictEqual( - params.parent, - 'projects/p/locations/l/agents/a/sessions/my-session%3F%24foo%3DBAR%23', - ); - assert.strictEqual(params.fileId, 'file-123'); - }); - - it('should throw on path traversal in multi-segment params', () => { - const params: Record = { - name: 'projects/p/locations/l/agents/a/sessions/agents/../subagent', - }; - assert.throws(() => { - validateAndEncodeParams('https://example.com/v1/{+name}', params); - }, /Value for name must not contain segments that are exactly \. or \.\./); - }); - - it('should throw on path traversal in single-segment params', () => { - const params: Record = { - fileId: '..', - }; - assert.throws(() => { - validateAndEncodeParams( - 'https://example.com/drive/v3/files/{fileId}', - params, - ); - }, /Invalid value \.\. for fileId/); - }); - - it('should handle array path parameters', () => { - const params: Record = { - names: ['projects/p/loc/l/a/1?$foo=bar#', 'projects/p/loc/l/a/2'], - }; - validateAndEncodeParams('https://example.com/v1/{+names}', params); - assert.deepStrictEqual(params.names, [ - 'projects/p/loc/l/a/1%3F%24foo%3Dbar%23', - 'projects/p/loc/l/a/2', - ]); - }); - - it('should handle missing and null params gracefully', () => { - const params: Record = { - name: null, - fileId: undefined, - }; - assert.doesNotThrow(() => { - validateAndEncodeParams( - 'https://example.com/v1/{+name}/files/{fileId}', - params, - ); - }); - }); - }); -}); From 6c76cd34538fb0c32e3f6c986eef7f2492a5acad Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 14:02:38 -0400 Subject: [PATCH 20/36] Eliminate the while loop --- core/packages/nodejs-googleapis-common/src/transcoding.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index f962c8478379..a540645148a0 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -93,10 +93,9 @@ function extractTemplateParams(urlTemplate: string): { } { const multiSegmentParams = new Set(); const singleSegmentParams = new Set(); - const regex = /\{([^}]+)\}/g; - let match: RegExpExecArray | null; + const matches = urlTemplate.matchAll(/\{([^}]+)\}/g); - while ((match = regex.exec(urlTemplate)) !== null) { + for (const match of matches) { const expression = match[1]; if (expression.startsWith('+')) { const vars = expression.slice(1).split(','); From 567330d538fbbf0f986414a3a7c800961927c2fc Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 14:15:03 -0400 Subject: [PATCH 21/36] Do the scan by wildcards instead --- .../src/transcoding.ts | 70 ++++++++----------- 1 file changed, 28 insertions(+), 42 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index a540645148a0..4e3f7e10fc45 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -83,44 +83,39 @@ function encodeWithoutSlashes(str: string): string { } /** - * Extracts template parameter names and classifies them as multi-segment or single-segment. + * Extracts template parameters and their corresponding wildcard types ('*' or '**'). * * @param urlTemplate The RFC 6570 URI template string */ -function extractTemplateParams(urlTemplate: string): { - multiSegmentParams: Set; - singleSegmentParams: Set; -} { - const multiSegmentParams = new Set(); - const singleSegmentParams = new Set(); +function extractTemplateParams(urlTemplate: string): Array<{ + param: string; + wildcard: '*' | '**'; +}> { + const paramMap = new Map(); const matches = urlTemplate.matchAll(/\{([^}]+)\}/g); for (const match of matches) { const expression = match[1]; - if (expression.startsWith('+')) { - const vars = expression.slice(1).split(','); - for (const v of vars) { - const paramName = v.replace(/^([^:*]+).*/, '$1').trim(); - if (paramName) { - multiSegmentParams.add(paramName); - } - } - } else { - const firstChar = expression.charAt(0); - const rawExpr = ['#', '.', '/', ';', '?', '&'].includes(firstChar) - ? expression.slice(1) - : expression; - const vars = rawExpr.split(','); - for (const v of vars) { - const paramName = v.replace(/^([^:*]+).*/, '$1').trim(); - if (paramName) { - singleSegmentParams.add(paramName); + const wildcard: '*' | '**' = expression.startsWith('+') ? '**' : '*'; + const firstChar = expression.charAt(0); + const rawExpr = ['+', '#', '.', '/', ';', '?', '&'].includes(firstChar) + ? expression.slice(1) + : expression; + const vars = rawExpr.split(','); + for (const v of vars) { + const paramName = v.replace(/^([^:*]+).*/, '$1').trim(); + if (paramName) { + if (!paramMap.has(paramName) || wildcard === '**') { + paramMap.set(paramName, wildcard); } } } } - return {multiSegmentParams, singleSegmentParams}; + return Array.from(paramMap.entries()).map(([param, wildcard]) => ({ + param, + wildcard, + })); } /** @@ -159,35 +154,26 @@ export function validateAndEncodeParams( return; } - // 1. Scan provided URL template to extract parameter names - const {multiSegmentParams, singleSegmentParams} = - extractTemplateParams(urlTemplate); + // Identify the parameters and wildcards in the URL template + const templateParams = extractTemplateParams(urlTemplate); - // 2. Validate and encode parameters: - // - Multi-segment parameters ({+param}) allow slashes for hierarchical resource paths; - // each slash-separated segment is validated against traversal ('.' or '..') and pre-encoded - // with encodeWithoutSlashes to prevent query parameter/fragment injection. - // - Single-segment parameters ({param}) disallow '.' and '..'; url-template standard expansion - // handles character percent-encoding automatically. - const allParams = new Set([...multiSegmentParams, ...singleSegmentParams]); - for (const param of allParams) { + for (const {param, wildcard} of templateParams) { const val = params[param]; if (val === undefined || val === null) { continue; } - const isMultiSegment = multiSegmentParams.has(param); const isArray = Array.isArray(val); const items: unknown[] = isArray ? val : [val]; for (const item of items) { - if (isMultiSegment) { - validateUriPath(param, String(item)); - } else { + if (wildcard === '*') { validateUriPathSegment(param, String(item)); + } else if (wildcard === '**') { + validateUriPath(param, String(item)); } } - if (isMultiSegment) { + if (wildcard === '**') { params[param] = isArray ? val.map(item => encodeWithoutSlashes(String(item))) : encodeWithoutSlashes(String(val)); From 7d6bfb2f1c0f9c6a0afd4de6a27d67e3c738f8fd Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 16:47:01 -0400 Subject: [PATCH 22/36] Add the code snippet verbatim --- .../src/transcoding.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 4e3f7e10fc45..bbef547abbb6 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -164,12 +164,20 @@ export function validateAndEncodeParams( } const isArray = Array.isArray(val); const items: unknown[] = isArray ? val : [val]; + const match = [null, ...items.map(String)]; + const wildcards = items.map(() => wildcard); + const propertyName = param; - for (const item of items) { - if (wildcard === '*') { - validateUriPathSegment(param, String(item)); - } else if (wildcard === '**') { - validateUriPath(param, String(item)); + // Check the captured group values + for (let i = 1; i < match.length; i++) { + const groupVal = match[i]; + if (groupVal !== undefined && groupVal !== null) { + const wildcardType = wildcards[i - 1]; + if (wildcardType === '*') { + validateUriPathSegment(propertyName, groupVal); + } else if (wildcardType === '**') { + validateUriPath(propertyName, groupVal); + } } } From c206f2d28254cc7546e7ea43f84cd6a57dc711d8 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 16:51:16 -0400 Subject: [PATCH 23/36] Add comments about refactor --- core/packages/nodejs-googleapis-common/src/transcoding.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index bbef547abbb6..3a05575397a6 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -168,7 +168,9 @@ export function validateAndEncodeParams( const wildcards = items.map(() => wildcard); const propertyName = param; - // Check the captured group values + // Check the captured group values. + // Note: The loop below matches the validation loop in google-gax verbatim. + // TODO: Consider refactoring this in the future. for (let i = 1; i < match.length; i++) { const groupVal = match[i]; if (groupVal !== undefined && groupVal !== null) { From 188385e82032a249c4f97a6ffdb2c5ba952ed706 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 17:06:23 -0400 Subject: [PATCH 24/36] consolidate all the code into applyPattern --- .../src/transcoding.ts | 88 ++++++++++++++----- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 3a05575397a6..1da5f2f16fed 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -118,6 +118,59 @@ function extractTemplateParams(urlTemplate: string): Array<{ })); } +function escapeRegExp(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function applyPattern( + pattern: string, + fieldValue: string, + propertyName = 'resource', // Used to provide precise error messages when path validation fails +): string | undefined { + if (!pattern || pattern === '*') { + validateUriPathSegment(propertyName, fieldValue); + return encodeWithSlashes(fieldValue); + } + + if (!pattern.includes('*') && pattern !== fieldValue) { + return undefined; + } + + // since we're converting the pattern to a regex, make necessary precautions: + const regex = new RegExp( + '^' + + escapeRegExp(pattern) + .replace(/\\\*\\\*/g, '(.+)') + .replace(/\\\*/g, '([^/]+)') + + '$', + ); + + const match = fieldValue.match(regex); + if (!match) { + return undefined; + } + + // Identify the segments and wildcards in pattern to perform validation in order of appearance + const wildcards: string[] = pattern.match(/\*\*|\*/g) || []; + + // Check the captured group values. + // Note: The loop below matches the validation loop in google-gax verbatim. + // TODO: Consider refactoring this in the future. + for (let i = 1; i < match.length; i++) { + const groupVal = match[i]; + if (groupVal !== undefined && groupVal !== null) { + const wildcardType = wildcards[i - 1]; + if (wildcardType === '*') { + validateUriPathSegment(propertyName, groupVal); + } else if (wildcardType === '**') { + validateUriPath(propertyName, groupVal); + } + } + } + + return encodeWithoutSlashes(fieldValue); +} + /** * Modifies the pathParams array in-place to normalize / un-alias parameters * that have trailing underscores (e.g. 'resource_' -> 'resource') due to @@ -162,31 +215,18 @@ export function validateAndEncodeParams( if (val === undefined || val === null) { continue; } - const isArray = Array.isArray(val); - const items: unknown[] = isArray ? val : [val]; - const match = [null, ...items.map(String)]; - const wildcards = items.map(() => wildcard); - const propertyName = param; - - // Check the captured group values. - // Note: The loop below matches the validation loop in google-gax verbatim. - // TODO: Consider refactoring this in the future. - for (let i = 1; i < match.length; i++) { - const groupVal = match[i]; - if (groupVal !== undefined && groupVal !== null) { - const wildcardType = wildcards[i - 1]; - if (wildcardType === '*') { - validateUriPathSegment(propertyName, groupVal); - } else if (wildcardType === '**') { - validateUriPath(propertyName, groupVal); - } + if (Array.isArray(val)) { + const transformed = val.map(item => + applyPattern(wildcard, String(item), param), + ); + if (wildcard === '**') { + params[param] = transformed; + } + } else { + const transformed = applyPattern(wildcard, String(val), param); + if (wildcard === '**') { + params[param] = transformed; } - } - - if (wildcard === '**') { - params[param] = isArray - ? val.map(item => encodeWithoutSlashes(String(item))) - : encodeWithoutSlashes(String(val)); } } } From 8ce8ad1e4d1e266974ddba1637f0c2e46e92d083 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 17:11:05 -0400 Subject: [PATCH 25/36] Add JS documentation --- .../nodejs-googleapis-common/src/transcoding.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 1da5f2f16fed..e38f4ffa159e 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -122,6 +122,16 @@ function escapeRegExp(str: string) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +/** + * Matches the applyPattern method in google-gax verbatim. + * Validates a field value against a wildcard pattern ('*' single-segment or '**' multi-segment) + * to prevent path traversal attacks ('.' and '..') and percent-encodes the string. + * + * @param pattern The wildcard pattern ('*' or '**') + * @param fieldValue The string value to validate and encode + * @param propertyName Name of the parameter being validated (used for error reporting) + * @returns The encoded string if the value matches the pattern, or undefined if it does not + */ function applyPattern( pattern: string, fieldValue: string, From 9f98c3ab1b86386c03439b4eba2e8228cb936b49 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 17:13:37 -0400 Subject: [PATCH 26/36] Simplify use of apply pattern --- .../nodejs-googleapis-common/src/transcoding.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index e38f4ffa159e..c4eb0cfb82f4 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -225,18 +225,11 @@ export function validateAndEncodeParams( if (val === undefined || val === null) { continue; } - if (Array.isArray(val)) { - const transformed = val.map(item => - applyPattern(wildcard, String(item), param), - ); - if (wildcard === '**') { - params[param] = transformed; - } - } else { - const transformed = applyPattern(wildcard, String(val), param); - if (wildcard === '**') { - params[param] = transformed; - } + const transformed = Array.isArray(val) + ? val.map(item => applyPattern(wildcard, String(item), param)) + : applyPattern(wildcard, String(val), param); + if (wildcard === '**') { + params[param] = transformed; } } } From b0ea0dd54697660ea5482430c3648203405b54c5 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Wed, 19 Aug 2026 17:16:43 -0400 Subject: [PATCH 27/36] Add an input/output example --- .../nodejs-googleapis-common/src/transcoding.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index c4eb0cfb82f4..ef0e3778156e 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -85,7 +85,21 @@ function encodeWithoutSlashes(str: string): string { /** * Extracts template parameters and their corresponding wildcard types ('*' or '**'). * + * @example + * ```ts + * // Input: + * 'https://example.com/v1/{+parent}/databases/{databaseId}/documents/{+documentPath}' + * + * // Output: + * [ + * { param: 'parent', wildcard: '**' }, + * { param: 'databaseId', wildcard: '*' }, + * { param: 'documentPath', wildcard: '**' } + * ] + * ``` + * * @param urlTemplate The RFC 6570 URI template string + * @returns Array of parameter names and their associated wildcard pattern */ function extractTemplateParams(urlTemplate: string): Array<{ param: string; From 5e6f379c181606a0c6c6c9d1a660a648937db57d Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 09:35:58 -0400 Subject: [PATCH 28/36] Change the variable name to parameterValue --- .../nodejs-googleapis-common/src/transcoding.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index ef0e3778156e..fbf49cf9b0d0 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -235,13 +235,13 @@ export function validateAndEncodeParams( const templateParams = extractTemplateParams(urlTemplate); for (const {param, wildcard} of templateParams) { - const val = params[param]; - if (val === undefined || val === null) { + const parameterValue = params[param]; + if (parameterValue === undefined || parameterValue === null) { continue; } - const transformed = Array.isArray(val) - ? val.map(item => applyPattern(wildcard, String(item), param)) - : applyPattern(wildcard, String(val), param); + const transformed = Array.isArray(parameterValue) + ? parameterValue.map(item => applyPattern(wildcard, String(item), param)) + : applyPattern(wildcard, String(parameterValue), param); if (wildcard === '**') { params[param] = transformed; } From 91860e5d5cf10dd49f829d0b5e0d103e74a14296 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 09:51:01 -0400 Subject: [PATCH 29/36] Add a single wildcard test --- .../test/test.apirequest.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/core/packages/nodejs-googleapis-common/test/test.apirequest.ts b/core/packages/nodejs-googleapis-common/test/test.apirequest.ts index c2d3848c387e..9976fe2de0c2 100644 --- a/core/packages/nodejs-googleapis-common/test/test.apirequest.ts +++ b/core/packages/nodejs-googleapis-common/test/test.apirequest.ts @@ -892,5 +892,26 @@ describe('createAPIRequest', () => { assert.ok(res.config.url?.toString().endsWith(p)); scope.done(); }); + + it('should percent-encode all reserved characters (including slashes) for single-segment (*) path parameters', async () => { + const p = '/drive/v3/files/folder%2Ffile%201%3F%24foo%3Dbar%23'; + const scope = nock('https://example.com').get(p).reply(200, {}); + + const res = await createAPIRequest({ + options: { + url: 'https://example.com/drive/v3/files/{fileId}', + method: 'GET', + }, + params: { + fileId: 'folder/file 1?$foo=bar#', + }, + requiredParams: [], + pathParams: ['fileId'], + context: fakeContext, + }); + + assert.ok(res.config.url?.toString().endsWith(p)); + scope.done(); + }); }); }); From 9b02fe8fa2421218e596349a9886907c3fb1a5da Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 10:09:09 -0400 Subject: [PATCH 30/36] chore: remove normalizePathParams from request pipeline --- .../nodejs-googleapis-common/src/apirequest.ts | 4 +--- .../nodejs-googleapis-common/src/transcoding.ts | 17 ----------------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/apirequest.ts b/core/packages/nodejs-googleapis-common/src/apirequest.ts index 738adeeb420b..a4fb63a34678 100644 --- a/core/packages/nodejs-googleapis-common/src/apirequest.ts +++ b/core/packages/nodejs-googleapis-common/src/apirequest.ts @@ -24,7 +24,7 @@ import {SchemaParameters} from './schema'; import * as h2 from './http2'; import {GaxiosResponseWithHTTP2} from './http2'; import {headersToClassicHeaders, marshallGaxiosResponse} from './util'; -import {normalizePathParams, validateAndEncodeParams} from './transcoding'; +import {validateAndEncodeParams} from './transcoding'; // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('../../package.json'); @@ -157,8 +157,6 @@ async function createAPIRequestAsync( } }); - // Un-alias path parameters that were modified due to conflicts with reserved names - normalizePathParams(parameters.pathParams); // Check for missing required parameters in the API request const missingParams = getMissingParams(params, parameters.requiredParams); diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index fbf49cf9b0d0..5f757110bd48 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -195,23 +195,6 @@ function applyPattern( return encodeWithoutSlashes(fieldValue); } -/** - * Modifies the pathParams array in-place to normalize / un-alias parameters - * that have trailing underscores (e.g. 'resource_' -> 'resource') due to - * conflicts with JavaScript reserved words. - * - * @param pathParams List of path parameter names to normalize in-place - */ -export function normalizePathParams(pathParams?: string[]): void { - if (!pathParams || !Array.isArray(pathParams)) { - return; - } - for (let i = 0; i < pathParams.length; i++) { - if (pathParams[i].slice(-1) === '_') { - pathParams[i] = pathParams[i].slice(0, -1); - } - } -} /** * Validates path parameters against traversal attacks ('.' and '..') and encodes From b564f3508b8c4c9b0aeaf0a4d0cbbc238b6126b6 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 10:49:40 -0400 Subject: [PATCH 31/36] refactor with comments so the * and ** distinction is clear --- .../src/transcoding.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 5f757110bd48..c12d25ee0623 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -222,11 +222,23 @@ export function validateAndEncodeParams( if (parameterValue === undefined || parameterValue === null) { continue; } - const transformed = Array.isArray(parameterValue) - ? parameterValue.map(item => applyPattern(wildcard, String(item), param)) - : applyPattern(wildcard, String(parameterValue), param); if (wildcard === '**') { - params[param] = transformed; + params[param] = Array.isArray(parameterValue) + ? parameterValue.map(item => + applyPattern(wildcard, String(item), param), + ) + : applyPattern(wildcard, String(parameterValue), param); + } else { + // For single-segment parameters (*), only validation against path traversal (. and ..) + // is needed here. Character percent-encoding is handled automatically by url-template later + // when urlTemplate.parse(url).expand(params) is called in createAPIRequestAsync. + if (Array.isArray(parameterValue)) { + parameterValue.forEach(item => + validateUriPathSegment(param, String(item)), + ); + } else { + validateUriPathSegment(param, String(parameterValue)); + } } } } From 43456760242cfc0fa9339b149363e225f1b483a9 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 20 Aug 2026 11:05:13 -0400 Subject: [PATCH 32/36] Remove the extra line --- core/packages/nodejs-googleapis-common/src/apirequest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/core/packages/nodejs-googleapis-common/src/apirequest.ts b/core/packages/nodejs-googleapis-common/src/apirequest.ts index a4fb63a34678..e6b9b477c636 100644 --- a/core/packages/nodejs-googleapis-common/src/apirequest.ts +++ b/core/packages/nodejs-googleapis-common/src/apirequest.ts @@ -157,7 +157,6 @@ async function createAPIRequestAsync( } }); - // Check for missing required parameters in the API request const missingParams = getMissingParams(params, parameters.requiredParams); if (missingParams) { From 5d64cb93b986507103c5a573ac3968156380d7ee Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 10:54:38 -0400 Subject: [PATCH 33/36] simplify expression --- core/packages/nodejs-googleapis-common/src/apirequest.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/apirequest.ts b/core/packages/nodejs-googleapis-common/src/apirequest.ts index e6b9b477c636..d012333b4255 100644 --- a/core/packages/nodejs-googleapis-common/src/apirequest.ts +++ b/core/packages/nodejs-googleapis-common/src/apirequest.ts @@ -165,13 +165,10 @@ async function createAPIRequestAsync( throw new Error('Missing required parameters: ' + missingParams.join(', ')); } - // Validate and encode path params to prevent traversal and injection attacks + // Validate and encode path params to prevent traversal and injection attacks. + // Uses options.url (converting URL objects to string if possible) or falls back to mediaUrl. validateAndEncodeParams( - options.url !== undefined && options.url !== null - ? typeof options.url === 'object' - ? options.url.toString() - : options.url - : parameters.mediaUrl ?? undefined, + options.url?.toString() ?? parameters.mediaUrl ?? undefined, params, ); From 2d5e51f28c15652ee6cb5a46bec451dec3fc9977 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 11:11:46 -0400 Subject: [PATCH 34/36] Eliminate applyPattern --- .../src/transcoding.ts | 74 ++----------------- 1 file changed, 6 insertions(+), 68 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index c12d25ee0623..ceb62640d506 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -132,70 +132,6 @@ function extractTemplateParams(urlTemplate: string): Array<{ })); } -function escapeRegExp(str: string) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Matches the applyPattern method in google-gax verbatim. - * Validates a field value against a wildcard pattern ('*' single-segment or '**' multi-segment) - * to prevent path traversal attacks ('.' and '..') and percent-encodes the string. - * - * @param pattern The wildcard pattern ('*' or '**') - * @param fieldValue The string value to validate and encode - * @param propertyName Name of the parameter being validated (used for error reporting) - * @returns The encoded string if the value matches the pattern, or undefined if it does not - */ -function applyPattern( - pattern: string, - fieldValue: string, - propertyName = 'resource', // Used to provide precise error messages when path validation fails -): string | undefined { - if (!pattern || pattern === '*') { - validateUriPathSegment(propertyName, fieldValue); - return encodeWithSlashes(fieldValue); - } - - if (!pattern.includes('*') && pattern !== fieldValue) { - return undefined; - } - - // since we're converting the pattern to a regex, make necessary precautions: - const regex = new RegExp( - '^' + - escapeRegExp(pattern) - .replace(/\\\*\\\*/g, '(.+)') - .replace(/\\\*/g, '([^/]+)') + - '$', - ); - - const match = fieldValue.match(regex); - if (!match) { - return undefined; - } - - // Identify the segments and wildcards in pattern to perform validation in order of appearance - const wildcards: string[] = pattern.match(/\*\*|\*/g) || []; - - // Check the captured group values. - // Note: The loop below matches the validation loop in google-gax verbatim. - // TODO: Consider refactoring this in the future. - for (let i = 1; i < match.length; i++) { - const groupVal = match[i]; - if (groupVal !== undefined && groupVal !== null) { - const wildcardType = wildcards[i - 1]; - if (wildcardType === '*') { - validateUriPathSegment(propertyName, groupVal); - } else if (wildcardType === '**') { - validateUriPath(propertyName, groupVal); - } - } - } - - return encodeWithoutSlashes(fieldValue); -} - - /** * Validates path parameters against traversal attacks ('.' and '..') and encodes * multi-segment parameters in params so that reserved characters (query params, fragments, etc.) @@ -223,11 +159,13 @@ export function validateAndEncodeParams( continue; } if (wildcard === '**') { + const encodeParam = (val: string) => { + validateUriPath(param, val); + return encodeWithoutSlashes(val); + }; params[param] = Array.isArray(parameterValue) - ? parameterValue.map(item => - applyPattern(wildcard, String(item), param), - ) - : applyPattern(wildcard, String(parameterValue), param); + ? parameterValue.map(item => encodeParam(String(item))) + : encodeParam(String(parameterValue)); } else { // For single-segment parameters (*), only validation against path traversal (. and ..) // is needed here. Character percent-encoding is handled automatically by url-template later From 45be5236fd402c51573f5f9d948790754bcaf28b Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 11:19:47 -0400 Subject: [PATCH 35/36] Add a comment explaining the rationale of the code --- core/packages/nodejs-googleapis-common/src/transcoding.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index ceb62640d506..7c407b03a694 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -159,6 +159,12 @@ export function validateAndEncodeParams( continue; } if (wildcard === '**') { + // This block applies the core logic of google-gax's applyPattern method, + // but is greatly simplified because the wildcard type ('**') is already known. + // As a result, we do not need to convert arbitrary patterns into regular + // expressions, match against field values, extract capture groups, or + // scan and dispatch validation for variable wildcard types. We can directly + // validate against traversal segments and encode with slashes preserved. const encodeParam = (val: string) => { validateUriPath(param, val); return encodeWithoutSlashes(val); From a96ae8e92ce6c7b15f9dc2fa961378cccd9fc0f8 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Fri, 21 Aug 2026 11:38:55 -0400 Subject: [PATCH 36/36] Replace the templating function --- .../src/transcoding.ts | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/core/packages/nodejs-googleapis-common/src/transcoding.ts b/core/packages/nodejs-googleapis-common/src/transcoding.ts index 7c407b03a694..a3e5d82ec210 100644 --- a/core/packages/nodejs-googleapis-common/src/transcoding.ts +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -106,23 +106,16 @@ function extractTemplateParams(urlTemplate: string): Array<{ wildcard: '*' | '**'; }> { const paramMap = new Map(); - const matches = urlTemplate.matchAll(/\{([^}]+)\}/g); + + // Natively skips {}, {#}, {?}, and {,} by demanding valid variable characters + const matches = urlTemplate.matchAll(/\{(\+?)([a-zA-Z0-9_$-]+)\}/g); for (const match of matches) { - const expression = match[1]; - const wildcard: '*' | '**' = expression.startsWith('+') ? '**' : '*'; - const firstChar = expression.charAt(0); - const rawExpr = ['+', '#', '.', '/', ';', '?', '&'].includes(firstChar) - ? expression.slice(1) - : expression; - const vars = rawExpr.split(','); - for (const v of vars) { - const paramName = v.replace(/^([^:*]+).*/, '$1').trim(); - if (paramName) { - if (!paramMap.has(paramName) || wildcard === '**') { - paramMap.set(paramName, wildcard); - } - } + const wildcard = match[1] === '+' ? '**' : '*'; + const paramName = match[2]; + + if (wildcard === '**' || !paramMap.has(paramName)) { + paramMap.set(paramName, wildcard); } }