Skip to content

gchat: fail-closed JWT verification at construction (port of 9824d33)#130

Open
patrick-chinchill wants to merge 2 commits into
mainfrom
claude/port-gchat-fail-closed-verifier
Open

gchat: fail-closed JWT verification at construction (port of 9824d33)#130
patrick-chinchill wants to merge 2 commits into
mainfrom
claude/port-gchat-fail-closed-verifier

Conversation

@patrick-chinchill
Copy link
Copy Markdown
Collaborator

@patrick-chinchill patrick-chinchill commented May 30, 2026

What this ports

Ports the gchat slice of upstream chat commit 9824d33 (PR #441 "adapter hardening pass") to GoogleChatAdapter. 9824d33 is a 4-part security pass across multiple adapters; this PR is the Google Chat portion only (worklist P1-5).

GoogleChatAdapter now fails closed at two layers:

  1. Constructor: raises ValidationError unless webhook signature verification can be performed for at least one transport, or the operator explicitly opts out.
  2. handle_webhook runtime per-path routing: each webhook shape (direct vs Pub/Sub push) is independently verified or rejected with 401, even if the OTHER shape is configured. The construct-time any-of gate is necessary but not sufficient — the runtime check is what stops a Pub/Sub-only-configured adapter from accepting an unverified direct webhook (and vice versa). This matches upstream's handleWebhook byte-for-byte (vercel/chat's adapter-gchat/src/index.ts:705-764).

Exact gating condition (construct-time)

The constructor raises unless one of these is satisfied:

  • google_chat_project_number is set (or GOOGLE_CHAT_PROJECT_NUMBER) — direct webhooks, or
  • pubsub_audience is set (or GOOGLE_CHAT_PUBSUB_AUDIENCE) — Pub/Sub push, or
  • a new disable_signature_verification: bool config flag is explicitly True (with GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION=true env fallback) — the dev-only escape hatch.
if not (self._google_chat_project_number or self._pubsub_audience or self._disable_signature_verification):
    raise ValidationError("gchat", "Webhook signature verification is required. ...")

This mirrors upstream's exact condition set (googleChatProjectNumber || pubsubAudience || disableSignatureVerification).

Runtime per-path verification (in handle_webhook)

The Python port previously had a warn-and-allow path for the unconfigured shape — upstream had already fixed this in 9824d33 but the Python port shipped the gap. Now matched:

  • Direct webhook payload + pubsub_audience-only config → 401 Unauthorized
  • Pub/Sub push payload + google_chat_project_number-only config → 401 Unauthorized
  • disable_signature_verification=True → both shapes accepted (warn-and-process; escape hatch activated only when the real verifier is absent for that shape)
  • Verification happens BEFORE any JSON parse / handler lookup (no info-leak / pre-verify exploit surface)

Pub/Sub detection is unambiguous: isinstance(parsed.get("message"), dict) and parsed["message"].get("data") and parsed.get("subscription") — anything not matching falls to the direct branch, and each branch independently requires its own verifier. No shape-ambiguity bypass.

New config field

Added disable_signature_verification: bool | None = None to GoogleChatAdapterConfig, placed at the END of the dataclass field list to preserve positional-call compatibility with existing callers. The bool/env resolution uses x if x is not None else default so an explicit disable_signature_verification=False is distinct from unset (None) and still fails closed — matching upstream's config.x ?? process.env... === "true". A plain or would have silently discarded an explicit False.

Escape-hatch rationale

disable_signature_verification exists for local development (or when an upstream layer such as authenticated Cloud Run invocations provides equivalent guarantees). It is not recommended in production. When it is the sole reason the adapter constructs (no real verifier configured), the constructor logs a warn.

Existing fixtures updated

A fail-closed change breaks every existing gchat adapter instantiation that did not set a gating field. Updated test fixtures across 11 files to set a valid gate (mostly the explicit disable_signature_verification=True opt-out, since those tests exercise non-verification mechanics):

  • test_google_chat_adapter.py, test_gchat_webhook.py, test_gchat_webhook_extended.py, test_gchat_api.py, test_gchat_comprehensive.py, test_gchat_verification.py (helpers + inline ctors)
  • test_critical_fixes.py, test_production_fixes.py, test_fixture_replay.py, test_dispatch_key_validation.py, test_get_user_adapters.py (cross-cutting fixtures)

Auth-error tests (test_missing_auth, test_no_auth_raises, test_throws_when_no_auth_configured_and_no_env_vars) were given a gating field so they still reach the auth check (the verification check now precedes auth, matching upstream order).

Confirmed these are fixture fixes, not masked breaks: the per-path runtime routing is the intended security tightening; the only thing that needed updating in fixtures was construction without a gate and webhook tests that previously relied on the unverified-shape warn-and-allow path.

New load-bearing tests (test_gchat_verification.py)

Construct-time gate:

  • constructor with NONE of the three gates → raises ValidationError
  • each of the three satisfied individually → constructs successfully
  • escape hatch → constructs and emits the dev-only warning (asserted)
  • GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION env fallback works (true satisfies; non-"true" fails closed)
  • explicit disable_signature_verification=False still fails closed and overrides env true (the unset-vs-explicit-False distinction)

Runtime per-path verification (TestPerShapeVerificationRejection):

  • test_pubsub_push_rejected_when_only_project_number_configured — load-bearing (pre-fix returned 200; post-fix returns 401)
  • test_direct_webhook_rejected_when_only_pubsub_audience_configured — load-bearing (symmetric)
  • test_disable_signature_verification_allows_both_shapes — escape-hatch path

API compatibility (TestDisableSignatureVerificationFieldOrder):

  • test_disable_signature_verification_is_last_field — pins the dataclass field order at runtime
  • test_old_positional_call_does_not_misalign — load-bearing against the field-insertion bug

Test isolation (TestDisableSignatureVerificationEnvFallback):

  • test_env_does_not_leak_to_subsequent_construction — env-fallback tests converted to monkeypatch, no leak across tests

Blast radius

This is a breaking change for downstream gchat configs. Any GoogleChatAdapter instantiation that relied on the old "construct anyway, accept unverified webhooks" default will now raise ValidationError at construction. Downstream callers must set one gating field (google_chat_project_number, pubsub_audience, or disable_signature_verification=True) per gchat adapter instance.

chinchill-api impact: NONE. A check of chinchill-api's codebase confirmed it does not currently use chat_sdk's GoogleChatAdapter (only Slack and Teams adapters are registered). The CI-side google-chat-notify action is an incoming webhook poster, unrelated to the SDK adapter. So this PR can land without coordinated chinchill-api changes.

Within this repo the blast radius was limited to test fixtures only (11 files); the single src/ caller is the create_google_chat_adapter factory, which just passes config through. No new runtime dependencies.

Validation

  • uv run ruff check src/ tests/ → All checks passed
  • uv run ruff format --check src/ tests/ → all formatted
  • uv run python scripts/audit_test_quality.py → 0 hard failures (pre-existing cross-adapter name-duplicate warnings only)
  • uv run pytest tests/ -k google_chat -q → 26 passed
  • uv run pytest tests/ -q4063 passed, 3 skipped (skips pre-existing, unrelated)

Notes

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

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
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

Warning

Review limit reached

@patrick-chinchill, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 60 minutes. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b41d9ada-c301-4fad-be16-7866c95cb8d2

📥 Commits

Reviewing files that changed from the base of the PR and between 3ba6456 and 89cedd2.

📒 Files selected for processing (13)
  • src/chat_sdk/adapters/google_chat/adapter.py
  • src/chat_sdk/adapters/google_chat/types.py
  • tests/test_critical_fixes.py
  • tests/test_dispatch_key_validation.py
  • tests/test_fixture_replay.py
  • tests/test_gchat_api.py
  • tests/test_gchat_comprehensive.py
  • tests/test_gchat_verification.py
  • tests/test_gchat_webhook.py
  • tests/test_gchat_webhook_extended.py
  • tests/test_get_user_adapters.py
  • tests/test_google_chat_adapter.py
  • tests/test_production_fixes.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

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

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 introduces a fail-closed security mechanism to the Google Chat adapter. The adapter's constructor now requires webhook signature verification to be configured via google_chat_project_number or pubsub_audience, or explicitly disabled using the new disable_signature_verification configuration option (or its corresponding environment variable). If none of these are set, a ValidationError is raised. The test suite has been updated to support this new validation, and comprehensive tests have been added to verify the constructor's fail-closed behavior. There are no review comments to address, and the implementation is clean and correct.

Copy link
Copy Markdown
Collaborator Author

@codex review


Generated by Claude Code

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fcd4a2927e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# accepted any webhook in this state, allowing forged payloads to
# impersonate users / trigger handlers. Mirrors the gchat slice of
# upstream 9824d33 (PR #441).
if not (self._google_chat_project_number or self._pubsub_audience or self._disable_signature_verification):
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Require verification for each accepted webhook shape

When only one verifier is configured, this gate still lets the adapter start while the other webhook shape remains accepted without verification: for example, with only pubsub_audience set, a non-Pub/Sub/direct Google Chat payload falls through to the direct branch, logs the existing warning, and is processed without a project-number JWT check; the symmetric case exists for Pub/Sub when only google_chat_project_number is set. If handle_webhook continues to accept both shapes on the same endpoint, construction should either require verifiers for both paths or require the explicit disable flag for any unverified path.

Useful? React with 👍 / 👎.

Comment thread tests/test_gchat_verification.py Outdated
adapter = GoogleChatAdapter(_config())
assert adapter._disable_signature_verification is True
finally:
os.environ.update(saved)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore env vars after env-fallback tests

In the env-fallback tests, this finally only re-adds variables that existed before the test; if GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION was absent, setting it to true above leaves it set after the test. That can make later GoogleChatAdapter constructions pass the new fail-closed gate through the env fallback and hide missing configuration, so these tests should use monkeypatch or clear the gating keys before restoring saved.

Useful? React with 👍 / 👎.

# when an upstream layer (e.g. authenticated Cloud Run invocations) provides
# equivalent guarantees. Falls back to the
# GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION env var when left unset (None).
disable_signature_verification: bool | None = None
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Append new config fields to preserve positional callers

Because GoogleChatAdapterConfig is a public dataclass with positional __init__ arguments, inserting this field in the middle shifts every existing positional argument after google_chat_project_number; a caller that previously passed impersonate_user, logger, or pubsub_audience positionally will now populate disable_signature_verification with that value, potentially making the new gate truthy and misassigning the remaining config. Add the new option at the end of the dataclass or make the config keyword-only to avoid breaking existing callers.

Useful? React with 👍 / 👎.

… 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
Copy link
Copy Markdown
Collaborator Author

@GHCodex review


Generated by Claude Code

@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 30, 2026 16:59
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.

2 participants