mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 15:01:34 +08:00
When TELEGRAM_WEBHOOK_URL was set but TELEGRAM_WEBHOOK_SECRET was not, python-telegram-bot received secret_token=None and the webhook endpoint accepted any HTTP POST. Anyone who could reach the listener could inject forged updates — spoofed user IDs, spoofed chat IDs, attacker-controlled message text — and trigger handlers as if Telegram delivered them. The fix refuses to start the adapter in webhook mode without the secret. Polling mode (default, no webhook URL) is unaffected — polling is authenticated by the bot token directly. BREAKING CHANGE for webhook-mode deployments that never set TELEGRAM_WEBHOOK_SECRET. The error message explains remediation: export TELEGRAM_WEBHOOK_SECRET="$(openssl rand -hex 32)" and instructs registering it with Telegram via setWebhook's secret_token parameter. Release notes must call this out. Reported in GHSA-3vpc-7q5r-276h by @bupt-Yy-young. Hardening — not CVE per SECURITY.md §3 "Public Exposure: Deploying the gateway to the public internet without external authentication or network protection" covers the historical default, but shipping a fail-open webhook as the default was the wrong choice and the guard aligns us with the SECURITY.md threat model.
101 lines
4.0 KiB
Python
101 lines
4.0 KiB
Python
"""Tests for GHSA-3vpc-7q5r-276h — Telegram webhook secret required.
|
|
|
|
Previously, when TELEGRAM_WEBHOOK_URL was set but TELEGRAM_WEBHOOK_SECRET
|
|
was not, python-telegram-bot received secret_token=None and the webhook
|
|
endpoint accepted any HTTP POST.
|
|
|
|
The fix refuses to start the adapter in webhook mode without the secret.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_repo = str(Path(__file__).resolve().parents[2])
|
|
if _repo not in sys.path:
|
|
sys.path.insert(0, _repo)
|
|
|
|
|
|
class TestTelegramWebhookSecretRequired:
|
|
"""Direct source-level check of the webhook-secret guard.
|
|
|
|
The guard is embedded in TelegramAdapter.connect() and hard to isolate
|
|
via mocks (requires a full python-telegram-bot ApplicationBuilder
|
|
chain). These tests exercise it via source inspection — verifying the
|
|
check exists, raises RuntimeError with the advisory link, and only
|
|
fires in webhook mode. End-to-end validation is covered by CI +
|
|
manual deployment tests.
|
|
"""
|
|
|
|
def _get_source(self) -> str:
|
|
path = Path(_repo) / "gateway" / "platforms" / "telegram.py"
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
def test_webhook_branch_checks_secret(self):
|
|
"""The webhook-mode branch of connect() must read
|
|
TELEGRAM_WEBHOOK_SECRET and refuse when empty."""
|
|
src = self._get_source()
|
|
# The guard must appear after TELEGRAM_WEBHOOK_URL is set
|
|
assert re.search(
|
|
r'TELEGRAM_WEBHOOK_SECRET.*?\.strip\(\)\s*\n\s*if not webhook_secret:',
|
|
src, re.DOTALL,
|
|
), (
|
|
"TelegramAdapter.connect() must strip TELEGRAM_WEBHOOK_SECRET "
|
|
"and raise when the secret is empty — see GHSA-3vpc-7q5r-276h"
|
|
)
|
|
|
|
def test_guard_raises_runtime_error(self):
|
|
"""The guard raises RuntimeError (not a silent log) so operators
|
|
see the failure at startup."""
|
|
src = self._get_source()
|
|
# Between the "if not webhook_secret:" line and the next blank
|
|
# line block, we should see a RuntimeError being raised
|
|
guard_match = re.search(
|
|
r'if not webhook_secret:\s*\n\s*raise\s+RuntimeError\(',
|
|
src,
|
|
)
|
|
assert guard_match, (
|
|
"Missing webhook secret must raise RuntimeError — silent "
|
|
"fall-through was the original GHSA-3vpc-7q5r-276h bypass"
|
|
)
|
|
|
|
def test_guard_message_includes_advisory_link(self):
|
|
"""The RuntimeError message should reference the advisory so
|
|
operators can read the full context."""
|
|
src = self._get_source()
|
|
assert "GHSA-3vpc-7q5r-276h" in src, (
|
|
"Guard error message must cite the advisory for operator context"
|
|
)
|
|
|
|
def test_guard_message_explains_remediation(self):
|
|
"""The error should tell the operator how to fix it."""
|
|
src = self._get_source()
|
|
# Should mention how to generate a secret
|
|
assert "openssl rand" in src or "TELEGRAM_WEBHOOK_SECRET=" in src, (
|
|
"Guard error message should show operators how to set "
|
|
"TELEGRAM_WEBHOOK_SECRET"
|
|
)
|
|
|
|
def test_polling_branch_has_no_secret_guard(self):
|
|
"""Polling mode (else-branch) must NOT require the webhook secret —
|
|
polling authenticates via the bot token, not a webhook secret."""
|
|
src = self._get_source()
|
|
# The guard should appear inside the `if webhook_url:` branch,
|
|
# not the `else:` polling branch. Rough check: the raise is
|
|
# followed (within ~60 lines) by an `else:` that starts the
|
|
# polling branch, and there's no secret-check in that polling
|
|
# branch.
|
|
webhook_block = re.search(
|
|
r'if webhook_url:\s*\n(.*?)\n else:\s*\n(.*?)\n',
|
|
src, re.DOTALL,
|
|
)
|
|
if webhook_block:
|
|
webhook_body = webhook_block.group(1)
|
|
polling_body = webhook_block.group(2)
|
|
assert "TELEGRAM_WEBHOOK_SECRET" in webhook_body
|
|
assert "TELEGRAM_WEBHOOK_SECRET" not in polling_body
|