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/src/apirequest.ts b/core/packages/nodejs-googleapis-common/src/apirequest.ts index 37811edf7b14..d012333b4255 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 {validateAndEncodeParams} from './transcoding'; // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('../../package.json'); @@ -164,6 +165,13 @@ async function createAPIRequestAsync( throw new Error('Missing required parameters: ' + missingParams.join(', ')); } + // 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?.toString() ?? parameters.mediaUrl ?? undefined, + params, + ); + // Parse urls if (options.url) { let url = options.url; 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..a3e5d82ec210 --- /dev/null +++ b/core/packages/nodejs-googleapis-common/src/transcoding.ts @@ -0,0 +1,181 @@ +// 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 + */ +function validateUriPathSegment( + propertyName: string, + value: string, +): void { + if (value === '.' || value === '..') { + throw new Error(`Invalid value ${value} for ${propertyName}`); + } +} + +/** + * Validates a multi-segment path matched by a double wildcard (**) 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 + */ +function validateUriPath( + 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 ..`, + ); + } + } +} + +/** + * 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 + */ +function encodeWithSlashes(str: string): string { + return encodeURIComponent(str).replace( + /[!'()*]/g, // Characters preserved by encodeURIComponent + character => '%' + character.charCodeAt(0).toString(16).toUpperCase(), + ); +} + +/** + * Percent-encodes a string according to RFC 3986, preserving unreserved + * characters (alpha-numeric, '-', '_', '.', and '~') and slashes ('/'). All other + * characters are percent-encoded. + * + * @param str The input string to encode + * @returns The percent-encoded string with slashes preserved + */ +function encodeWithoutSlashes(str: string): string { + return str.split('/').map(encodeWithSlashes).join('/'); +} + +/** + * 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; + wildcard: '*' | '**'; +}> { + const paramMap = new Map(); + + // Natively skips {}, {#}, {?}, and {,} by demanding valid variable characters + const matches = urlTemplate.matchAll(/\{(\+?)([a-zA-Z0-9_$-]+)\}/g); + + for (const match of matches) { + const wildcard = match[1] === '+' ? '**' : '*'; + const paramName = match[2]; + + if (wildcard === '**' || !paramMap.has(paramName)) { + paramMap.set(paramName, wildcard); + } + } + + return Array.from(paramMap.entries()).map(([param, wildcard]) => ({ + param, + wildcard, + })); +} + +/** + * Validates path parameters against traversal attacks ('.' and '..') and encodes + * multi-segment parameters in params so that reserved characters (query params, fragments, etc.) + * cannot be injected into the path. Modifies params in-place. + * + * @param urlTemplate URL template associated with the request (e.g. url, mediaUrl) + * @param params Request parameters dictionary (modified in-place) + */ +export function validateAndEncodeParams( + 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, or if urlTemplate is missing + if (!params || typeof params !== 'object' || !urlTemplate) { + return; + } + + // Identify the parameters and wildcards in the URL template + const templateParams = extractTemplateParams(urlTemplate); + + for (const {param, wildcard} of templateParams) { + const parameterValue = params[param]; + if (parameterValue === undefined || parameterValue === null) { + 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); + }; + params[param] = Array.isArray(parameterValue) + ? 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 + // 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)); + } + } + } +} diff --git a/core/packages/nodejs-googleapis-common/test/test.apirequest.ts b/core/packages/nodejs-googleapis-common/test/test.apirequest.ts index 8e2e3b153cde..9976fe2de0c2 100644 --- a/core/packages/nodejs-googleapis-common/test/test.apirequest.ts +++ b/core/packages/nodejs-googleapis-common/test/test.apirequest.ts @@ -763,4 +763,155 @@ 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%24foo%3DBAR%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?$foo=BAR#', + }, + requiredParams: [], + pathParams: ['session'], + context: fakeContext, + }); + + assert.ok(res.config.url?.toString().endsWith(p)); + scope.done(); + }); + + 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'; + 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(); + }); + + 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(); + }); + }); }); 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..079ba5ee3132 --- /dev/null +++ b/core/packages/nodejs-googleapis-common/test/test.dialogflow.ts @@ -0,0 +1,102 @@ +// 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 * 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(); + +describe('Dialogflow Apiary Client User Simulation', () => { + afterEach(() => { + nock.cleanAll(); + }); + + 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({}); + + 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 + // ========================================================================================= + // 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 \.\./ + // ========================================================================================= + }); + + it('detectIntent: prevents query injection when user supplies a session containing "?" and "$"', async () => { + const dialogflow = new dialogflow_v3.Dialogflow({}); + + const injectionSession = + 'projects/p/locations/l/agents/a/sessions/session-(1)*?$foo=BAR&admin=1#frag'; + + const expectedPath = + '/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, {}) + .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(); + }); +});