Skip to content

Commit 09a47bc

Browse files
gchat: fail-closed JWT verification at construction (port of 9824d33) (#130)
* gchat: fail-closed JWT verification at construction (port of 9824d33) Port the gchat slice of upstream chat 9824d33 (PR #441 "adapter hardening pass") to GoogleChatAdapter. The constructor now fails closed: it raises ValidationError unless at least one of the following gates webhook signature verification -- - google_chat_project_number (or GOOGLE_CHAT_PROJECT_NUMBER), or - pubsub_audience (or GOOGLE_CHAT_PUBSUB_AUDIENCE), or - a new disable_signature_verification config flag explicitly set True (with GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION=true env fallback). Previously the adapter constructed in any state and silently accepted unverified webhooks when no verifier was configured, allowing forged payloads to impersonate users / trigger handlers. The new disable_signature_verification flag is an explicit, dev-only escape hatch; when it is the sole reason the adapter constructs, a warning is logged. The bool/env resolution uses `x if x is not None else default` so an explicit disable_signature_verification=False is distinct from unset and still fails closed (matching upstream `config.x ?? env === "true"`). Existing gchat test fixtures constructed the adapter without any gating field and now break under fail-closed; updated those fixtures (helpers + inline constructors) to set a valid gate (mostly the explicit opt-out, since they exercise non-verification mechanics). Verified this is a fixture fix, not a masked behavioral break: runtime webhook routing is unchanged in this slice. Tracking: #98 https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * fix(gchat): per-shape verification + dataclass field order + test env isolation (codex review) Addresses three findings from Codex's review of #130. 1. Per-shape webhook verification (P1, security). handle_webhook accepts BOTH direct Google Chat webhooks AND Pub/Sub push shapes on a single endpoint. The constructor's "at least one verifier OR disable_signature_verification" check is a coarse gate: an operator who configured only pubsub_audience left the direct-webhook shape going through the warned-but-unverified path (and symmetrically for project_number vs Pub/Sub). An attacker could pick the unconfigured shape to bypass the configured verifier. handle_webhook now REJECTS (401) any shape whose verifier isn't configured, unless disable_signature_verification is explicitly set -- in which case the existing warn-and-process path is preserved. Mirrors upstream adapter-gchat/src/index.ts (vercel/chat) faithfully; upstream solves this the same way (per-path require, construct-time stays any-of). 2. GoogleChatAdapterConfig.disable_signature_verification field order (P2). The PR inserted the new field in the MIDDLE of the dataclass, shifting every later positional arg. A caller like GoogleChatAdapterConfig("creds", "audience", impersonate, logger) would silently route `impersonate` into `disable_signature_verification`. Moved the field to the END so existing positional callers keep working (the field has a default so trailing positional callers are unaffected). Added a regression test that pins the field's position via dataclasses.fields(). 3. Env-var leakage in fail-closed tests (P2, test isolation). The env-fallback tests used a manual try/finally that only restored env vars SET before the test. Setting GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION=true in a test where the var was originally absent leaked it to later tests, silently satisfying the fail-closed gate in unrelated construction tests. Converted to pytest's monkeypatch fixture, which restores both was-set and was-absent cases at teardown. Added a load-bearing leak-detection test that exercises the previously-leaky pattern within a single test. All three findings have load-bearing tests added under tests/test_gchat_verification.py: - TestPerShapeVerificationRejection (3 tests; first two would 200 instead of 401 pre-fix) - TestDisableSignatureVerificationFieldOrder (2 tests; both fail pre-fix) - TestDisableSignatureVerificationEnvFallback::test_env_does_not_leak_to_subsequent_construction Validation: uv run ruff check, ruff format --check, audit_test_quality.py (0 hard failures), full pytest (4063 passed, 3 skipped). https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 367d1d2 commit 09a47bc

13 files changed

Lines changed: 455 additions & 24 deletions

src/chat_sdk/adapters/google_chat/adapter.py

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,45 @@ def __init__(self, config: GoogleChatAdapterConfig | None = None) -> None:
155155
"GOOGLE_CHAT_PROJECT_NUMBER"
156156
)
157157
self._pubsub_audience = config.pubsub_audience or os.environ.get("GOOGLE_CHAT_PUBSUB_AUDIENCE")
158+
# Explicit opt-out of signature verification. An explicit
159+
# config.disable_signature_verification value (True OR False) wins over
160+
# the env var; only fall back to the env var when the config field is
161+
# unset (None). Note: ``x if x is not None else default`` -- a plain
162+
# ``or`` would silently discard an explicit ``False``.
163+
self._disable_signature_verification = (
164+
config.disable_signature_verification
165+
if config.disable_signature_verification is not None
166+
else os.environ.get("GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION") == "true"
167+
)
168+
169+
# Fail-closed: refuse to construct unless webhook signature verification
170+
# can be performed for at least one transport, or the operator has
171+
# explicitly opted into the unverified path. Previously the adapter
172+
# accepted any webhook in this state, allowing forged payloads to
173+
# impersonate users / trigger handlers. Mirrors the gchat slice of
174+
# upstream 9824d33 (PR #441).
175+
if not (self._google_chat_project_number or self._pubsub_audience or self._disable_signature_verification):
176+
raise ValidationError(
177+
"gchat",
178+
"Webhook signature verification is required. Set "
179+
"google_chat_project_number (or GOOGLE_CHAT_PROJECT_NUMBER) for "
180+
"direct webhooks and/or pubsub_audience (or "
181+
"GOOGLE_CHAT_PUBSUB_AUDIENCE) for Pub/Sub. To accept unverified "
182+
"webhooks (NOT recommended in production), set "
183+
"disable_signature_verification=True.",
184+
)
185+
186+
# The escape hatch is dev-only -- warn loudly whenever it is the only
187+
# reason the adapter was allowed to construct without a verifier.
188+
if self._disable_signature_verification and not (self._google_chat_project_number or self._pubsub_audience):
189+
self._logger.warn(
190+
"Google Chat webhook signature verification is disabled "
191+
"(disable_signature_verification / "
192+
"GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION). Incoming webhooks "
193+
"will be accepted without JWT verification. Do not use this in "
194+
"production -- set google_chat_project_number and/or "
195+
"pubsub_audience instead.",
196+
)
158197

159198
# In-progress subscription creations to prevent duplicate requests
160199
self._pending_subscriptions: dict[str, Any] = {}
@@ -839,31 +878,59 @@ async def handle_webhook(
839878
and parsed["message"].get("data")
840879
and parsed.get("subscription")
841880
):
842-
# Verify Pub/Sub JWT if audience is configured
881+
# Verify Pub/Sub JWT if audience is configured. The two transports
882+
# share a single endpoint, so each shape must be independently
883+
# verified -- otherwise an attacker could send the unconfigured
884+
# shape to bypass the configured verifier. The constructor's "at
885+
# least one verifier OR disable_signature_verification" check is
886+
# not sufficient here for that reason. Mirrors upstream
887+
# adapter-gchat/src/index.ts.
843888
if self._pubsub_audience:
844889
valid = await self._verify_bearer_token(request, self._pubsub_audience)
845890
if not valid:
846891
return {"body": "Unauthorized", "status": 401}
847-
elif not self._warned_no_pubsub_verification:
848-
self._warned_no_pubsub_verification = True
892+
elif self._disable_signature_verification:
893+
if not self._warned_no_pubsub_verification:
894+
self._warned_no_pubsub_verification = True
895+
self._logger.warn(
896+
"Pub/Sub webhook verification is disabled. "
897+
"Set GOOGLE_CHAT_PUBSUB_AUDIENCE or pubsubAudience to verify incoming requests."
898+
)
899+
else:
900+
# Direct-webhook verifier may be configured but Pub/Sub
901+
# verifier is not -- reject rather than silently process an
902+
# unverified payload that a peer transport's config does
903+
# nothing to authenticate.
849904
self._logger.warn(
850-
"Pub/Sub webhook verification is disabled. "
851-
"Set GOOGLE_CHAT_PUBSUB_AUDIENCE or pubsubAudience to verify incoming requests."
905+
"Rejected Pub/Sub webhook: pubsub_audience is not configured. "
906+
"Set GOOGLE_CHAT_PUBSUB_AUDIENCE, or set "
907+
"disable_signature_verification to accept unverified Pub/Sub payloads."
852908
)
909+
return {"body": "Unauthorized", "status": 401}
853910
return self._handle_pub_sub_message(parsed, options)
854911

855-
# Verify direct Google Chat webhook JWT if project number is configured
912+
# Verify direct Google Chat webhook JWT if project number is configured.
913+
# Same reasoning as the Pub/Sub branch: each shape requires its own
914+
# verifier (or explicit opt-out) to prevent cross-transport bypass.
856915
if self._google_chat_project_number:
857916
valid = await self._verify_bearer_token(request, self._google_chat_project_number)
858917
if not valid:
859918
return {"body": "Unauthorized", "status": 401}
860-
elif not self._warned_no_webhook_verification:
861-
self._warned_no_webhook_verification = True
919+
elif self._disable_signature_verification:
920+
if not self._warned_no_webhook_verification:
921+
self._warned_no_webhook_verification = True
922+
self._logger.warn(
923+
"Google Chat webhook verification is disabled. "
924+
"Set GOOGLE_CHAT_PROJECT_NUMBER or googleChatProjectNumber "
925+
"to verify incoming requests."
926+
)
927+
else:
862928
self._logger.warn(
863-
"Google Chat webhook verification is disabled. "
864-
"Set GOOGLE_CHAT_PROJECT_NUMBER or googleChatProjectNumber "
865-
"to verify incoming requests."
929+
"Rejected direct Google Chat webhook: google_chat_project_number "
930+
"is not configured. Set GOOGLE_CHAT_PROJECT_NUMBER, or set "
931+
"disable_signature_verification to accept unverified payloads."
866932
)
933+
return {"body": "Unauthorized", "status": 401}
867934

868935
# Treat as a direct Google Chat webhook event
869936
event: dict[str, Any] = parsed

src/chat_sdk/adapters/google_chat/types.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,16 @@ class GoogleChatAdapterConfig:
315315

316316
# Override bot username
317317
user_name: str | None = None
318+
319+
# Explicit opt-in to disable webhook signature verification. Required to
320+
# construct the adapter when neither google_chat_project_number nor
321+
# pubsub_audience is configured. Without this flag the constructor raises
322+
# ValidationError -- fail-closed by default. Only enable in development or
323+
# when an upstream layer (e.g. authenticated Cloud Run invocations) provides
324+
# equivalent guarantees. Falls back to the
325+
# GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION env var when left unset (None).
326+
#
327+
# Kept at the END of the field list intentionally: GoogleChatAdapterConfig
328+
# is a positional-args dataclass, so inserting a new field in the middle
329+
# would silently shift every later positional arg for existing callers.
330+
disable_signature_verification: bool | None = None

tests/test_critical_fixes.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ def _make_google_chat_adapter() -> GoogleChatAdapter:
9090
private_key="-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----",
9191
project_id="test-project",
9292
),
93+
# Adapter fails closed without a verification gating field; this test
94+
# exercises non-verification mechanics, so use the explicit opt-out.
95+
disable_signature_verification=True,
9396
)
9497
return GoogleChatAdapter(config)
9598

