Skip to content

Harden path params further and modernize Node 22 runtime - #69

Closed
jeffdh5 wants to merge 1 commit into
firebase:mainfrom
jeffdh5:jeff/take-path-hardening-and-modernize
Closed

Harden path params further and modernize Node 22 runtime#69
jeffdh5 wants to merge 1 commit into
firebase:mainfrom
jeffdh5:jeff/take-path-hardening-and-modernize

Conversation

@jeffdh5

@jeffdh5 jeffdh5 commented Jul 28, 2026

Copy link
Copy Markdown

Summary

Credits: security + modernization approach adapted from @inlined in #67.

Test plan

  • npm test -- --runInBand __tests__/bundle.test.ts (unit tests pass)
  • Emulator integration tests in __tests__/functions.test.ts (need emulators; unchanged here)
  • Install/upgrade extension on a test project and request a parameterized bundle with valid, slash-containing, and missing path params

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-9635d3485b

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities 2 High 4 Medium
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings -
Software Management Finding Software Management Findings -
Total 2 High 4 Medium

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread functions/src/index.ts
Comment on lines +140 to 141
export const serve = functions.https.onRequest(
async (req, res): Promise<any> => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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);
};

Comment on lines +128 to 155
// 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}`
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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}`
        );
      }

Comment on lines +295 to +299
value = value.split(",").map((v) => {
const maybeNumber = parseFloat(v);
if (!isNaN(maybeNumber)) {
return maybeNumber;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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;
}

@huangjeff5 huangjeff5 closed this Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants