Skip to content

Commit 16fd338

Browse files
committed
fix(identity): match the blocking token audience on the parsed host
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.
1 parent ff27308 commit 16fd338

2 files changed

Lines changed: 87 additions & 25 deletions

File tree

‎src/firebase_functions/private/token_verifier.py‎

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
Module for internal token verification.
1616
"""
1717

18+
import re
19+
from urllib.parse import urlsplit
20+
1821
import google.auth.exceptions
1922
import google.oauth2.id_token
2023
import google.oauth2.service_account
@@ -29,12 +32,48 @@
2932
)
3033
from google.auth import jwt
3134

35+
_CLOUD_RUN_HOST_SUFFIX = ".run.app"
36+
37+
38+
def _blocking_audience_matcher(project_id):
39+
"""Builds the `audience_matcher` for auth blocking tokens.
40+
41+
A blocking token's `aud` is the function's own URL. firebase-tools registers the
42+
cloudfunctions.net URL when it creates a blocking function and the run.app URL
43+
when it updates one, so either host has to be accepted.
44+
45+
The gen-1 host is matched as `<region>-<project-id>.cloudfunctions.net` with the
46+
region pinned to its naming shape, so a project id that merely ends with this one
47+
(`other-my-project` against `my-project`) is not accepted as a suffix. Matching
48+
the parsed host rather than a substring of the URL also keeps the expected text
49+
from being smuggled in via a path, query or fragment.
50+
51+
Deliberately stricter than firebase-admin-node, which substring-matches `aud`.
52+
53+
The run.app host is not project-scoped: any Cloud Run URL satisfies it, as in the
54+
Node SDK. The `iss` check is what ties the token to this project.
55+
"""
56+
cloudfunctions_host = re.compile(
57+
rf"[a-z]+(?:-[a-z]+)*\d+-{re.escape(project_id)}\.cloudfunctions\.net"
58+
)
59+
60+
def matches(audience):
61+
if not isinstance(audience, str):
62+
return False
63+
parts = urlsplit(audience)
64+
if parts.scheme != "https":
65+
return False
66+
host = parts.hostname or ""
67+
return host.endswith(_CLOUD_RUN_HOST_SUFFIX) or bool(cloudfunctions_host.fullmatch(host))
68+
69+
return matches
70+
3271

3372
# pylint: disable=consider-using-f-string
3473
# mypy: ignore-errors
3574
# TODO remove once firebase-admin supports this directly.
3675
# Modified from src/firebase_admin/_token_gen.py to add
37-
# support for app_check tokens (expected_audiences kwarg and
76+
# support for app_check tokens (audience_matcher kwarg and
3877
# usage are new, plus None for audience on google.oauth2.id_token.verify_token call)
3978
class _JWTVerifier:
4079
"""Verifies Firebase JWTs (ID tokens or session cookies)."""
@@ -46,7 +85,8 @@ def __init__(self, **kwargs):
4685
self.url = kwargs.pop("doc_url")
4786
self.cert_url = kwargs.pop("cert_url")
4887
self.issuer = kwargs.pop("issuer")
49-
self.expected_audiences = kwargs.pop("expected_audiences")
88+
self.audience_matcher = kwargs.pop("audience_matcher", None)
89+
self.expected_audience_msg = kwargs.pop("expected_audience_msg", None)
5090
if self.short_name[0].lower() in "aeiou":
5191
self.articled_short_name = f"an {self.short_name}"
5292
else:
@@ -102,20 +142,12 @@ def verify(self, token, request):
102142
self.short_name, header.get("alg"), verify_id_token_msg
103143
)
104144
)
105-
elif (
106-
not emulated
107-
and self.expected_audiences
108-
and not (
109-
isinstance(audience, str)
110-
and any(expected in audience for expected in self.expected_audiences)
111-
)
112-
):
113-
expected = " or ".join(f'"{expected}"' for expected in self.expected_audiences)
145+
elif not emulated and self.audience_matcher and not self.audience_matcher(audience):
114146
error_message = (
115-
f'Firebase {self.short_name} has incorrect "aud" (audience) claim. Expected {expected} but '
147+
f'Firebase {self.short_name} has incorrect "aud" (audience) claim. Expected {self.expected_audience_msg} but '
116148
f'got "{audience}". {project_id_match_msg} {verify_id_token_msg}'
117149
)
118-
elif not emulated and not self.expected_audiences and audience != self.project_id:
150+
elif not emulated and not self.audience_matcher and audience != self.project_id:
119151
error_message = (
120152
f'Firebase {self.short_name} has incorrect "aud" (audience) claim. Expected "{self.project_id}" but '
121153
f'got "{audience}". {project_id_match_msg} {verify_id_token_msg}'
@@ -144,9 +176,9 @@ def verify(self, token, request):
144176
verified_claims = google.oauth2.id_token.verify_token(
145177
token,
146178
request=request,
147-
# If expected_audiences is set then we have already verified
179+
# If audience_matcher is set then we have already verified
148180
# the audience above.
149-
audience=(None if self.expected_audiences else self.project_id),
181+
audience=(None if self.audience_matcher else self.project_id),
150182
certs_url=self.cert_url,
151183
)
152184
verified_claims["uid"] = verified_claims["sub"]
@@ -197,13 +229,11 @@ def __init__(self, app):
197229
issuer=_token_gen.ID_TOKEN_ISSUER_PREFIX,
198230
invalid_token_error=InvalidAuthBlockingTokenError,
199231
expired_token_error=ExpiredAuthBlockingTokenError,
200-
# firebase-tools registers the cloudfunctions.net URL when it creates a
201-
# blocking function and the run.app URL when it updates one, so a token's
202-
# audience is either form depending on how the function was last deployed.
203-
expected_audiences=[
204-
"run.app",
205-
f"{app.project_id}.cloudfunctions.net/",
206-
],
232+
audience_matcher=_blocking_audience_matcher(app.project_id),
233+
expected_audience_msg=(
234+
"a https://*.run.app or "
235+
f"https://<region>-{app.project_id}.cloudfunctions.net/ function URL"
236+
),
207237
)
208238

209239
def verify_auth_blocking_token(self, auth_blocking_token):

‎tests/test_token_verifier.py‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,24 +92,56 @@ def _verify(app, token: str):
9292
return token_verifier.AuthBlockingTokenVerifier(app).verify_auth_blocking_token(token)
9393

9494

95-
@pytest.mark.parametrize("audience", [RUN_APP_AUDIENCE, CLOUDFUNCTIONS_AUDIENCE])
96-
def test_accepts_both_audience_forms(app, signing_key, audience):
95+
@pytest.mark.parametrize(
96+
"audience",
97+
[
98+
RUN_APP_AUDIENCE,
99+
CLOUDFUNCTIONS_AUDIENCE,
100+
# Run hosts: the current form carries the project number, the legacy form a
101+
# hash, and a revision tag prefixes the service with `tag---`.
102+
"https://beforecreate-123456789.us-central1.run.app",
103+
"https://tag---beforecreate-123456789.us-central1.run.app",
104+
# Regions the SupportedRegion enum predates, and a hypothetical shape with
105+
# more than one hyphen, since the region is matched by shape.
106+
f"https://northamerica-northeast1-{PROJECT_ID}.cloudfunctions.net/before_create",
107+
f"https://me-west1-{PROJECT_ID}.cloudfunctions.net/before_create",
108+
f"https://us-far-west1-{PROJECT_ID}.cloudfunctions.net/before_create",
109+
],
110+
)
111+
def test_accepts_function_url_audiences(app, signing_key, audience):
97112
assert _verify(app, _token(signing_key, audience))["uid"] == "uid123"
98113

99114

100115
@pytest.mark.parametrize(
101116
"audience",
102117
[
103118
"https://us-east1-other-project.cloudfunctions.net/before_create",
104-
f"https://us-east1-{PROJECT_ID}.cloudfunctions.net.example.com/before_create",
105119
"https://example.com/before_create",
120+
# A project id ending with this one must not be accepted as a suffix.
121+
f"https://us-east1-other-{PROJECT_ID}.cloudfunctions.net/before_create",
122+
f"https://us-east1-x1-{PROJECT_ID}.cloudfunctions.net/before_create",
123+
f"https://us-east1-a-{PROJECT_ID}.cloudfunctions.net/before_create",
124+
# The expected host must be the host, not text anywhere in the URL.
125+
f"https://us-east1-{PROJECT_ID}.cloudfunctions.net.example.com/before_create",
126+
f"https://example.com/{PROJECT_ID}.cloudfunctions.net/before_create",
127+
f"https://example.com#us-east1-{PROJECT_ID}.cloudfunctions.net/",
128+
"https://example.com/?x=run.app",
129+
"https://run.app.example.com/before_create",
130+
f"http://us-east1-{PROJECT_ID}.cloudfunctions.net/before_create",
106131
],
107132
)
108133
def test_rejects_foreign_audience(app, signing_key, audience):
109134
with pytest.raises(token_verifier.InvalidAuthBlockingTokenError, match='"aud"'):
110135
_verify(app, _token(signing_key, audience))
111136

112137

138+
def test_accepts_any_run_host(app, signing_key):
139+
"""The run.app form is not project-scoped; `iss` is what pins the project."""
140+
assert _verify(app, _token(signing_key, "https://svc-999.us-central1.run.app"))["uid"] == (
141+
"uid123"
142+
)
143+
144+
113145
def test_rejects_wrong_issuer(app, signing_key):
114146
token = _token(signing_key, RUN_APP_AUDIENCE, issuer="https://securetoken.google.com/other")
115147
with pytest.raises(token_verifier.InvalidAuthBlockingTokenError, match='"iss"'):

0 commit comments

Comments
 (0)