Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b9dc2a4
fix(common): encode REST URI path variables and prevent path traversal
google-labs-jules[bot] Aug 14, 2026
6f73654
fix(common): encode REST URI path variables and prevent path traversal
google-labs-jules[bot] Aug 14, 2026
3686a0d
Merge branch 'vulnerability-bigquery-rest-traversal' of https://githu…
danieljbruce Aug 20, 2026
100757a
Eliminate dependency on a class
danieljbruce Aug 20, 2026
4ae9487
Move encodeAbsoluteURI into one method
danieljbruce Aug 20, 2026
ab44abd
Add a comment to encodeURIPath to explain what its doing
danieljbruce Aug 20, 2026
1ff5899
Add JS doc comment for encodeAbsoluteURI
danieljbruce Aug 20, 2026
06f2424
Append the inline comments
danieljbruce Aug 20, 2026
a05fd58
Explain significance of URL object
danieljbruce Aug 20, 2026
83f4a6b
Add comments to the else blocks
danieljbruce Aug 20, 2026
a92100b
Add comments here about percent encoding
danieljbruce Aug 20, 2026
b51c0b3
Move joinURIComponents into a separate method
danieljbruce Aug 21, 2026
a451317
Use the gax based utility methods verbatim
danieljbruce Aug 21, 2026
af8a85d
Merge branch 'main' into vulnerability-bigquery-rest-traversal
danieljbruce Aug 21, 2026
d083db0
Move the security tests to service-object
danieljbruce Aug 21, 2026
812fc63
generate tests for Bigquery in the common library
danieljbruce Aug 21, 2026
ce24d58
Merge branch 'vulnerability-bigquery-rest-traversal' of https://githu…
danieljbruce Aug 21, 2026
f93b18e
Add the system tests for bigquery.
danieljbruce Aug 21, 2026
647558d
Introduce the processSegment method
danieljbruce Aug 21, 2026
a501715
Move the tests to bigquery where they belong and mock out proxyquire
danieljbruce Aug 21, 2026
766df9b
Move the tests to traversal.ts
danieljbruce Aug 21, 2026
873359f
removed these tests. They are not needed anymore.
danieljbruce Aug 21, 2026
94f4a1d
Add a TODO for after common is released
danieljbruce Aug 21, 2026
8ac4dfc
Make encodings explicit
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
21 changes: 12 additions & 9 deletions core/common/src/service-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
BodyResponseCallback,
DecorateRequestOptions,
ResponseBody,
encodeAbsoluteURI,
joinURIComponents,
util,
} from './util';

