fix: validate path parameters and prevent traversal/injection in apiary request encodings - #9181
Conversation
…into apiary-path-traversal-vulnerability
…into apiary-path-traversal-vulnerability
There was a problem hiding this comment.
Code Review
This pull request introduces path parameter validation and encoding to prevent directory traversal and injection attacks. It adds a new transcoding.ts module with utility functions to validate single-segment and multi-segment path parameters, percent-encode them strictly according to RFC 3986, and integrate this validation into the API request creation flow. Comprehensive tests are added to verify these security enhancements. The review feedback suggests simplifying the applyPattern function in transcoding.ts by removing redundant regular expression compilation, as the wildcard patterns are limited to '*' and '**'.
| 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); | ||
| } | ||
|
|
||
| if (!pattern.includes('*') && pattern !== fieldValue) { | ||
| return undefined; | ||
| } | ||
|
|
||
| // since we're converting the pattern to a regex, make necessary precautions: | ||
| const regex = new RegExp( | ||
| '^' + | ||
| escapeRegExp(pattern) | ||
| .replace(/\\\*\\\*/g, '(.+)') | ||
| .replace(/\\\*/g, '([^/]+)') + | ||
| '$', | ||
| ); | ||
|
|
||
| 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. | ||
| // Note: The loop below matches the validation loop in google-gax verbatim. | ||
| // TODO: Consider refactoring this in the future. | ||
| 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); | ||
| } |
There was a problem hiding this comment.
Since extractTemplateParams only ever returns '*' or '**' as the wildcard pattern, the complex regular expression compilation and matching logic in applyPattern is entirely redundant and unreachable.\n\nWe can simplify applyPattern to directly handle '*' and '**' cases. This avoids compiling a new RegExp on every single parameter validation call, significantly improving performance and readability.
function applyPattern(\n pattern: string,\n fieldValue: string,\n propertyName = 'resource',\n): string | undefined {\n if (pattern === '*') {\n validateUriPathSegment(propertyName, fieldValue);\n return encodeWithSlashes(fieldValue);\n }\n\n if (pattern === '**') {\n validateUriPath(propertyName, fieldValue);\n return encodeWithoutSlashes(fieldValue);\n }\n\n return undefined;\n}There was a problem hiding this comment.
Yes. But we also want this method to be an exact match with the gax method because that makes the solution way easier to understand.
westarle
left a comment
There was a problem hiding this comment.
A couple opportunities to simplify and hopefully avoid bugs. I notice a few lints, also.
| options.url !== undefined && options.url !== null | ||
| ? typeof options.url === 'object' | ||
| ? options.url.toString() | ||
| : options.url | ||
| : parameters.mediaUrl ?? undefined, |
There was a problem hiding this comment.
could this be extracted like:
const urlTemplateString = options.url?.toString() ?? parameters.mediaUrl ?? undefined;There was a problem hiding this comment.
I mostly agree with this, but with a few adjustments:
- We should allow options.url to be used if it is available even if it is not an object that has toString method
- I definitely think that inlining here instead of creating a urlTemplateString variable is the right approach with the main benefit being that it doesn't create an extra urlTemplateString variable that the reader has to wonder about in the rest of the method. If the intent of the fragment is unclear then maybe just adding a comment is best.
There was a problem hiding this comment.
Actually, my point #1 is irrelevant because a url string has a toString method so that will work either way.
| * @param urlTemplate The RFC 6570 URI template string | ||
| * @returns Array of parameter names and their associated wildcard pattern | ||
| */ | ||
| function extractTemplateParams(urlTemplate: string): Array<{ |
There was a problem hiding this comment.
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,
}));
}There was a problem hiding this comment.
Sounds good. I've applied this code to simplify the method.
| params[param] = Array.isArray(parameterValue) | ||
| ? parameterValue.map(item => | ||
| applyPattern(wildcard, String(item), param), | ||
| ) | ||
| : applyPattern(wildcard, String(parameterValue), param); |
There was a problem hiding this comment.
Can we replace with something like this and delete applyPattern:
const encodeParam = (val: string) => {
validateUriPath(param, val);
return encodeWithoutSlashes(val);
};
params[param] = Array.isArray(parameterValue)
? parameterValue.map(item => encodeParam(String(item)))
: encodeParam(String(parameterValue));There was a problem hiding this comment.
Yes. That's a good idea, but I've made sure to leave a comment that this achieves what applyPattern from gax achieves because I think pointing out the connection between the two is really important.
Description
#9166 resolves a vulnerability for Gapic client libraries, but that same vulnerability exists for apiary libraries which this pull request patches. While this change is not on the apiary library, it is a change for the nodejs-googleapis-common package which all the apiary libraries use to do their requests.
Impact
Removes the potential for an exploit on apiary libraries.
Important Notes About Code Changes
Next Steps
Tracked here