Files
Krzysztof CzerwinskiandClaude Opus 4.7 7f08a16dee refactor(backend/integrations): consolidate webhook signature verification + fix Exa/Airtable bugs (#13135)
### Why / What / How

**Why.** While auditing the webhook ingress path, three issues surfaced:

1. **Exa** webhook verification was broken. The signature check ran
inside `validate_payload`, computed HMAC against `webhook.secret` (Exa
actually signs with the secret *it* returns at registration, stored in
`config["exa_secret"]`), read the wrong header (`X-Exa-Signature` vs the
real `Exa-Signature`), compared the raw `t=<ts>,v1=<hex>` header against
a bare hex digest, and signed the body without the required
`<timestamp>.` prefix. It could never have validated a real Exa
delivery.
2. **Airtable** verification had the same flavor of bug: Airtable
returns the signing secret base64-encoded as `macSecretBase64` (stored
in `config["mac_secret"]`), but the code used the base64 string verbatim
as the HMAC key instead of base64-decoding it first. Additionally, the
cursor update in `validate_payload` *replaced* the whole `config` blob,
wiping `mac_secret` after the first delivery.
3. The **generic webhook** trigger accepted any payload posted to its
URL with no way to opt into a shared-secret check, even though the user
controls both ends.

These didn't outright break anything today because Exa/Airtable gated
the check behind `if header_present:` — absent header → check skipped.
Net effect: verification was effectively a no-op everywhere except
GitHub/Telegram.

**What.** Consolidate signature verification into a dedicated,
provider-overridable step that runs *before* payload validation, and
make it actually correct for every provider whose protocol supports it.

**How.** New `BaseWebhooksManager.verify_signature(webhook, request)`
classmethod, called by the ingress router before `validate_payload`. The
default is a no-op so providers without a signing scheme (Compass,
Slant3D) don't have to fake one. Verification failures return **403**
(not 404 — that would leak webhook existence). Providers that sign
override it and `hmac.compare_digest` against the stored secret.

> **Note on feature flags:** an earlier revision of this PR gated
Exa/Airtable enforcement behind LaunchDarkly flags (`ENFORCE_*`) for a
staged rollout. We dropped that approach: a DB check confirmed **no
agents use the Exa or Airtable webhook triggers** (zero registered
webhooks for either provider), so the flags were pure ceremony — and a
`default=False` gate makes a security control fail *open* on a
LaunchDarkly outage. Verification now enforces unconditionally,
consistent with GitHub/Telegram.

### Changes 🏗️

| File | Change |
|---|---|
| `webhooks/_base.py` | Add `verify_signature` classmethod (no-op
default) |
| `api/features/integrations/router.py` | Call `verify_signature` before
`validate_payload`; 403 on failure |
| `webhooks/github.py`, `telegram.py` | Move existing checks from
`validate_payload` into `verify_signature` (pure refactor, behavior
unchanged) |
| `blocks/exa/_webhook.py` | Correct implementation: read
`Exa-Signature`, parse `t=<ts>,v1=<hex>` (multi-`v1` supported), sign
`<ts>.<raw body>`, key with `config["exa_secret"]` ([Exa
docs](https://docs.exa.ai/websets/api/webhooks/verifying-signatures)) |
| `blocks/airtable/_webhook.py` | Base64-decode `config["mac_secret"]`
before HMAC; verify `X-Airtable-Content-MAC` (`hmac-sha256=<hex>`);
merge cursor update into existing config instead of replacing it |
| `blocks/generic_webhook/_webhook.py`, `triggers.py` | Optional
`secret_token` input → require matching `X-Webhook-Secret`
(constant-time); unset = today's behavior |
| `docs/platform/new_blocks.md`, `docs/.../generic_webhook/triggers.md`
| Document the `verify_signature` extension point and the generic
`secret_token` |

**Compass / Slant3D:** no code change — their protocols have no signing
scheme; the default no-op covers them (the UUID URL is the bearer
secret).

### Test plan 📋

`backend/api/features/integrations/webhook_ingress_test.py` (24 tests):

- Unsigned providers (Compass, Slant3D) pass through.
- Always-signed providers (GitHub, Telegram) reject missing/invalid
sigs, accept valid ones.
- **Exa**: missing / malformed / wrong-signature → 403; correct `t=,v1=`
over `<ts>.<body>` → accepted; signature computed over body-only (the
old bug) → rejected; missing `config["exa_secret"]` fail-closes.
- **Airtable**: missing sig / missing `config["mac_secret"]` → 403;
correct base64-decoded HMAC → accepted; regression test that the old
"base64 string as key" HMAC does **not** verify.
- **Generic webhook**: no-token / empty-token pass through; configured
token → missing/wrong/correct `X-Webhook-Secret`.
- Ordering: `verify_signature` runs before `validate_payload`.

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `poetry run pytest
backend/api/features/integrations/webhook_ingress_test.py` — 24 passed
  - [x] `poetry run lint` / `type-check` clean; full CI green

Closes [SECRT-2359](https://linear.app/autogpt/issue/SECRT-2359).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-23 09:32:21 +00:00
..