Skip to content

fix(identity): accept cloudfunctions.net auth blocking token audiences - #313

Merged
IzaakGough merged 6 commits into
mainfrom
fix/blocking-token-cloudfunctions-audience
Oct 2, 2026
Merged

IzaakGough merged 6 commits into
mainfrom
fix/blocking-token-cloudfunctions-audience

Conversation

@IzaakGough

@IzaakGough IzaakGough commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #311.

  • Accept either audience form. The verifier required an audience containing run.app. firebase-tools registers the cloudfunctions.net URL when it creates a blocking function and the run.app URL when it updates one, so a newly created function rejected every token with a 500 until a later deploy took the update path. It now accepts either, matching the Node SDK fix in Allow v2 auth blocking functions to use run.app or cf.net URLs firebase-functions#1831.
  • Match the parsed host suffix, not a substring of the URL. aud must be an https URL whose host ends with .run.app or .cloudfunctions.net, so the expected text in a path, query or fragment no longer matches. Neither host is project-scoped: iss already binds the token to this project, and aud only has to tell blocking tokens apart from regular ID tokens, whose aud is the bare project id.

Tests sign real tokens and serve the signing key as the certs, so acceptance is checked end to end. Verified against a real project: a first deploy registered a cloudfunctions.net URI and Google sign-in failed with this exact error, which the new test pins. The host matching is covered by the suite only, not by a live token.

The verifier only accepted an audience containing run.app, but
firebase-tools registers the cloudfunctions.net URL when it creates a
blocking function and the run.app URL when it updates one. A newly
created blocking function therefore rejected every token with a 500
until the next deploy moved it to the update path.

Accept either form, matching the Node SDK. The existing sys.modules mock
in the identity tests is replaced with an attribute patch, since it only
held while nothing had imported the real token_verifier module.

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

Copy link
Copy Markdown
Contributor

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 updates the token verification logic to support multiple expected audiences, specifically accommodating both 'run.app' and 'cloudfunctions.net' URL formats. The review highlights a potential security vulnerability in the current audience validation implementation, which uses a substring check that could be susceptible to audience confusion attacks. It is recommended to implement strict URL parsing and hostname validation. Additionally, the reviewer suggests expanding the test suite to include cases that specifically target potential bypasses of the audience check.

Comment thread src/firebase_functions/private/token_verifier.py Outdated
Comment thread tests/test_token_verifier.py
@IzaakGough
IzaakGough marked this pull request as ready for review September 28, 2026 14:15

@cabljac cabljac left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@IzaakGough approving, ready for the Firebase team to review. A few small things inline, the main one being the substring audience match.

