Skip to content
Merged
79 changes: 70 additions & 9 deletions core/packages/gax/src/transcoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,30 @@ export function deleteField(request: JSONObject, field: string): void {
delete request[part];
}

// Validates a single path segment matched by a single wildcard (*).
// Checks that the segment is not exactly '.' or '..' (directory traversal indicators).
function validateUriPathSegment(propertyName: string, value: string): void {
if (value === '.' || value === '..') {
throw new Error(`Invalid value ${value} for ${propertyName}`);
}
}

// Validates a multi-segment path matched by a double wildcard (**).
// Splitting by slash, it checks that no individual segment is exactly '.' or '..'.
// This segment-by-segment check prevents directory traversal while allowing
// legitimate resource names containing dots (e.g., domain-scoped project IDs).
function validateUriPath(propertyName: string, value: string): void {
if (value) {
// Split by slash and check for exact segment matches of '.' or '..' rather
// than using a simple string.includes('.') check. This avoids rejecting
// valid domain-scoped resource segments (e.g. projects/example.com:project-id).
const segments = value.split('/');
if (segments.some(segment => segment === '.' || segment === '..')) {
throw new Error(`Value for ${propertyName} must not contain segments that are exactly . or ..`);
}
}
}

export function buildQueryStringComponents(
request: JSONObject,
prefix = '',
Expand Down Expand Up @@ -148,18 +172,35 @@ export function buildQueryStringComponents(
return resultList;
}

/**
* Percent-encodes a string according to RFC 3986, preserving only unreserved
* characters (alpha-numeric, '-', '_', '.', and '~'). All other characters,
* including slashes ('/'), are percent-encoded.
*
* This is necessary because encodeURIComponent natively encodes URL-unsafe
* characters like ?, #, $, &, +, etc., but preserves !, ', (, ), and *.
* To ensure strict compliance, we manually encode those preserved characters.
*
* @param {string} str - The input string to encode.
* @returns {string} The percent-encoded string.
*/
export function encodeWithSlashes(str: string): string {
return str
.split('')
.map(c => (c.match(/[-_.~0-9a-zA-Z]/) ? c : encodeURIComponent(c)))
.join('');
return encodeURIComponent(str).replace(
/[!'()*]/g, // Characters preserved by encodeURIComponent
character => '%' + character.charCodeAt(0).toString(16).toUpperCase()
);
}

/**
* Percent-encodes a string according to RFC 3986, preserving unreserved
* characters (alpha-numeric, '-', '_', '.', and '~') and slashes ('/'). All other
* characters are percent-encoded.
*
* @param {string} str - The input string to encode.
* @returns {string} The percent-encoded string with slashes preserved.
*/
export function encodeWithoutSlashes(str: string): string {
return str
.split('')
.map(c => (c.match(/[-_.~0-9a-zA-Z/]/) ? c : encodeURIComponent(c)))
.join('');
return str.split('/').map(encodeWithSlashes).join('/');
}

function escapeRegExp(str: string) {
Expand All @@ -169,8 +210,10 @@ function escapeRegExp(str: string) {
export 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);
}

Expand All @@ -187,10 +230,27 @@ export function applyPattern(
'$',
);

if (!fieldValue.match(regex)) {
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
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);
}

Expand Down Expand Up @@ -225,6 +285,7 @@ export function match(
const appliedPattern = applyPattern(
pattern,
fieldValue === null ? 'null' : fieldValue!.toString(),
camelCasedField,
);
if (appliedPattern === undefined) {
return undefined;
Expand Down
58 changes: 58 additions & 0 deletions core/packages/gax/test/unit/transcoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,24 @@ describe('gRPC to HTTP transcoding', () => {
);
});

it('should correctly handle Unicode surrogate pairs in encodeWithSlashes', () => {
// Emojis (like 😊) are surrogate pairs.
// They should be encoded successfully instead of throwing a URIError.
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_.~';
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');
});

it('encodeWithoutSlashes', () => {
assert.strictEqual(encodeWithoutSlashes('abcd'), 'abcd');
assert.strictEqual(
Expand All @@ -384,6 +402,12 @@ describe('gRPC to HTTP transcoding', () => {
);
});

it('should correctly handle Unicode surrogate pairs in encodeWithoutSlashes', () => {
// Emojis (like 😊) are surrogate pairs.
// They should be encoded successfully instead of throwing a URIError.
assert.strictEqual(encodeWithoutSlashes('😊'), '%F0%9F%98%8A');
});

it('applyPattern', () => {
assert.strictEqual(applyPattern('*', 'test'), 'test');
assert.strictEqual(applyPattern('test', 'test'), 'test');
Expand Down Expand Up @@ -411,6 +435,40 @@ describe('gRPC to HTTP transcoding', () => {
);
});

it('applyPattern should throw an error for double-asterisk segment traversal containing segments that are exactly ".."', () => {
assert.throws(() => {
applyPattern(
'projects/*/locations/*/agents/*/sessions/**',
'projects/p/locations/l/agents/a/sessions/agents/../subagent',
'session'
);
}, /Value for session must not contain segments that are exactly \. or \.\./);
});

it('applyPattern should throw an error for double-asterisk segment traversal containing segments that are exactly "."', () => {
assert.throws(() => {
applyPattern(
'projects/*/locations/*/agents/*/sessions/**',
'projects/p/locations/l/agents/a/sessions/agents/./subagent',
'session'
);
}, /Value for session must not contain segments that are exactly \. or \.\./);
});

it('applyPattern should percent-encode query injection attempt on double-asterisk without throwing traversal error', () => {
const res = applyPattern(
'projects/*/locations/*/agents/*/sessions/**',
'projects/p/locations/l/agents/a/sessions/..?$foo=BAR#',
'session'
);
assert.strictEqual(res, 'projects/p/locations/l/agents/a/sessions/..%3F%24foo%3DBAR%23');
});

it('applyPattern should handle optional unmatched groups gracefully without throwing TypeErrors', () => {
const res = applyPattern('projects/*', 'projects/p', 'session');
assert.strictEqual(res, 'projects/p');
});

it('flattenObject', () => {
assert.deepStrictEqual(flattenObject({}), {});
assert.deepStrictEqual(flattenObject({field: 'value'}), {field: 'value'});
Expand Down
132 changes: 132 additions & 0 deletions core/packages/gax/test/unit/transcoding_validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// 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
//
// https://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';
const { v3 } = require('../../../../../../packages/google-cloud-dialogflow-cx');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems brittle to me; is there a place that has visibility to both packages? If not, we might want to introduce one. Can you file a ticket to track this (moving this to another directory where the dependencies can be a bit more natural -- maybe reuse your 'de-skip' issue)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


const sinon = require('sinon');

describe('Dialogflow CX Fallback Transcoding and Path Traversal Prevention', () => {
let client: any;
let fetchStub: any;

beforeEach(() => {
client = new v3.SessionsClient({
fallback: true,
credentials: { client_email: 'bogus@example.com', private_key: 'bogus' },
projectId: 'bogus',
});
fetchStub = sinon.stub().resolves({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(Buffer.from('{}')),
});
client.auth.fetch = fetchStub;
});

// Test 1: Single Asterisk Dot Validation on client call
it.skip('1. should throw an error for single-asterisk segment traversal using exactly "." as session ID', async () => {
// TODO: Re-enable this test when the gax version with the new encoding is released.
await client.initialize();
await assert.rejects(
client.detectIntent({
session: 'projects/p/locations/l/agents/a/sessions/.',
queryInput: { text: { text: 'hello' }, languageCode: 'en' },
}),
/Invalid value \. for session/
);
});

// Test 2: Single Asterisk Dot-Dot Validation on client call
it.skip('2. should throw an error for single-asterisk segment traversal using exactly ".." as session ID', async () => {
// TODO: Re-enable this test when the gax version with the new encoding is released.
await client.initialize();
await assert.rejects(
client.detectIntent({
session: 'projects/p/locations/l/agents/a/sessions/..',
queryInput: { text: { text: 'hello' }, languageCode: 'en' },
}),
/Invalid value \.\. for session/
);
});



// Test 5: Standard Valid Path fallback REST call
it('5. should pass transcoding validation with a valid session path and construct the correct REST URL', async () => {
await client.initialize();
await client.detectIntent({
session: 'projects/p/locations/l/agents/a/sessions/valid-session-id',
queryInput: { text: { text: 'hello' }, languageCode: 'en' },
});
assert.strictEqual(fetchStub.callCount, 1);
const requestUrl = fetchStub.firstCall.args[0];
assert.ok(requestUrl.includes('/v3/projects/p/locations/l/agents/a/sessions/valid-session-id:detectIntent'));
});

// Test 6: Query Parameter Injection Prevention via percent-encoding
it('6. should protect against query parameter injection by percent-encoding "?" and "$" in the session ID', async () => {
await client.initialize();
await client.detectIntent({
session: 'projects/p/locations/l/agents/a/sessions/my-session?$foo=BAR#',
queryInput: { text: { text: 'hello' }, languageCode: 'en' },
});
assert.strictEqual(fetchStub.callCount, 1);
const requestUrl = fetchStub.firstCall.args[0];
// "?" -> %3F, "$" -> %24, "=" -> %3D, "#" -> %23
assert.ok(requestUrl.includes('my-session%3F%24foo%3DBAR%23'));
});

// Test 7: Combined Path Traversal and Query Parameter Injection
it('7. should protect against path traversal and query injection by percent-encoding combined patterns', async () => {
// This request is permitted because the template uses * instead of **
// * is supposed to match against exactly . or ..
// This is okay because we still percent encode the ? parameter.
// example: POST https://<location>-dialogflow.googleapis.com/v3/{session=projects/*/locations/*/agents/*/sessions/*}:detectIntent
await client.initialize();
await client.detectIntent({
session: 'projects/p/locations/l/agents/a/sessions/..?$foo=BAR#',
queryInput: { text: { text: 'hello' }, languageCode: 'en' },
});
assert.strictEqual(fetchStub.callCount, 1);
const requestUrl = fetchStub.firstCall.args[0];
assert.ok(requestUrl.includes('/v3/projects/p/locations/l/agents/a/sessions/..%3F%24foo%3DBAR%23:detectIntent'));
});

// Test 8: Combined Path Traversal (.) and Query Parameter Injection
it('8. should protect against path traversal and query injection by percent-encoding combined patterns using dot', async () => {
await client.initialize();
await client.detectIntent({
session: 'projects/p/locations/l/agents/a/sessions/.?$foo=BAR#',
queryInput: { text: { text: 'hello' }, languageCode: 'en' },
});
assert.strictEqual(fetchStub.callCount, 1);
const requestUrl = fetchStub.firstCall.args[0];
assert.ok(requestUrl.includes('/v3/projects/p/locations/l/agents/a/sessions/.%3F%24foo%3DBAR%23:detectIntent'));
});

// Test 9: Percent-encoding all other characters
it.skip('9. should percent-encode all other characters except unreserved ones', async () => {
// TODO: Re-enable this test when the gax version with the new encoding is released.
await client.initialize();
await client.detectIntent({
session: 'projects/p/locations/l/agents/a/sessions/ !@$&\'()*+,;=:%',
queryInput: { text: { text: 'hello' }, languageCode: 'en' },
});
assert.strictEqual(fetchStub.callCount, 1);
const requestUrl = fetchStub.firstCall.args[0];
assert.ok(requestUrl.includes('/v3/projects/p/locations/l/agents/a/sessions/%20%21%40%24%26%27%28%29%2A%2B%2C%3B%3D%3A%25:detectIntent'));
});
});
Loading