Expand Down Expand Up @@ -563,17 +565,18 @@ class ServiceObject<T = any> extends EventEmitter {
const uriComponents = [this.baseUrl, this.id || '', reqOpts.uri];

if (isAbsoluteUrl) {
uriComponents.splice(0, uriComponents.indexOf(reqOpts.uri));
// Encode only the pathname to preserve protocol, host, and query params.
// We cannot pass uriComponents through encodeURIPath after splicing
// because it will percent-encode parts we do not want to encode.
reqOpts.uri = encodeAbsoluteURI(reqOpts.uri);
} else {
// Relative path components contain only path segments (no protocol or host),
// so we encode each segment directly and join them with '/'.
reqOpts.uri = joinURIComponents(
uriComponents.filter(x => x!.trim()) as string[],
);
}

reqOpts.uri = uriComponents
.filter(x => x!.trim()) // Limit to non-empty strings.
.map(uriComponent => {
const trimSlashesRegex = /^\/*|\/*$/g;
return uriComponent!.replace(trimSlashesRegex, '');
})
.join('/');

const childInterceptors = (arrify as unknown as (arg1: any) => [])(
reqOpts.interceptors_!,
);
Expand Down
26 changes: 14 additions & 12 deletions core/common/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
DecorateRequestOptions,
MakeAuthenticatedRequest,
PackageJson,
encodeAbsoluteURI,
joinURIComponents,
util,
} from './util';

Expand Down Expand Up @@ -212,20 +214,20 @@ export class Service {
uriComponents.push(reqOpts.uri);

if (isAbsoluteUrl) {
uriComponents.splice(0, uriComponents.indexOf(reqOpts.uri));
// Encode only the pathname to preserve protocol, host, and query params.
// We cannot pass uriComponents through encodeURIPath after splicing
// because it will percent-encode parts we do not want to encode.
reqOpts.uri = encodeAbsoluteURI(reqOpts.uri);
} else {
// Relative path components contain only path segments (no protocol or host),
// so we encode each segment directly and join them with '/'.
reqOpts.uri = joinURIComponents(uriComponents)
// Some URIs have colon separators.
// Bad: https://.../projects/:list
// Good: https://.../projects:list
.replace(/\/:/g, ':');
}

reqOpts.uri = uriComponents
.map(uriComponent => {
const trimSlashesRegex = /^\/*|\/*$/g;
return uriComponent.replace(trimSlashesRegex, '');
})
.join('/')
// Some URIs have colon separators.
// Bad: https://.../projects/:list
// Good: https://.../projects:list
.replace(/\/:/g, ':');

const requestInterceptors = this.getRequestInterceptors();

(arrify as unknown as (arg1: any) => any[])(reqOpts.interceptors_!).forEach(
Expand Down
126 changes: 126 additions & 0 deletions core/common/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,5 +1024,131 @@ class ProgressStream extends Transform {
}
}

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

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.

security-high high

validateUriPathSegment does not decode the input segment before checking if it is . or ... If a user passes a percent-encoded path traversal sequence (e.g., %2e%2e), this check will be bypassed. To prevent path traversal bypasses, the segment should be decoded using decodeURIComponent before validation.

export function validateUriPathSegment(propertyName: string, value: string): void {
  try {
    const decoded = decodeURIComponent(value);
    if (decoded === '.' || decoded === '..') {
      throw new Error(`Invalid value ${value} for ${propertyName}`);
    }
  } catch {
    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).
export 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 ..`,
);
}
}
}
Comment on lines +1039 to +1051

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.

security-high high

validateUriPath does not decode the path segments before checking for . or ... A user could bypass this check by using percent-encoded characters (e.g., %2e%2e). To ensure robust protection against path traversal, each segment should be decoded using decodeURIComponent before checking.

export function validateUriPath(propertyName: string, value: string): void {
  if (value) {
    const segments = value.split('/');
    const hasTraversal = segments.some(segment => {
      try {
        const decoded = decodeURIComponent(segment);
        return decoded === '.' || decoded === '..';
      } catch {
        return segment === '.' || segment === '..';
      }
    });
    if (hasTraversal) {
      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 {string} str - The input string to encode.
* @returns {string} The percent-encoded string.
*/
export 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 {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(encodeWithSlashes).join('/');
}

/**
* Encodes each path segment in a URI string while preserving slash (`/`) and
* colon (`:`) delimiters, and validates that no path segment is `.` or `..` to
* prevent path traversal.
*
* @param {string} uri - The URI path to encode.
* @return {string} The encoded URI path.
*/
export function encodeURIPath(uri: string): string {
const processSegment = (segment: string): string => {
if (segment === '') {
return '';
}
let decoded = segment;
try {
decoded = decodeURIComponent(segment);
} catch {
// Fallback to raw segment if decoding fails (e.g. malformed '%')
}
validateUriPathSegment('path segment', decoded);
return encodeWithSlashes(decoded);
};

const parts = uri.split('/');
return parts
.map(part => {
if (part.includes(':')) {
return part.split(':').map(processSegment).join(':');
}
return processSegment(part);
})
.join('/');
}
Comment on lines +1092 to +1116

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.

security-high high

encodeURIPath currently double-encodes any path segments that are already percent-encoded (e.g., %20 becomes %2520). This will break legitimate requests that contain pre-encoded characters (such as spaces or non-ASCII characters in table/dataset IDs). Additionally, if the input is already percent-encoded, the path traversal checks in validateUriPathSegment can be bypassed.

To fix both issues, each segment/subpart should be decoded using decodeURIComponent before validation and encoding.

export function encodeURIPath(uri: string): string {
  const parts = uri.split('/');
  return parts
    .map(part => {
      if (part === '') {
        return '';
      }
      if (part.includes(':')) {
        const subparts = part.split(':');
        return subparts
          .map(subpart => {
            if (subpart === '') {
              return '';
            }
            let decoded = subpart;
            try {
              decoded = decodeURIComponent(subpart);
            } catch {
              // Fallback to raw subpart if decoding fails
            }
            validateUriPathSegment('path segment', decoded);
            return encodeWithSlashes(decoded);
          })
          .join(':');
      }
      let decoded = part;
      try {
        decoded = decodeURIComponent(part);
      } catch {
        // Fallback to raw part if decoding fails
      }
      validateUriPathSegment('path segment', decoded);
      return encodeWithSlashes(decoded);
    })
    .join('/');
}


/**
* Encodes the pathname of an absolute URI string using `encodeURIPath`,
* preserving any query parameters, hash, or trailing slash formatting.
*
* @param {string} uri - The absolute URI string to encode.
* @return {string} The formatted and encoded absolute URI string.
*/
export function encodeAbsoluteURI(uri: string): string {
const url = new URL(uri); // Isolate pathname from protocol, host, and query.
const encodedPath = encodeURIPath(url.pathname);
url.pathname = encodedPath;
let res = url.toString();
if (!uri.endsWith('/') && res.endsWith('/')) {
res = res.slice(0, -1);
}
return res;
}

/**
* Trims slashes, encodes path segments to prevent path traversal, and joins
* URI components into a single relative path.
*
* @param {string[]} components - URI components to encode and join.
* @return {string} The formatted and joined URI path.
*/
export function joinURIComponents(components: string[]): string {
return components
.map(uriComponent => {
const trimSlashesRegex = /^\/*|\/*$/g;
const trimmed = uriComponent.replace(trimSlashesRegex, '');
return encodeURIPath(trimmed); // Encode and prevent path traversal.
})
.join('/');
}

const util = new Util();
export {util};
73 changes: 73 additions & 0 deletions core/common/test/service-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,26 @@ describe('ServiceObject', () => {
});
});

it('should throw error when id or uri contains path traversal segments', () => {
serviceObject.id = '..';
assert.throws(() => {
asInternal(serviceObject).request_(reqOpts, () => {});
}, /Invalid value \.\. for path segment/);
});

it('should percent-encode query parameter injection payloads in path components', done => {
const maliciousId = 'table_name?param=value#tag';
serviceObject.id = maliciousId;
serviceObject.parent.request = (reqOpts_, callback) => {
assert.strictEqual(
reqOpts_.uri,
`${serviceObject.baseUrl}/table_name%3Fparam%3Dvalue%23tag/${reqOpts.uri}`,
);
callback(null, null, {} as r.Response);
};
asInternal(serviceObject).request_(reqOpts, () => done());
});

it('should extend interceptors from child ServiceObjects', async () => {
const parent = new ServiceObject(CONFIG) as FakeServiceObject;
parent.interceptors.push({
Expand Down Expand Up @@ -1235,4 +1255,57 @@ describe('ServiceObject', () => {
serviceObject.requestStream(fakeOptions);
});
});

// Temporary test suite pulling in BigQuery to verify end-to-end path traversal
// protection and URI encoding with the local @google-cloud/common implementation.
// Note: We will delete these tests after the corresponding tests in BigQuery
// (handwritten/bigquery/test/dataset.ts) are unskipped upon the release of @google-cloud/common.
describe('BigQuery Dataset integration (security - URI encoding and path traversal protection)', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let BigQueryDataset: any;

before(() => {
BigQueryDataset = proxyquire(
'../../../../handwritten/bigquery/build/src/dataset',
{
'@google-cloud/common': {
ServiceObject,
util,
},
},
).Dataset;
});

it('should throw error when dataset id or path segment is dot or dot-dot', () => {
const bigqueryMock = {
projectId: 'my-project',
request: util.noop,
};

assert.throws(() => {
const invalidDataset = new BigQueryDataset(bigqueryMock, '..');
invalidDataset.getMetadata(assert.ifError);
}, /Invalid value \.\. for path segment/);
});

it('should percent-encode query parameter injection payload in table name', done => {
const bigqueryMock = {
projectId: 'my-project',
request: util.noop,
};
const ds = new BigQueryDataset(bigqueryMock, 'kittens');
const maliciousTableId = 'table_name?param=value#tag';
const table = ds.table(maliciousTableId);

ds.request = (reqOpts: DecorateRequestOptions) => {
assert.strictEqual(
reqOpts.uri,
'tables/table_name%3Fparam%3Dvalue%23tag',
);
done();
};

table.getMetadata(assert.ifError);
});
});
});
Loading
Loading