tests/test_dispatch_key_validation.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,12 @@ def _make_adapter(self) -> Any:
327327
from chat_sdk.adapters.google_chat.adapter import GoogleChatAdapter
328328
from chat_sdk.adapters.google_chat.types import GoogleChatAdapterConfig
329329

330-
adapter = GoogleChatAdapter(GoogleChatAdapterConfig(use_application_default_credentials=True))
330+
adapter = GoogleChatAdapter(
331+
GoogleChatAdapterConfig(
332+
use_application_default_credentials=True,
333+
disable_signature_verification=True,
334+
)
335+
)
331336
adapter._chat = _make_mock_chat()
332337
adapter._bot_user_id = "users/bot123"
333338
return adapter

tests/test_fixture_replay.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,9 @@ def _make_adapter(self, fixture: dict[str, Any]) -> GoogleChatAdapter:
467467
client_email="test@test.iam.gserviceaccount.com",
468468
private_key="-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
469469
),
470+
# Replay fixtures carry no JWT; opt out of verification so the
471+
# adapter constructs (fail-closed default would otherwise raise).
472+
disable_signature_verification=True,
470473
)
471474
adapter = GoogleChatAdapter(config)
472475
# Set bot user ID from fixture so the adapter can detect self-messages

tests/test_gchat_api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ def _make_credentials() -> ServiceAccountCredentials:
133133

