Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
f2031d9
Do . and .. check and percent encode
danieljbruce Aug 18, 2026
2cef1c5
clean the tsconfig file
danieljbruce Aug 18, 2026
133114f
Add a comment about aliases
danieljbruce Aug 18, 2026
2e93597
Add some comments to the encoding
danieljbruce Aug 18, 2026
0f2977c
Eliminate the url tostring change
danieljbruce Aug 18, 2026
cf8edea
Remove the transcoding import
danieljbruce Aug 18, 2026
d6ce21f
Separate the two methods
danieljbruce Aug 18, 2026
0be0c31
Get rid of the backwards compatibility helper
danieljbruce Aug 18, 2026
a324113
Merge branch 'main' of https://github.com/googleapis/google-cloud-nod…
danieljbruce Aug 18, 2026
05f3b8e
Adopt changes from other vulnerability PR
danieljbruce Aug 18, 2026
7c622cd
Add dialogflow tests
danieljbruce Aug 18, 2026
ff9fdaa
Introduce proper method names
danieljbruce Aug 19, 2026
39b911d
Update the dialogflow tests
danieljbruce Aug 19, 2026
899a822
Reduce test size for demonstration purposes
danieljbruce Aug 19, 2026
ff7bc4f
move comment to bottom
danieljbruce Aug 19, 2026
ddb0991
Simplify validateAndEncode
danieljbruce Aug 19, 2026
0ac0aa5
Undo unnecessary changes
danieljbruce Aug 19, 2026
bc35c8f
Consolidate the code into multi and single path
danieljbruce Aug 19, 2026
8f11100
inline urlTemplateString
danieljbruce Aug 19, 2026
4a31513
reduce api surface for code change
danieljbruce Aug 19, 2026
6c76cd3
Eliminate the while loop
danieljbruce Aug 19, 2026
567330d
Do the scan by wildcards instead
danieljbruce Aug 19, 2026
c60f22d
Merge branch 'main' of https://github.com/googleapis/google-cloud-nod…
danieljbruce Aug 19, 2026
7d6bfb2
Add the code snippet verbatim
danieljbruce Aug 19, 2026
c206f2d
Add comments about refactor
danieljbruce Aug 19, 2026
188385e
consolidate all the code into applyPattern
danieljbruce Aug 19, 2026
8ce8ad1
Add JS documentation
danieljbruce Aug 19, 2026
9f98c3a
Simplify use of apply pattern
danieljbruce Aug 19, 2026
b0ea0dd
Add an input/output example
danieljbruce Aug 19, 2026
5e6f379
Change the variable name to parameterValue
danieljbruce Aug 20, 2026
91860e5
Add a single wildcard test
danieljbruce Aug 20, 2026
9b02fe8
chore: remove normalizePathParams from request pipeline
danieljbruce Aug 20, 2026
b564f35
refactor with comments so the * and ** distinction is clear
danieljbruce Aug 20, 2026
4345676
Remove the extra line
danieljbruce Aug 20, 2026
e30270d
Merge branch 'main' into apiary-path-traversal-vulnerability
danieljbruce Aug 20, 2026
5d64cb9
simplify expression
danieljbruce Aug 21, 2026
2d5e51f
Eliminate applyPattern
danieljbruce Aug 21, 2026
45be523
Add a comment explaining the rationale of the code
danieljbruce Aug 21, 2026
a96ae8e
Replace the templating function
danieljbruce Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/packages/nodejs-googleapis-common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions core/packages/nodejs-googleapis-common/src/apirequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
// limitations under the License.

import {Gaxios} from 'gaxios';
import {GoogleAuth} from 'google-auth-library';

Check failure on line 15 in core/packages/nodejs-googleapis-common/src/apirequest.ts

View workflow job for this annotation

GitHub Actions / lint

Cannot find module 'google-auth-library' or its corresponding type declarations.
import * as qs from 'qs';

Check failure on line 16 in core/packages/nodejs-googleapis-common/src/apirequest.ts

View workflow job for this annotation

GitHub Actions / lint

Cannot find module 'qs' or its corresponding type declarations.
import * as stream from 'stream';

Check failure on line 17 in core/packages/nodejs-googleapis-common/src/apirequest.ts

View workflow job for this annotation

GitHub Actions / lint

Cannot find name 'stream'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
import * as urlTemplate from 'url-template';

Check failure on line 18 in core/packages/nodejs-googleapis-common/src/apirequest.ts

View workflow job for this annotation

GitHub Actions / lint

Cannot find module 'url-template' or its corresponding type declarations.
import * as extend from 'extend';

Check failure on line 19 in core/packages/nodejs-googleapis-common/src/apirequest.ts

View workflow job for this annotation

GitHub Actions / lint

Could not find a declaration file for module 'extend'. '/home/runner/work/google-cloud-node/google-cloud-node/node_modules/extend/index.js' implicitly has an 'any' type.

import {APIRequestParams, BodyResponseCallback} from './api';
import {isBrowser} from './isbrowser';
Expand All @@ -24,9 +24,10 @@
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');

Check failure on line 30 in core/packages/nodejs-googleapis-common/src/apirequest.ts

View workflow job for this annotation

GitHub Actions / lint

Cannot find name 'require'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.

const randomUUID = () =>
globalThis.crypto?.randomUUID() || require('crypto').randomUUID();
Expand Down Expand Up @@ -164,6 +165,13 @@
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;
Expand Down
181 changes: 181 additions & 0 deletions core/packages/nodejs-googleapis-common/src/transcoding.ts
Original file line number Diff line number Diff line change
@@ -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<{

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.

I think this covers some advanced templating that I don't think we use in Apiary.

Consider:

function extractTemplateParams(urlTemplate: string): Array<{
  param: string;
  wildcard: '*' | '**';
}> {
  const paramMap = new Map<string, '*' | '**'>();
  
  // 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,
  }));
}

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.

Sounds good. I've applied this code to simplify the method.

param: string;
wildcard: '*' | '**';
}> {
const paramMap = new Map<string, '*' | '**'>();

// 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<string, any>,
): 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));
}
}
}
}
151 changes: 151 additions & 0 deletions core/packages/nodejs-googleapis-common/test/test.apirequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading
Loading