# audience is either form depending on how the function was last deployed.
expected_audiences=[
"run.app",
f"{app.project_id}.cloudfunctions.net/",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the substring match might accept a bit more than we intend. I added a probe test on this branch and these both pass verification:

  • https://us-east1-other-test-project.cloudfunctions.net/fn for project test-project
  • https://evil.example.com/?x=run.app

Real risk seems low, since iss pins the project and Google signs the token, and Node has the same looseness. Tightening looks cheap though, so could we match on the parsed host instead? Something like:

host = urlsplit(audience).hostname or ""
host.endswith(".run.app") or host.endswith(f"-{self.project_id}.cloudfunctions.net")

Whenever we accept a value by pattern, it's usually worth writing the test for the closest lookalike first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 16fd338, matching on the parsed host.

One thing worth noting on the suffix form: host.endswith(f"-{project_id}.cloudfunctions.net") still accepts the probe you found. us-east1-other-test-project.cloudfunctions.net ends with -test-project.cloudfunctions.net, and so do the x1- and a- prefixed variants. Suffix matching cannot separate them, since region and project id both contain hyphens, so the region is pinned to its naming shape instead:

rf"[a-z]+(?:-[a-z]+)*\d+-{re.escape(project_id)}\.cloudfunctions\.net"

All 47 regions in gstatic.com/ipranges/cloud.json satisfy that, including ones newer than the SupportedRegion enum. It is an assumption about Google naming though, and a region shaped differently would be rejected, so I called that out in the PR body as the line to revisit.

urlsplit(...).hostname handles your ?x=run.app probe, as you had it. I also kept the run.app side un-scoped to the project, as in Node, so iss is still what ties a token to this project.

Framed it in the body as tidying rather than a security fix, since reaching this check needs a Google-signed token for this project either way.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I don't disagree that these tighter checks on cloudfunctions.net urls will work but ... is this over-engineering? It seems like we're adding extra checks only to one code path (cloudfunctions.net) but not another (run.app) and that just checking for either host suffix is probably sufficient?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Following up on this after looking closer at what the aud check is actually protecting against here:

  1. sig + iss already bind the token to Google and to this project:
    _JWTVerifier.verify() verifies the RS256 signature against ID_TOKEN_CERT_URI (securetoken@system.gserviceaccount.com) and requires exact equality on iss == f"https://securetoken.google.com/{self.project_id}". Because iss is strictly checked:

    • A blocking token from a suffix-colliding project (other-my-project) has iss = "https://securetoken.google.com/other-my-project" and is rejected by the iss check regardless of what aud contains.
    • aud is signed by Google rather than caller-controlled, and Identity Platform only registers valid Cloud Functions / Cloud Run trigger URLs when configuring blocking functions.
  2. What aud actually needs to distinguish:
    The only other tokens signed by securetoken@system.gserviceaccount.com with iss == f"https://securetoken.google.com/{project_id}" are regular Firebase Auth ID tokens, where aud is the bare project_id string (never a URL). The real security role of the aud check in AuthBlockingTokenVerifier is preventing token-type confusion so a regular Firebase ID token for the same project cannot be accepted as an Auth Blocking token.

  3. Why simple host suffix matching is better here:
    Since a Firebase project_id cannot contain dots or slashes, checking that aud is an https:// URL whose hostname ends with .run.app or .cloudfunctions.net completely separates blocking tokens from regular ID tokens. Pinning cloudfunctions.net to a region regex ([a-z]+(?:-[a-z]+)*\d+) doesn't add security over the .run.app branch, and risks breaking if GCP ever introduces a region name that doesn't match that pattern.

Could we simplify _blocking_audience_matcher to just check the host suffix?

_BLOCKING_HOST_SUFFIXES = (".run.app", ".cloudfunctions.net")


def _blocking_audience_matcher(audience: object) -> bool:
    if not isinstance(audience, str):
        return False
    parts = urlsplit(audience)
    host = parts.hostname or ""
    return parts.scheme == "https" and host.endswith(_BLOCKING_HOST_SUFFIXES)

(Or if we still want project_id in the cloudfunctions.net suffix for parity with Node's ${projectId}.cloudfunctions.net/, host.endswith((".run.app", f"-{project_id}.cloudfunctions.net")) avoids the region regex while keeping the host parser.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for digging into this. Agreed, iss already binds the token to the project, so aud only needs to tell blocking tokens apart from regular ID tokens. I went with your first snippet in 61614c9: an https URL whose host ends with .run.app or .cloudfunctions.net, with the region regex dropped.

I left out the -{project_id}.cloudfunctions.net variant because it still accepts other-my-project for my-project, so it wouldn't add anything over iss, and it would scope one host but not the other.

On the tests, a regular ID token (aud = project id) is now an aud rejection case. The prefixed-project cases now use their own project's iss, and the test checks they're rejected on iss (which means they got past the aud check). I've updated the PR body to match.

Comment thread tests/test_token_verifier.py Outdated
Comment thread tests/test_token_verifier.py Outdated
Comment thread src/firebase_functions/private/token_verifier.py Outdated
Comment thread src/firebase_functions/private/token_verifier.py Outdated
Comment thread tests/test_identity_fn.py Outdated
Comment thread tests/test_token_verifier.py
The bare pytest.raises passed for any InvalidAuthBlockingTokenError, including
an audience rejection, so it did not pin what its name says.
Reject a non-string "aud" with InvalidAuthBlockingTokenError rather than letting
the membership test raise TypeError, fix the class comment to name the renamed
kwarg, drop a stale test comment, and declare cryptography in the dev group since
the tests import it directly.
The audience check substring-matched the token's `aud`, so it accepted a URL
that merely contained the expected text: a project id ending with this one
(`other-my-project` against `my-project`), or the text placed in a path, query
or fragment on any host.

Match the parsed host instead, with the gen-1 region pinned to its naming
shape. This is deliberately stricter than firebase-admin-node, which
substring-matches. The run.app form stays un-scoped to the project, as in Node,
so `iss` is still what ties a token to this project.

`expected_audiences` becomes `audience_matcher`, since the list was only a
truthiness flag plus error text once matching moved to the host.
)

def matches(audience):
if not isinstance(audience, str):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I realize it worked this way in the old code effectively too, but do you know why we're only handling the case where audience is a string. RFC 7519 §4.1.3 allows the "aud" claim to be either a single string or an array of strings.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I did some digging into the security bits more, but that also came back with an answer that today we always only write a string so this is fine (at least until that changes)

# audience is either form depending on how the function was last deployed.
expected_audiences=[
"run.app",
f"{app.project_id}.cloudfunctions.net/",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I don't disagree that these tighter checks on cloudfunctions.net urls will work but ... is this over-engineering? It seems like we're adding extra checks only to one code path (cloudfunctions.net) but not another (run.app) and that just checking for either host suffix is probably sufficient?

iss already binds the token to the project, so aud only needs to tell
blocking tokens apart from regular ID tokens. Drops the region regex.
@IzaakGough
IzaakGough merged commit 4186454 into main Oct 2, 2026
21 checks passed
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.

identity_fn rejects cloudfunctions.net token audiences after the first deploy

3 participants