Skip to content

Functions emulator calls Secret Manager for secret params since 15.25.0 (isEmulator/force args swapped at resolveParams call site) #10866

Description

@Bradley-McCallion

[REQUIRED] Environment info

firebase-tools: 15.25.0 (regression from 15.24.0; master still affected as of writing)

Platform: Ubuntu 24.04 (GitHub Actions ubuntu-latest), Node 24.18.1

[REQUIRED] Test case

A functions codebase that declares a secret param, run against a demo project under the emulator:

# src/transactions/_common/config.py
from firebase_functions import params
TRANSACTIONS_APPLE_PRIVATE_KEY = params.SecretParam("TRANSACTIONS_APPLE_PRIVATE_KEY")
// .firebaserc
{ "projects": { "testing": "demo-abcd" } }

src/transactions/.secret.local exists and contains a value for TRANSACTIONS_APPLE_PRIVATE_KEY.

We hit this with runtime: python313 and firebase-functions==0.5.0, but the affected code is runtime-agnostic (src/deploy/functions/build.ts), so defineSecret() in a Node codebase should be affected identically.

[REQUIRED] Steps to reproduce

firebase emulators:exec --project testing "<any command>"

[REQUIRED] Expected behavior

The Functions emulator resolves secret params locally from .secret.local and never calls Cloud Secret Manager. This is the behaviour guaranteed by the isEmulator guard in resolveParams, added for #5520 / #7401:

// The functions emulator will handle secrets
if (!isEmulator) {
  for (const param of needSecret) { ... }
}

This worked in 15.24.0 and every prior version we've used.

[REQUIRED] Actual behavior

The emulator makes an authenticated Secret Manager call against the demo project during trigger discovery and the codebase fails to load, so no triggers are registered:

i  emulators: Detected demo project ID "demo-abcd", emulated services will use a demo configuration
i  functions: Watching ".../src/transactions" for Cloud Functions...
   * Serving Flask app 'serving'
   127.0.0.1 - - "GET /__/functions.yaml HTTP/1.1" 200 -
   127.0.0.1 - - "GET /__/quitquitquit HTTP/1.1" 200 -
⬢  functions: Failed to load function definition from source: FirebaseError: Request to
   https://secretmanager.googleapis.com/v1/projects/demo-abcd/secrets/TRANSACTIONS_APPLE_PRIVATE_KEY
   had HTTP Error: 403, Permission denied on resource project demo-abcd.

Note the URL is /secrets/{name} (metadata lookup), not /secrets/{name}/versions/latest — i.e. this is ensureSecretsecretManager.getSecretMetadata, a deploy-only path, not the emulator's own resolveSecretEnvs. .secret.local is never consulted, because this fails during discovery, before resolveSecretEnvs runs.

Cause

#10714 inserted a new force parameter into resolveParams before the existing isEmulator parameter, but the caller in build.ts was not updated, so the two arguments are now passed in swapped positions.

src/deploy/functions/params.ts#L396-L403 — new signature:

export async function resolveParams(
  params: Param[],
  firebaseConfig: FirebaseConfig,
  userEnvs: Record<string, ParamValue>,
  nonInteractive?: boolean,
  force?: boolean,              // <-- inserted at position 5
  isEmulator = false,           // <-- displaced to position 6
): Promise<...>

src/deploy/functions/build.ts#L356-L363 — caller still uses the old order:

const { paramValues, secretRefs } = await params.resolveParams(
  opts.build.params,
  opts.firebaseConfig,
  envWithTypes(opts.build.params, opts.userEnvs),
  opts.nonInteractive,
  opts.isEmulator,   // <-- binds to `force`
  opts.force,        // <-- binds to `isEmulator`
);

Under the emulator (isEmulator: true, force: undefined) this yields force === true and isEmulator === undefined, so !isEmulator is truthy, the guard is bypassed and ensureSecret() runs.

There is a second consequence in the deploy direction: firebase deploy --force yields isEmulator === true, which silently skips secret validation entirely, so a deploy referencing a nonexistent secret would no longer be caught by ensureSecret. Both parameters are optional booleans, so TypeScript does not flag the transposition.

Suggested fix

Swap the two arguments at the call site:

   opts.nonInteractive,
-  opts.isEmulator,
   opts.force,
+  opts.isEmulator,
 );

Both call sites of resolveParams are worth an audit, and a regression test asserting no Secret Manager call is made when isEmulator is true would prevent a recurrence. Happy to send a PR if that's useful.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions