Skip to content

Commit 89cedd2

Browse files
committed
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
1 parent fcd4a29 commit 89cedd2

3 files changed

Lines changed: 295 additions & 127 deletions

File tree

src/chat_sdk/adapters/google_chat/adapter.py

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -878,31 +878,59 @@ async def handle_webhook(
878878
and parsed["message"].get("data")
879879
and parsed.get("subscription")
880880
):
881-
# 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.
882888
if self._pubsub_audience:
883889
valid = await self._verify_bearer_token(request, self._pubsub_audience)
884890
if not valid:
885891
return {"body": "Unauthorized", "status": 401}
886-
elif not self._warned_no_pubsub_verification:
887-
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.
888904
self._logger.warn(
889-
"Pub/Sub webhook verification is disabled. "
890-
"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."
891908
)
909+
return {"body": "Unauthorized", "status": 401}
892910
return self._handle_pub_sub_message(parsed, options)
893911

894-
# 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.
895915
if self._google_chat_project_number:
896916
valid = await self._verify_bearer_token(request, self._google_chat_project_number)
897917
if not valid:
898918
return {"body": "Unauthorized", "status": 401}
899-
elif not self._warned_no_webhook_verification:
900-
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:
901928
self._logger.warn(
902-
"Google Chat webhook verification is disabled. "
903-
"Set GOOGLE_CHAT_PROJECT_NUMBER or googleChatProjectNumber "
904-
"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."
905932
)
933+
return {"body": "Unauthorized", "status": 401}
906934

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

src/chat_sdk/adapters/google_chat/types.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -301,15 +301,6 @@ class GoogleChatAdapterConfig:
301301
# Google Cloud project number for verifying direct webhook JWTs
302302
google_chat_project_number: str | None = None
303303

304-
# Explicit opt-in to disable webhook signature verification. Required to
305-
# construct the adapter when neither google_chat_project_number nor
306-
# pubsub_audience is configured. Without this flag the constructor raises
307-
# ValidationError -- fail-closed by default. Only enable in development or
308-
# when an upstream layer (e.g. authenticated Cloud Run invocations) provides
309-
# equivalent guarantees. Falls back to the
310-
# GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION env var when left unset (None).
311-
disable_signature_verification: bool | None = None
312-
313304
# User email to impersonate for Workspace Events API calls
314305
impersonate_user: str | None = None
315306

@@ -324,3 +315,16 @@ class GoogleChatAdapterConfig:
324315

325316
# Override bot username
326317
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

0 commit comments

Comments
 (0)