Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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,660 changes: 1,270 additions & 390 deletions src/apis/dialogflow/v3.ts

Large diffs are not rendered by default.

898 changes: 657 additions & 241 deletions src/apis/drive/v2.ts

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions src/generator/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,21 @@
return !!method.parameters && !!method.parameters['resource'];
}

export function isReservedParam(
p: {reserved?: boolean},
pname: string,
mpath?: string

Check failure on line 124 in src/generator/filters.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`

Check failure on line 124 in src/generator/filters.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`
): boolean {
if (p && p.reserved) {
return true;
}
if (!mpath) {
return false;
}
const regex = new RegExp(`\\{\\+${pname}(?:[\\}:/=]|$)`);
return regex.test(mpath);
}

const RESERVED_PARAMS = ['resource', 'media', 'auth'];

/**
Expand Down
1 change: 1 addition & 0 deletions src/generator/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class Generator {
this.env.addFilter('getPathParams', filters.getPathParams);
this.env.addFilter('getSafeParamName', filters.getSafeParamName);
this.env.addFilter('hasResourceParam', filters.hasResourceParam);
this.env.addFilter('isReservedParam', filters.isReservedParam);
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/generator/templates/api-endpoint.njk
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ import {
APIRequestContext,
} from 'googleapis-common';
import {Readable} from 'stream';
import {
validateSingleSegment,
validateMultiSegment,
encodeWithSlashes,
encodeWithoutSlashes,
} from '../../transcoding';

export namespace {{api.name}}_{{api.version|replace('\.', '_')}} {

Expand Down
22 changes: 20 additions & 2 deletions src/generator/templates/method-partial.njk
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,33 @@
options = {};
}

{% if m.parameters %}
{% for pname, p in m.parameters|dictsort %}
{% if p.location == 'path' %}
{% set safePName = pname|getSafeParamName %}
{% set isReserved = p|isReservedParam(pname, m.path) %}
if (params.{{ safePName }} !== undefined && params.{{ safePName }} !== null) {
{% if isReserved %}
validateMultiSegment('{{ pname }}', String(params.{{ safePName }}));
params.{{ safePName }} = encodeWithoutSlashes(String(params.{{ safePName }}));
{% else %}
validateSingleSegment('{{ pname }}', String(params.{{ safePName }}));
params.{{ safePName }} = encodeWithSlashes(String(params.{{ safePName }}));
{% endif %}
}
{% endif %}
{% endfor %}
{% endif %}

const rootUrl = options.rootUrl || {{ rootApi.rootUrl|buildurl|safe }};
const parameters = {
options: Object.assign({
url: (rootUrl + {{ ('/' + rootApi.servicePath + m.path)|buildurl|safe }}).replace(/([^:]\/)\/+/g, '$1'),
url: (rootUrl + {{ ('/' + rootApi.servicePath + m.path)|buildurl|safe }}).replace(/([^:]\/)\/+/g, '$1').replace(/\{([^+:][^}]*)\}/g, '{+$1}'),
method: '{{ m.httpMethod }}',
apiVersion: '{{ m.apiVersion }}'
}, options),
params,
{% if m.mediaUpload.protocols.simple.path %}mediaUrl: (rootUrl + {{ ('/' + m.mediaUpload.protocols.simple.path)|buildurl|safe }}).replace(/([^:]\/)\/+/g, '$1'),{% endif %}
{% if m.mediaUpload.protocols.simple.path %}mediaUrl: (rootUrl + {{ ('/' + m.mediaUpload.protocols.simple.path)|buildurl|safe }}).replace(/([^:]\/)\/+/g, '$1').replace(/\{([^+:][^}]*)\}/g, '{+$1}'),{% endif %}
requiredParams: [{% if m.parameterOrder.length %}'{{ m.parameterOrder|join("', '")|safe }}'{% endif %}],
pathParams: [{% if pathParams.length %}'{{ pathParams|join("', '")|safe }}'{% endif %}],
context: this.context,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {GoogleApis} from './googleapis';
const google = new GoogleApis();
export {google, GoogleApis};
export * as Common from 'googleapis-common';
export * from './transcoding';
export * as Auth from 'google-auth-library';

export {abusiveexperiencereport_v1} from './apis/abusiveexperiencereport/v1';
Expand Down
65 changes: 65 additions & 0 deletions src/transcoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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).
export function validateSingleSegment(
propertyName: string,
value: string

Check failure on line 19 in src/transcoding.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`

Check failure on line 19 in src/transcoding.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`
): 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).
export function validateMultiSegment(
propertyName: string,
value: string

Check failure on line 32 in src/transcoding.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`

Check failure on line 32 in src/transcoding.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`
): 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 ..`

Check failure on line 38 in src/transcoding.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`

Check failure on line 38 in src/transcoding.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`
);
}
}
}

// Strictly percent-encodes a character to comply with 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.
export function strictEncodeURIComponent(str: string): string {
return encodeURIComponent(str).replace(
/[!'()*]/g,
character => '%' + character.charCodeAt(0).toString(16).toUpperCase()

Check failure on line 51 in src/transcoding.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`

Check failure on line 51 in src/transcoding.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`
);
}

export function encodeWithSlashes(str: string): string {
return [...str]
.map(c => (c.match(/[-_.~0-9a-zA-Z]/) ? c : strictEncodeURIComponent(c)))
.join('');
}

export function encodeWithoutSlashes(str: string): string {
return [...str]
.map(c => (c.match(/[-_.~0-9a-zA-Z/]/) ? c : strictEncodeURIComponent(c)))
.join('');
}
81 changes: 81 additions & 0 deletions test/test.path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,87 @@
);
});

it('should throw an error for single-segment path traversal containing "." or ".."', () => {
assert.throws(() => {
localDrive.files.get({fileId: '.'}, Utils.noop);
}, /Invalid value \. for fileId/);

assert.throws(() => {
localDrive.files.get({fileId: '..'}, Utils.noop);
}, /Invalid value \.\. for fileId/);
});

it('should throw an error for multi-segment path traversal containing "." or ".." segments', () => {
const google = new GoogleApis();
const dialogflow = google.dialogflow('v3');

assert.throws(() => {
dialogflow.projects.locations.agents.sessions.detectIntent(
{
session:
'projects/p/locations/l/agents/a/sessions/agents/../subagent',
},
Utils.noop

Check failure on line 255 in test/test.path.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`

Check failure on line 255 in test/test.path.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`
);
}, /Value for session must not contain segments that are exactly \. or \.\./);

assert.throws(() => {
dialogflow.projects.locations.agents.sessions.detectIntent(
{
session:

Check failure on line 262 in test/test.path.ts

View workflow job for this annotation

GitHub Actions / lint

Delete `⏎···········`

Check failure on line 262 in test/test.path.ts

View workflow job for this annotation

GitHub Actions / lint

Delete `⏎···········`
'projects/p/locations/l/agents/a/sessions/agents/./subagent',
},
Utils.noop

Check failure on line 265 in test/test.path.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`

Check failure on line 265 in test/test.path.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`
);
}, /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', done => {
const google = new GoogleApis();
const dialogflow = google.dialogflow('v3');
const p =
'/v3/projects/p/locations/l/agents/a/sessions/my-session%3F%24httpMethod%3DDELETE%23:detectIntent';

nock('https://dialogflow.googleapis.com').post(p).reply(200, {});

dialogflow.projects.locations.agents.sessions.detectIntent(
{
session:
'projects/p/locations/l/agents/a/sessions/my-session?$httpMethod=DELETE#',
},
(err: Error | null, res?: GaxiosResponseWithHTTP2 | null) => {
if (err) {
return done(err);
}
assert.ok(res?.config.url?.toString().endsWith(p));
done();
}

Check failure on line 289 in test/test.path.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`

Check failure on line 289 in test/test.path.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`
);
});

it('should strictly percent-encode reserved characters while preserving unreserved characters and slashes in reserved parameters', done => {
const google = new GoogleApis();
const dialogflow = google.dialogflow('v3');
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';

nock('https://dialogflow.googleapis.com').post(p).reply(200, {});

dialogflow.projects.locations.agents.sessions.detectIntent(
{
session:

Check failure on line 303 in test/test.path.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `⏎··········'projects/p/locations/l/agents/a/sessions/·!@$&\'()*+,;=:%'` with `·"projects/p/locations/l/agents/a/sessions/·!@$&'()*+,;=:%"`

Check failure on line 303 in test/test.path.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `⏎··········'projects/p/locations/l/agents/a/sessions/·!@$&\'()*+,;=:%'` with `·"projects/p/locations/l/agents/a/sessions/·!@$&'()*+,;=:%"`
'projects/p/locations/l/agents/a/sessions/ !@$&\'()*+,;=:%',
},
(err: Error | null, res?: GaxiosResponseWithHTTP2 | null) => {
if (err) {
return done(err);
}
assert.ok(res?.config.url?.toString().endsWith(p));
done();
}
);
});

after(() => {
nock.cleanAll();
});
Expand Down
Loading