134134

135135
def _make_adapter(**overrides: Any) -> GoogleChatAdapter:
136+
# Adapter fails closed without a verification gating field; default these
137+
# non-verification tests to the explicit opt-out.
138+
overrides.setdefault("disable_signature_verification", True)
136139
config = GoogleChatAdapterConfig(
137140
credentials=overrides.pop("credentials", _make_credentials()),
138141
**overrides,
@@ -1088,6 +1091,7 @@ def test_logs_context_in_error(self):
10881091
GoogleChatAdapterConfig(
10891092
credentials=_make_credentials(),
10901093
logger=mock_logger,
1094+
disable_signature_verification=True,
10911095
)
10921096
)
10931097

tests/test_gchat_comprehensive.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ def _make_credentials() -> ServiceAccountCredentials:
7777

7878

7979
def _make_adapter(**overrides: Any) -> GoogleChatAdapter:
80+
# Adapter fails closed without a verification gating field; default these
81+
# non-verification tests to the explicit opt-out.
82+
overrides.setdefault("disable_signature_verification", True)
8083
config = GoogleChatAdapterConfig(
8184
credentials=overrides.pop("credentials", _make_credentials()),
8285
**overrides,
@@ -279,8 +282,10 @@ def test_throws_when_no_auth_configured_and_no_env_vars(self):
279282
saved = {k: v for k, v in os.environ.items() if k.startswith("GOOGLE_CHAT_")}
280283
try:
281284
self._clear_gchat_env()
285+
# Gating field set so construction reaches the auth check rather
286+
# than the fail-closed verification check.
282287
with pytest.raises(ValidationError, match="Authentication"):
283-
GoogleChatAdapter(GoogleChatAdapterConfig())
288+
GoogleChatAdapter(GoogleChatAdapterConfig(disable_signature_verification=True))
284289
finally:
285290
os.environ.update(saved)
286291

@@ -294,7 +299,7 @@ def test_resolves_credentials_from_env_var(self):
294299
"private_key": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n",
295300
}
296301
)
297-
adapter = GoogleChatAdapter(GoogleChatAdapterConfig())
302+
adapter = GoogleChatAdapter(GoogleChatAdapterConfig(disable_signature_verification=True))
298303
assert adapter.name == "gchat"
299304
finally:
300305
self._clear_gchat_env()
@@ -305,7 +310,7 @@ def test_resolves_adc_from_env_var(self):
305310
try:
306311
self._clear_gchat_env()
307312
os.environ["GOOGLE_CHAT_USE_ADC"] = "true"
308-
adapter = GoogleChatAdapter(GoogleChatAdapterConfig())
313+
adapter = GoogleChatAdapter(GoogleChatAdapterConfig(disable_signature_verification=True))
309314
assert adapter.name == "gchat"
310315
finally:
311316
self._clear_gchat_env()
@@ -322,7 +327,7 @@ def test_resolves_pubsub_topic_from_env_var(self):
322327
}
323328
)
324329
os.environ["GOOGLE_CHAT_PUBSUB_TOPIC"] = "projects/test/topics/test"
325-
adapter = GoogleChatAdapter(GoogleChatAdapterConfig())
330+
adapter = GoogleChatAdapter(GoogleChatAdapterConfig(disable_signature_verification=True))
326331
assert adapter._pubsub_topic == "projects/test/topics/test"
327332
finally:
328333
self._clear_gchat_env()
@@ -339,7 +344,7 @@ def test_resolves_impersonate_user_from_env_var(self):
339344
}
340345
)
341346
os.environ["GOOGLE_CHAT_IMPERSONATE_USER"] = "user@example.com"
342-
adapter = GoogleChatAdapter(GoogleChatAdapterConfig())
347+
adapter = GoogleChatAdapter(GoogleChatAdapterConfig(disable_signature_verification=True))
343348
assert adapter._impersonate_user == "user@example.com"
344349
finally:
345350
self._clear_gchat_env()
@@ -366,7 +371,12 @@ def test_config_credentials_take_priority_over_env_vars(self):
366371

367372
class TestConstructorWithADC:
368373
def test_accepts_adc_config(self):
369-
adapter = GoogleChatAdapter(GoogleChatAdapterConfig(use_application_default_credentials=True))
374+
adapter = GoogleChatAdapter(
375+
GoogleChatAdapterConfig(
376+
use_application_default_credentials=True,
377+
disable_signature_verification=True,
378+
)
379+
)
370380
assert adapter.name == "gchat"
371381

372382
def test_default_user_name_is_bot(self):

0 commit comments

Comments
 (0)