fix(channels): reject unsigned webhook POSTs on encryption-configured channels (#401)

Closes #392 (uncontroversial part).

WeChat (`_handle_message`) and Feishu (`_handle_event`) gated their
signature/decryption checks behind a condition the REQUEST controls:

- WeChat: `if encrypt and self._crypto:` -- a POST with no `<Encrypt>`
  element took the false branch and reached `_safe_process_message`
  without any verification, even when `encoding_aes_key` + `token` were
  configured.
- Feishu: `if self.config.encrypt_key and "encrypt" in body:` -- a
  plaintext body skipped decryption entirely and was processed directly.

Since the webhook port is the channel's only inbound boundary, an
attacker could POST forged plaintext and reach the agent, spoofing
`sender_id` / `FromUserName` (and, with an empty allowlist, passing the
sender gate).

Fix: when encryption is configured, an inbound POST MUST carry the
encrypted field (`<Encrypt>` / `encrypt`) -- otherwise it is rejected
with 403 and never reaches the agent. Plaintext mode (no encryption
configured) is unchanged, so existing plaintext deployments are not
affected. The remaining fail-closed question (what to do when
credentials are entirely unset) is left for the maintainers to decide
as the policy part of the issue.

Regression tests (9 new):
- WeChat: plaintext rejected / missing Encrypt rejected / bad signature
  rejected / valid signature decrypts and processes / plaintext still
  accepted when no crypto.
- Feishu: plaintext rejected / non-dict body rejected / encrypted body
  decrypts and processes / plaintext still accepted when no encrypt_key.

93 tests in the two channel files pass; full suite 3045 passed, 13
skipped; ruff clean.

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
This commit is contained in:
houren Antony
2026-08-15 00:25:57 +08:00
committed by GitHub
parent 932c934485
commit da15b70535
4 changed files with 239 additions and 4 deletions
+12 -2
View File
@@ -828,8 +828,18 @@ class FeishuChannel(Channel, WebhookMixin, TokenMixin):
except Exception:
return web.Response(status=400)
# ── Decrypt if encrypt_key is configured ──
if self.config.encrypt_key and "encrypt" in body:
# When encryption is configured the inbound POST MUST carry an
# ``encrypt`` field. A plaintext body used to skip decryption and
# reach the agent directly, defeating the encryption setup (issue
# #392). Treat a missing ``encrypt`` field on an
# encryption-configured channel as an authentication failure.
if self.config.encrypt_key:
if not isinstance(body, dict) or "encrypt" not in body:
logger.warning(
"Feishu event rejected: encrypt_key is configured but the "
"body has no 'encrypt' field (possible signature bypass)"
)
return web.Response(status=403)
try:
body = self._decrypt_event(body["encrypt"])
except Exception: