Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/chat_sdk/adapters/google_chat/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,45 @@ def __init__(self, config: GoogleChatAdapterConfig | None = None) -> None:
"GOOGLE_CHAT_PROJECT_NUMBER"
)
self._pubsub_audience = config.pubsub_audience or os.environ.get("GOOGLE_CHAT_PUBSUB_AUDIENCE")
# Explicit opt-out of signature verification. An explicit
# config.disable_signature_verification value (True OR False) wins over
# the env var; only fall back to the env var when the config field is
# unset (None). Note: ``x if x is not None else default`` -- a plain
# ``or`` would silently discard an explicit ``False``.
self._disable_signature_verification = (
config.disable_signature_verification
if config.disable_signature_verification is not None
else os.environ.get("GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION") == "true"
)

# Fail-closed: refuse to construct unless webhook signature verification
# can be performed for at least one transport, or the operator has
# explicitly opted into the unverified path. Previously the adapter
# 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 👍 / 👎.

raise ValidationError(
"gchat",
"Webhook signature verification is required. Set "
"google_chat_project_number (or GOOGLE_CHAT_PROJECT_NUMBER) for "
"direct webhooks and/or pubsub_audience (or "
"GOOGLE_CHAT_PUBSUB_AUDIENCE) for Pub/Sub. To accept unverified "
"webhooks (NOT recommended in production), set "
"disable_signature_verification=True.",
)

# The escape hatch is dev-only -- warn loudly whenever it is the only
# reason the adapter was allowed to construct without a verifier.
if self._disable_signature_verification and not (self._google_chat_project_number or self._pubsub_audience):
self._logger.warn(
"Google Chat webhook signature verification is disabled "
"(disable_signature_verification / "
"GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION). Incoming webhooks "
"will be accepted without JWT verification. Do not use this in "
"production -- set google_chat_project_number and/or "
"pubsub_audience instead.",
)

# In-progress subscription creations to prevent duplicate requests
self._pending_subscriptions: dict[str, Any] = {}
Expand Down
9 changes: 9 additions & 0 deletions src/chat_sdk/adapters/google_chat/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,15 @@ class GoogleChatAdapterConfig:
# Google Cloud project number for verifying direct webhook JWTs
google_chat_project_number: str | None = None

# Explicit opt-in to disable webhook signature verification. Required to
# construct the adapter when neither google_chat_project_number nor
# pubsub_audience is configured. Without this flag the constructor raises
# ValidationError -- fail-closed by default. Only enable in development or
# 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 👍 / 👎.


# User email to impersonate for Workspace Events API calls
impersonate_user: str | None = None

Expand Down
3 changes: 3 additions & 0 deletions tests/test_critical_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ def _make_google_chat_adapter() -> GoogleChatAdapter:
private_key="-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----",
project_id="test-project",
),
# Adapter fails closed without a verification gating field; this test
# exercises non-verification mechanics, so use the explicit opt-out.
disable_signature_verification=True,
)
return GoogleChatAdapter(config)

Expand Down
7 changes: 6 additions & 1 deletion tests/test_dispatch_key_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,12 @@ def _make_adapter(self) -> Any:
from chat_sdk.adapters.google_chat.adapter import GoogleChatAdapter
from chat_sdk.adapters.google_chat.types import GoogleChatAdapterConfig

adapter = GoogleChatAdapter(GoogleChatAdapterConfig(use_application_default_credentials=True))
adapter = GoogleChatAdapter(
GoogleChatAdapterConfig(
use_application_default_credentials=True,
disable_signature_verification=True,
)
)
adapter._chat = _make_mock_chat()
adapter._bot_user_id = "users/bot123"
return adapter
Expand Down
3 changes: 3 additions & 0 deletions tests/test_fixture_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,9 @@ def _make_adapter(self, fixture: dict[str, Any]) -> GoogleChatAdapter:
client_email="test@test.iam.gserviceaccount.com",
private_key="-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
),
# Replay fixtures carry no JWT; opt out of verification so the
# adapter constructs (fail-closed default would otherwise raise).
disable_signature_verification=True,
)
adapter = GoogleChatAdapter(config)
# Set bot user ID from fixture so the adapter can detect self-messages
Expand Down
4 changes: 4 additions & 0 deletions tests/test_gchat_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ def _make_credentials() -> ServiceAccountCredentials:


def _make_adapter(**overrides: Any) -> GoogleChatAdapter:
# Adapter fails closed without a verification gating field; default these
# non-verification tests to the explicit opt-out.
overrides.setdefault("disable_signature_verification", True)
config = GoogleChatAdapterConfig(
credentials=overrides.pop("credentials", _make_credentials()),
**overrides,
Expand Down Expand Up @@ -1088,6 +1091,7 @@ def test_logs_context_in_error(self):
GoogleChatAdapterConfig(
credentials=_make_credentials(),
logger=mock_logger,
disable_signature_verification=True,
)
)

Expand Down
22 changes: 16 additions & 6 deletions tests/test_gchat_comprehensive.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ def _make_credentials() -> ServiceAccountCredentials:


def _make_adapter(**overrides: Any) -> GoogleChatAdapter:
# Adapter fails closed without a verification gating field; default these
# non-verification tests to the explicit opt-out.
overrides.setdefault("disable_signature_verification", True)
config = GoogleChatAdapterConfig(
credentials=overrides.pop("credentials", _make_credentials()),
**overrides,
Expand Down Expand Up @@ -279,8 +282,10 @@ def test_throws_when_no_auth_configured_and_no_env_vars(self):
saved = {k: v for k, v in os.environ.items() if k.startswith("GOOGLE_CHAT_")}
try:
self._clear_gchat_env()
# Gating field set so construction reaches the auth check rather
# than the fail-closed verification check.
with pytest.raises(ValidationError, match="Authentication"):
GoogleChatAdapter(GoogleChatAdapterConfig())
GoogleChatAdapter(GoogleChatAdapterConfig(disable_signature_verification=True))
finally:
os.environ.update(saved)

Expand All @@ -294,7 +299,7 @@ def test_resolves_credentials_from_env_var(self):
"private_key": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n",
}
)
adapter = GoogleChatAdapter(GoogleChatAdapterConfig())
adapter = GoogleChatAdapter(GoogleChatAdapterConfig(disable_signature_verification=True))
assert adapter.name == "gchat"
finally:
self._clear_gchat_env()
Expand All @@ -305,7 +310,7 @@ def test_resolves_adc_from_env_var(self):
try:
self._clear_gchat_env()
os.environ["GOOGLE_CHAT_USE_ADC"] = "true"
adapter = GoogleChatAdapter(GoogleChatAdapterConfig())
adapter = GoogleChatAdapter(GoogleChatAdapterConfig(disable_signature_verification=True))
assert adapter.name == "gchat"
finally:
self._clear_gchat_env()
Expand All @@ -322,7 +327,7 @@ def test_resolves_pubsub_topic_from_env_var(self):
}
)
os.environ["GOOGLE_CHAT_PUBSUB_TOPIC"] = "projects/test/topics/test"
adapter = GoogleChatAdapter(GoogleChatAdapterConfig())
adapter = GoogleChatAdapter(GoogleChatAdapterConfig(disable_signature_verification=True))
assert adapter._pubsub_topic == "projects/test/topics/test"
finally:
self._clear_gchat_env()
Expand All @@ -339,7 +344,7 @@ def test_resolves_impersonate_user_from_env_var(self):
}
)
os.environ["GOOGLE_CHAT_IMPERSONATE_USER"] = "user@example.com"
adapter = GoogleChatAdapter(GoogleChatAdapterConfig())
adapter = GoogleChatAdapter(GoogleChatAdapterConfig(disable_signature_verification=True))
assert adapter._impersonate_user == "user@example.com"
finally:
self._clear_gchat_env()
Expand All @@ -366,7 +371,12 @@ def test_config_credentials_take_priority_over_env_vars(self):

class TestConstructorWithADC:
def test_accepts_adc_config(self):
adapter = GoogleChatAdapter(GoogleChatAdapterConfig(use_application_default_credentials=True))
adapter = GoogleChatAdapter(
GoogleChatAdapterConfig(
use_application_default_credentials=True,
disable_signature_verification=True,
)
)
assert adapter.name == "gchat"

def test_default_user_name_is_bot(self):
Expand Down
163 changes: 162 additions & 1 deletion tests/test_gchat_verification.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""Tests for Google Chat webhook verification behaviour.

Covers: rejecting webhooks without auth header, rejecting invalid tokens,
Covers: the constructor fail-closed verification gate (google_chat_project_number,
pubsub_audience, or the disable_signature_verification escape hatch, with env
fallback), rejecting webhooks without auth header, rejecting invalid tokens,
warning when no project number is configured, and allowing webhooks
when verification is unconfigured.
"""

from __future__ import annotations

import json
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock

Expand All @@ -18,6 +21,7 @@
GoogleChatAdapterConfig,
ServiceAccountCredentials,
)
from chat_sdk.shared.errors import ValidationError

# =============================================================================
# Helpers
Expand All @@ -33,6 +37,12 @@ def _make_credentials() -> ServiceAccountCredentials:


def _make_adapter(**overrides: Any) -> GoogleChatAdapter:
# The adapter now fails closed at construction unless one of
# google_chat_project_number, pubsub_audience, or
# disable_signature_verification is set. Tests that want the unconfigured
# runtime path default to the explicit opt-out; verification-gated tests
# pass google_chat_project_number / pubsub_audience to override it.
overrides.setdefault("disable_signature_verification", True)
config = GoogleChatAdapterConfig(
credentials=overrides.pop("credentials", _make_credentials()),
**overrides,
Expand Down Expand Up @@ -174,6 +184,10 @@ async def test_warns_when_no_project_number_configured(self):
logger.child = MagicMock(return_value=logger)

adapter = _make_adapter(logger=logger)
# The constructor emits a dev-only warning when the escape hatch is the
# sole gate; reset the mock so this test asserts only the *runtime*
# warn path on the first unconfigured webhook.
logger.warn.reset_mock()
# Explicitly clear project number
adapter._google_chat_project_number = None
adapter._warned_no_webhook_verification = False
Expand Down Expand Up @@ -225,3 +239,150 @@ async def test_allows_webhook_without_verification_when_unconfigured(self):
assert result["status"] == 200
# process_message should have been called since the event was valid
chat.process_message.assert_called_once()


# =============================================================================
# Tests -- constructor fail-closed verification gate
#
# Ports the gchat slice of upstream 9824d33 (PR #441): the constructor refuses
# to start unless webhook signature verification can be performed for at least
# one transport, or the operator explicitly opts out.
# =============================================================================


def _clear_verification_env() -> dict[str, str]:
"""Remove the three gating env vars and return the saved values."""
keys = (
"GOOGLE_CHAT_PROJECT_NUMBER",
"GOOGLE_CHAT_PUBSUB_AUDIENCE",
"GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION",
)
saved = {k: os.environ[k] for k in keys if k in os.environ}
for k in keys:
os.environ.pop(k, None)
return saved


def _config(**overrides: Any) -> GoogleChatAdapterConfig:
"""Build a config with valid auth but no gating field unless overridden."""
return GoogleChatAdapterConfig(credentials=_make_credentials(), **overrides)


class TestConstructorFailsClosed:
"""The constructor must fail closed when no verifier is configured."""

def test_raises_when_no_gating_field_set(self):
saved = _clear_verification_env()
try:
with pytest.raises(ValidationError, match="signature verification is required"):
GoogleChatAdapter(_config())
finally:
os.environ.update(saved)

def test_explicit_disable_false_still_fails_closed(self):
# An explicit False must be treated as "verification required", NOT as
# unset -- otherwise the env fallback / fail-closed logic would be wrong.
saved = _clear_verification_env()
try:
with pytest.raises(ValidationError, match="signature verification is required"):
GoogleChatAdapter(_config(disable_signature_verification=False))
finally:
os.environ.update(saved)


class TestConstructorEachGatingFieldSatisfiesIndividually:
"""Any one of the three gating fields must allow construction."""

def test_google_chat_project_number_satisfies(self):
saved = _clear_verification_env()
try:
adapter = GoogleChatAdapter(_config(google_chat_project_number="123456789"))
assert adapter.name == "gchat"
assert adapter._google_chat_project_number == "123456789"
finally:
os.environ.update(saved)

def test_pubsub_audience_satisfies(self):
saved = _clear_verification_env()
try:
adapter = GoogleChatAdapter(_config(pubsub_audience="https://example.com/webhook"))
assert adapter.name == "gchat"
assert adapter._pubsub_audience == "https://example.com/webhook"
finally:
os.environ.update(saved)

def test_disable_signature_verification_satisfies(self):
saved = _clear_verification_env()
try:
adapter = GoogleChatAdapter(_config(disable_signature_verification=True))
assert adapter.name == "gchat"
assert adapter._disable_signature_verification is True
finally:
os.environ.update(saved)


class TestEscapeHatchEmitsWarning:
"""The dev-only escape hatch must construct AND log a warning."""

def test_escape_hatch_logs_warning(self):
saved = _clear_verification_env()
try:
logger = MagicMock()
logger.child = MagicMock(return_value=logger)
adapter = GoogleChatAdapter(_config(disable_signature_verification=True, logger=logger))
assert adapter._disable_signature_verification is True
warn_messages = [str(call) for call in logger.warn.call_args_list]
assert any("disabled" in m.lower() for m in warn_messages), (
f"Expected a dev-only warning when the escape hatch is used, got: {warn_messages}"
)
finally:
os.environ.update(saved)

def test_no_warning_when_real_verifier_configured(self):
# The warning is specific to the escape hatch; a real verifier must not
# trigger it even if disable_signature_verification is also set.
saved = _clear_verification_env()
try:
logger = MagicMock()
logger.child = MagicMock(return_value=logger)
GoogleChatAdapter(_config(google_chat_project_number="123456789", logger=logger))
warn_messages = [str(call) for call in logger.warn.call_args_list]
assert not any("disabled" in m.lower() for m in warn_messages), (
f"Did not expect an escape-hatch warning with a real verifier, got: {warn_messages}"
)
finally:
os.environ.update(saved)


class TestDisableSignatureVerificationEnvFallback:
"""The GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION env var must gate."""

def test_env_true_satisfies_construction(self):
saved = _clear_verification_env()
try:
os.environ["GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION"] = "true"
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 👍 / 👎.


def test_env_non_true_value_does_not_satisfy(self):
# Only the literal "true" enables the opt-out; anything else fails closed.
saved = _clear_verification_env()
try:
os.environ["GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION"] = "false"
with pytest.raises(ValidationError, match="signature verification is required"):
GoogleChatAdapter(_config())
finally:
os.environ.update(saved)

def test_explicit_config_false_overrides_env_true(self):
# An explicit config value wins over the env var, so a config False must
# fail closed even when the env var says "true".
saved = _clear_verification_env()
try:
os.environ["GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION"] = "true"
with pytest.raises(ValidationError, match="signature verification is required"):
GoogleChatAdapter(_config(disable_signature_verification=False))
finally:
os.environ.update(saved)
Loading
Loading