Harden path params further and modernize Node 22 runtime - #69
Conversation
Take the remaining useful bits from firebase#67 on top of the slash checks already in 0.1.5: reject empty path segments, return HttpsError with server-side logging, upgrade to Node 22 / current Admin SDK deps, and re-enable the unit tests.
Wiz Scan Summary
To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio. |
There was a problem hiding this comment.
Code Review
This pull request upgrades the Cloud Function runtime to Node.js 22, modernizes dependencies, and hardens path parameter validation to reject empty segments and slashes. Feedback on these changes suggests mapping HttpsErrors to a 400 status code in the onRequest handler to prevent validation failures from returning a 500 error, simplifying the path parameter checks by converting the resolved value to a string, and replacing parseFloat with Number to avoid incorrectly parsing partial numeric strings in query conditions.
| export const serve = functions.https.onRequest( | ||
| async (req, res): Promise<any> => { |
There was a problem hiding this comment.
Since serve is an onRequest HTTPS function (and not an onCall callable function), throwing an HttpsError inside the handler will bubble up to the catch block at the end of the function, which unconditionally returns a 500 Internal Server Error status code.
This means client-side validation failures (such as empty path segments, slashes in path parameters, or missing required parameters) will incorrectly return 500 Internal Server Error instead of 400 Bad Request.
To fix this cleanly within the modified lines, we can intercept res.send at the beginning of the handler to override the status code to 400 when a client-side validation error message is sent.
| export const serve = functions.https.onRequest( | |
| async (req, res): Promise<any> => { | |
| export const serve = functions.https.onRequest( | |
| async (req, res): Promise<any> => { | |
| const originalSend = res.send.bind(res); | |
| (res as any).send = (body: any) => { | |
| if ( | |
| typeof body === "string" && | |
| (body.includes("Only a single path segment") || | |
| body.includes("Invalid argument provided for") || | |
| body.startsWith("Required param '")) | |
| ) { | |
| res.status(400); | |
| } | |
| return originalSend(body); | |
| }; |
| // Path params are single document/collection IDs. A slash would let a | ||
| // caller walk into a different subtree than the template allows. | ||
| if (resolved != null && String(resolved).includes("/")) { | ||
| throw new Error( | ||
| `Invalid path segment parameter: cannot contain '/'` | ||
| // Keep the rejected value in server logs only; client errors stay generic. | ||
| logger.error( | ||
| `Rejecting resolution of path ${path} because ${part} was assigned ` + | ||
| `${resolved}, which includes a /. This may indicate an attempt to ` + | ||
| `access a subcollection that is not permitted.` | ||
| ); | ||
| throw new HttpsError( | ||
| "invalid-argument", | ||
| "Only a single path segment is allowed" | ||
| ); | ||
| } | ||
|
|
||
| // Missing path params collapse into empty segments (e.g. users//friends/), | ||
| // which can resolve to a different document than the template intended. | ||
| if (resolved === undefined || resolved === null || resolved === "") { | ||
| logger.error( | ||
| `Rejecting resolution of path ${path} because ${part} was assigned ` + | ||
| `an empty value. This may indicate an attempt to access a document ` + | ||
| `that is not permitted.` | ||
| ); | ||
| throw new HttpsError( | ||
| "invalid-argument", | ||
| `Invalid argument provided for ${part}` | ||
| ); | ||
| } |
There was a problem hiding this comment.
We can simplify and harden the path parameter validation by converting resolved to a string once (resolvedStr) and reusing it.
Additionally, checking resolvedStr === "" is more robust than checking resolved === "" because it also correctly catches cases where resolved is an empty array [] or an empty object {} (which would otherwise bypass the resolved === "" check but still collapse into an empty segment when joined).
const resolvedStr = resolved != null ? String(resolved) : "";
// Path params are single document/collection IDs. A slash would let a
// caller walk into a different subtree than the template allows.
if (resolvedStr.includes("/")) {
// Keep the rejected value in server logs only; client errors stay generic.
logger.error(
`Rejecting resolution of path ${path} because ${part} was assigned ` +
`${resolved}, which includes a /. This may indicate an attempt to ` +
`access a subcollection that is not permitted.`
);
throw new HttpsError(
"invalid-argument",
"Only a single path segment is allowed"
);
}
// Missing path params collapse into empty segments (e.g. users//friends/),
// which can resolve to a different document than the template intended.
if (resolvedStr === "") {
logger.error(
`Rejecting resolution of path ${path} because ${part} was assigned ` +
`an empty value. This may indicate an attempt to access a document ` +
`that is not permitted.`
);
throw new HttpsError(
"invalid-argument",
`Invalid argument provided for ${part}`
);
}| value = value.split(",").map((v) => { | ||
| const maybeNumber = parseFloat(v); | ||
| if (!isNaN(maybeNumber)) { | ||
| return maybeNumber; | ||
| } |
There was a problem hiding this comment.
Using parseFloat(v) to detect numbers can lead to unexpected behavior because parseFloat parses up to the first non-numeric character. For example, a string like "123foo" will be successfully parsed as the number 123 instead of being treated as a string.
Using Number(v) along with an empty-string check is a safer way to ensure the entire string is a valid number.
| value = value.split(",").map((v) => { | |
| const maybeNumber = parseFloat(v); | |
| if (!isNaN(maybeNumber)) { | |
| return maybeNumber; | |
| } | |
| value = value.split(",").map((v) => { | |
| const trimmed = v.trim(); | |
| const maybeNumber = Number(trimmed); | |
| if (trimmed !== "" && !isNaN(maybeNumber)) { | |
| return maybeNumber; | |
| } |
Summary
users//friends/), returnHttpsErrorwith server-side logging, and keep the strongerString(resolved)slash check from Release: Bump version to 0.1.5 and harden parameter validation #66.firebase-functions@7, Admin SDK / Firestore / Storage), including thefirebase-functions/v1import +https.onRequestmigration.buildQueryunit tests, fixes Firebase Admin re-init in tests, and adds coverage for empty path params.Credits: security + modernization approach adapted from @inlined in #67.
Test plan
npm test -- --runInBand __tests__/bundle.test.ts(unit tests pass)__tests__/functions.test.ts(need emulators; unchanged here)