From 5482dd6ff510142bffdb3e067d214830ebc913c0 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 26 Nov 2025 14:37:17 -0800 Subject: [PATCH 1/6] feat: enforce strict timeout validation for functions --- src/deploy/functions/prepare.ts | 1 + src/deploy/functions/validate.spec.ts | 102 ++++++++++++++++++++++++++ src/deploy/functions/validate.ts | 93 ++++++++++++++++++++++- 3 files changed, 194 insertions(+), 2 deletions(-) diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 0cc513dda0b..f4f10e048af 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -259,6 +259,7 @@ export async function prepare( await ensureTriggerRegions(wantBackend); resolveCpuAndConcurrency(wantBackend); validate.endpointsAreValid(wantBackend); + validate.validateTimeoutConfig(backend.allEndpoints(wantBackend)); inferBlockingDetails(wantBackend); } diff --git a/src/deploy/functions/validate.spec.ts b/src/deploy/functions/validate.spec.ts index c0ddc527cc5..2954ea48310 100644 --- a/src/deploy/functions/validate.spec.ts +++ b/src/deploy/functions/validate.spec.ts @@ -662,4 +662,106 @@ describe("validate", () => { } }); }); + + describe("validateTimeoutConfig", () => { + const ENDPOINT_BASE: backend.Endpoint = { + platform: "gcfv2", + id: "id", + region: "us-east1", + project: "project", + entryPoint: "func", + runtime: "nodejs16", + httpsTrigger: {}, + }; + + it("should allow valid HTTP v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + httpsTrigger: {}, + timeoutSeconds: 3600, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.not.throw(); + }); + + it("should allow function without timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + httpsTrigger: {}, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.not.throw(); + }); + + it("should throw on invalid HTTP v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + httpsTrigger: {}, + timeoutSeconds: 3601, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError); + }); + + it("should allow valid Event v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + eventTrigger: { + eventType: "google.cloud.storage.object.v1.finalized", + eventFilters: { bucket: "b" }, + retry: false, + }, + timeoutSeconds: 540, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.not.throw(); + }); + + it("should throw on invalid Event v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + eventTrigger: { + eventType: "google.cloud.storage.object.v1.finalized", + eventFilters: { bucket: "b" }, + retry: false, + }, + timeoutSeconds: 541, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError); + }); + + it("should allow valid Scheduled v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + scheduleTrigger: { schedule: "every 5 minutes" }, + timeoutSeconds: 1800, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.not.throw(); + }); + + it("should throw on invalid Scheduled v2 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + scheduleTrigger: { schedule: "every 5 minutes" }, + timeoutSeconds: 1801, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError); + }); + + it("should allow valid Gen 1 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: "gcfv1", + httpsTrigger: {}, + timeoutSeconds: 540, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.not.throw(); + }); + + it("should throw on invalid Gen 1 timeout", () => { + const ep: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: "gcfv1", + httpsTrigger: {}, + timeoutSeconds: 541, + }; + expect(() => validate.validateTimeoutConfig([ep])).to.throw(FirebaseError); + }); + }); }); diff --git a/src/deploy/functions/validate.ts b/src/deploy/functions/validate.ts index 05afe30b5ad..4c4e43c66f2 100644 --- a/src/deploy/functions/validate.ts +++ b/src/deploy/functions/validate.ts @@ -4,11 +4,42 @@ import * as clc from "colorette"; import { FirebaseError } from "../../error"; import { getSecretVersion, SecretVersion } from "../../gcp/secretManager"; import { logger } from "../../logger"; +import { getFunctionLabel } from "./functionsDeployHelper"; +import { serviceForEndpoint } from "./services"; import * as fsutils from "../../fsutils"; import * as backend from "./backend"; import * as utils from "../../utils"; import * as secrets from "../../functions/secrets"; -import { serviceForEndpoint } from "./services"; + +/** + * GCF Gen 1 has a max timeout of 540s. + */ +const MAX_V1_TIMEOUT_SECONDS = 540; + +/** + * Eventarc triggers are implicitly limited by Pub/Sub's ack deadline (600s). + * However, GCFv2 API prevents creation of functions with timeout > 540s. + * See https://cloud.google.com/pubsub/docs/subscription-properties#ack_deadline + */ +const MAX_V2_EVENTS_TIMEOUT_SECONDS = 540; + +/** + * Cloud Scheduler has a max attempt deadline of 30 minutes. + * See https://cloud.google.com/scheduler/docs/reference/rest/v1/projects.locations.jobs#Job.FIELDS.attempt_deadline + */ +const MAX_V2_SCHEDULE_TIMEOUT_SECONDS = 1800; + +/** + * Cloud Tasks has a max dispatch deadline of 30 minutes. + * See https://cloud.google.com/tasks/docs/reference/rest/v2/projects.locations.queues.tasks#Task.FIELDS.dispatch_deadline + */ +const MAX_V2_TASK_QUEUE_TIMEOUT_SECONDS = 1800; + +/** + * HTTP and Callable functions have a max timeout of 60 minutes. + * See https://cloud.google.com/run/docs/configuring/request-timeout + */ +const MAX_V2_HTTP_TIMEOUT_SECONDS = 3600; function matchingIds( endpoints: backend.Endpoint[], @@ -32,6 +63,7 @@ const cpu = (endpoint: backend.Endpoint): number => { export function endpointsAreValid(wantBackend: backend.Backend): void { const endpoints = backend.allEndpoints(wantBackend); functionIdsAreValid(endpoints); + validateTimeoutConfig(endpoints); for (const ep of endpoints) { serviceForEndpoint(ep).validateTrigger(ep, wantBackend); } @@ -145,6 +177,63 @@ export function cpuConfigIsValid(endpoints: backend.Endpoint[]): void { } } +/** + * Validates that the timeout for each endpoint is within acceptable limits. + * This is a breaking change to prevent dangerous infinite retry loops and confusing timeouts. + */ +export function validateTimeoutConfig(endpoints: backend.Endpoint[]): void { + const invalidEndpoints = endpoints.filter((ep) => { + const timeout = ep.timeoutSeconds; + if (!timeout) { + return false; + } + if (ep.platform === "gcfv1") { + return timeout > MAX_V1_TIMEOUT_SECONDS; + } + if (backend.isEventTriggered(ep)) { + return timeout > MAX_V2_EVENTS_TIMEOUT_SECONDS; + } + if (backend.isScheduleTriggered(ep)) { + return timeout > MAX_V2_SCHEDULE_TIMEOUT_SECONDS; + } + if (backend.isTaskQueueTriggered(ep)) { + return timeout > MAX_V2_TASK_QUEUE_TIMEOUT_SECONDS; + } + if (backend.isHttpsTriggered(ep) || backend.isCallableTriggered(ep)) { + return timeout > MAX_V2_HTTP_TIMEOUT_SECONDS; + } + return false; + }); + if (invalidEndpoints.length === 0) { + return; + } + + const invalidList = invalidEndpoints + .sort(backend.compareFunctions) + .map((ep) => { + let limit = MAX_V2_HTTP_TIMEOUT_SECONDS; + if (ep.platform === "gcfv1") { + limit = MAX_V1_TIMEOUT_SECONDS; + } else if (backend.isEventTriggered(ep)) { + limit = MAX_V2_EVENTS_TIMEOUT_SECONDS; + } else if (backend.isScheduleTriggered(ep)) { + limit = MAX_V2_SCHEDULE_TIMEOUT_SECONDS; + } else if (backend.isTaskQueueTriggered(ep)) { + limit = MAX_V2_TASK_QUEUE_TIMEOUT_SECONDS; + } else { + limit = MAX_V2_HTTP_TIMEOUT_SECONDS; + } + return `\t${getFunctionLabel(ep)}: ${ep.timeoutSeconds}s (limit: ${limit}s)`; + }) + .join("\n"); + + const msg = + "The following functions have timeouts that exceed the maximum allowed for their trigger type:\n\n" + + invalidList + + "\n\nFor more information, see https://firebase.google.com/docs/functions/quotas#time_limits"; + throw new FirebaseError(msg); +} + /** Validate that all endpoints in the given set of backends are unique */ export function endpointsAreUnique(backends: Record): void { const endpointToCodebases: Record> = {}; // function name -> codebases @@ -233,7 +322,7 @@ function validatePlatformTargets(endpoints: backend.Endpoint[]) { const errs = unsupported.map((e) => `${e.id}[platform=${e.platform}]`); throw new FirebaseError( `Tried to set secret environment variables on ${errs.join(", ")}. ` + - `Only ${secretsSupportedPlatforms.join(", ")} support secret environments.`, + `Only ${secretsSupportedPlatforms.join(", ")} support secret environments.`, ); } } From 515bd4218f685dd567fd2b4bc9e0ee6fe9e8944a Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 26 Nov 2025 14:43:06 -0800 Subject: [PATCH 2/6] nit: run formatter --- src/deploy/functions/validate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/deploy/functions/validate.ts b/src/deploy/functions/validate.ts index 4c4e43c66f2..ceac1b95e92 100644 --- a/src/deploy/functions/validate.ts +++ b/src/deploy/functions/validate.ts @@ -322,7 +322,7 @@ function validatePlatformTargets(endpoints: backend.Endpoint[]) { const errs = unsupported.map((e) => `${e.id}[platform=${e.platform}]`); throw new FirebaseError( `Tried to set secret environment variables on ${errs.join(", ")}. ` + - `Only ${secretsSupportedPlatforms.join(", ")} support secret environments.`, + `Only ${secretsSupportedPlatforms.join(", ")} support secret environments.`, ); } } From 46dd0a31e318a931b29b06ebb11f4d898826ec6a Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 26 Nov 2025 14:43:34 -0800 Subject: [PATCH 3/6] docs: add changelog entry for timeout validation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6727fb4a96d..082100ccb50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Enforce strict timeout validation for functions. (#9540) - [BREAKING] Changed `firestore:backups:list --json` to return a `listBackupsResponse` object instead of a raw array of backups. - [BREAKING] Removed support for '.bolt' rules files. - [BREAKING] Removed support for running emulators with Java versions prior to 21. From 604ec1d05c60503d5ba09808cdd12d468a077238 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 26 Nov 2025 14:47:21 -0800 Subject: [PATCH 4/6] refactor: optimize timeout validation logic --- src/deploy/functions/validate.ts | 53 +++++++++++++------------------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/src/deploy/functions/validate.ts b/src/deploy/functions/validate.ts index ceac1b95e92..64a8623a32f 100644 --- a/src/deploy/functions/validate.ts +++ b/src/deploy/functions/validate.ts @@ -182,49 +182,38 @@ export function cpuConfigIsValid(endpoints: backend.Endpoint[]): void { * This is a breaking change to prevent dangerous infinite retry loops and confusing timeouts. */ export function validateTimeoutConfig(endpoints: backend.Endpoint[]): void { - const invalidEndpoints = endpoints.filter((ep) => { + const invalidEndpoints: { ep: backend.Endpoint; limit: number }[] = []; + for (const ep of endpoints) { const timeout = ep.timeoutSeconds; if (!timeout) { - return false; + continue; } + + let limit: number | undefined; if (ep.platform === "gcfv1") { - return timeout > MAX_V1_TIMEOUT_SECONDS; - } - if (backend.isEventTriggered(ep)) { - return timeout > MAX_V2_EVENTS_TIMEOUT_SECONDS; - } - if (backend.isScheduleTriggered(ep)) { - return timeout > MAX_V2_SCHEDULE_TIMEOUT_SECONDS; - } - if (backend.isTaskQueueTriggered(ep)) { - return timeout > MAX_V2_TASK_QUEUE_TIMEOUT_SECONDS; + limit = MAX_V1_TIMEOUT_SECONDS; + } else if (backend.isEventTriggered(ep)) { + limit = MAX_V2_EVENTS_TIMEOUT_SECONDS; + } else if (backend.isScheduleTriggered(ep)) { + limit = MAX_V2_SCHEDULE_TIMEOUT_SECONDS; + } else if (backend.isTaskQueueTriggered(ep)) { + limit = MAX_V2_TASK_QUEUE_TIMEOUT_SECONDS; + } else if (backend.isHttpsTriggered(ep) || backend.isCallableTriggered(ep)) { + limit = MAX_V2_HTTP_TIMEOUT_SECONDS; } - if (backend.isHttpsTriggered(ep) || backend.isCallableTriggered(ep)) { - return timeout > MAX_V2_HTTP_TIMEOUT_SECONDS; + + if (limit !== undefined && timeout > limit) { + invalidEndpoints.push({ ep, limit }); } - return false; - }); + } + if (invalidEndpoints.length === 0) { return; } const invalidList = invalidEndpoints - .sort(backend.compareFunctions) - .map((ep) => { - let limit = MAX_V2_HTTP_TIMEOUT_SECONDS; - if (ep.platform === "gcfv1") { - limit = MAX_V1_TIMEOUT_SECONDS; - } else if (backend.isEventTriggered(ep)) { - limit = MAX_V2_EVENTS_TIMEOUT_SECONDS; - } else if (backend.isScheduleTriggered(ep)) { - limit = MAX_V2_SCHEDULE_TIMEOUT_SECONDS; - } else if (backend.isTaskQueueTriggered(ep)) { - limit = MAX_V2_TASK_QUEUE_TIMEOUT_SECONDS; - } else { - limit = MAX_V2_HTTP_TIMEOUT_SECONDS; - } - return `\t${getFunctionLabel(ep)}: ${ep.timeoutSeconds}s (limit: ${limit}s)`; - }) + .sort((a, b) => backend.compareFunctions(a.ep, b.ep)) + .map(({ ep, limit }) => `\t${getFunctionLabel(ep)}: ${ep.timeoutSeconds}s (limit: ${limit}s)`) .join("\n"); const msg = From 2fe7f566dc62ee4f655ec0f22c57faa6f4fa1544 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 26 Nov 2025 15:06:03 -0800 Subject: [PATCH 5/6] nit: add "breaking" prefix in changelog. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 082100ccb50..9f013c029da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -- Enforce strict timeout validation for functions. (#9540) +- [BREAKING] Enforce strict timeout validation for functions. (#9540) - [BREAKING] Changed `firestore:backups:list --json` to return a `listBackupsResponse` object instead of a raw array of backups. - [BREAKING] Removed support for '.bolt' rules files. - [BREAKING] Removed support for running emulators with Java versions prior to 21. From 25c63ea449d96368c163252ed5dd180e15d01b79 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 26 Nov 2025 15:07:07 -0800 Subject: [PATCH 6/6] bug: remove redundant timeout validation. --- src/deploy/functions/prepare.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index f4f10e048af..0cc513dda0b 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -259,7 +259,6 @@ export async function prepare( await ensureTriggerRegions(wantBackend); resolveCpuAndConcurrency(wantBackend); validate.endpointsAreValid(wantBackend); - validate.validateTimeoutConfig(backend.allEndpoints(wantBackend)); inferBlockingDetails(wantBackend); }