Skip to content

0.8.0-dev9: fix webhook HMAC verify silently failing open (security)#10

Merged
stevenca merged 1 commit into
mainfrom
fix/0.8.0-dev9-webhook-secret-backend-api
Jun 5, 2026
Merged

0.8.0-dev9: fix webhook HMAC verify silently failing open (security)#10
stevenca merged 1 commit into
mainfrom
fix/0.8.0-dev9-webhook-secret-backend-api

Conversation

@stevenca

@stevenca stevenca commented Jun 5, 2026

Copy link
Copy Markdown
Owner

Summary — security hotfix caught by the dev8 deploy

While verifying the dev8 Meraki webhook publisher end-to-end on `cpn-ful-netcortex1`, an introspection of the secret backend revealed that both `webhooks/meraki.py` and `webhooks/catalyst_center.py` were calling `backend.get_secret(...)` — which is not a real method on `AwsSecretsManagerBackend` (the actual method is `backend.get(path, required=True)`).

The bare `except Exception` swallowed the resulting `AttributeError`, `_SECRET_CACHE` stayed empty, and both handlers fell through to the "no-secret-configured" branch — which accepts the webhook without HMAC verification, with only a warning log.

In other words: every Meraki and Catalyst Center webhook received since these modules shipped has bypassed signature verification. With dev8 now publishing `:ReflexEvent` nodes from those webhooks, an unauthenticated POST could produce graph artifacts.

Why the existing tests didn't catch it

The pre-existing route tests (and the new dev8 ones I just added) inject the secret directly into `_SECRET_CACHE` via fixture, bypassing `_get_shared_secret` entirely. The bug only manifests when a real backend lookup happens — which only happens in production.

Fix

  • Both handlers now call `await backend.get(path, required=False)` (the correct API).
  • Distinguish "no secret stored for this tenant" (return None silently) from "backend exception" (log loudly + return None).
  • Cache still populates on success (unchanged).

Tests

  • `tests/webhooks/test_secret_backend_integration.py` — 6 new cases using a `FakeBackend` that mimics the real `get(path, required) -> dict | None` signature:
    • Meraki: uses correct method (`get` not `get_secret`), passes `required=False`, missing tenant returns None, backend failure logs and returns None, second call hits cache
    • Catalyst Center: same coverage

These tests would have caught the original bug.

Operational notes

After deploy:

  • Send an unsigned POST to `/webhooks/meraki/<configured_tenant>` → expect 401 (was 200 before this fix)
  • Send a correctly-signed POST → expect 200 and a `:ReflexEvent`
  • Logs should show zero `webhook.meraki.secret_fetch_failed` for tenants whose secret IS in AWS SM (previously was firing on every webhook as a masked AttributeError)

Test plan

  • CI: all 8 jobs pass
  • Build, push, helm upgrade to dev9
  • Verify in cluster: `kubectl exec deploy/netcortex-web -- python3 -c "..."` calls `backend.get(...)` successfully
  • End-to-end Meraki POST with valid HMAC → 200 + ReflexEvent
  • End-to-end Meraki POST with invalid HMAC → 401

Made with Cursor

Pre-existing bug caught while verifying the dev8 deploy.

Both webhooks/meraki.py::_get_shared_secret and
webhooks/catalyst_center.py::_get_shared_secret called
backend.get_secret(...) — which is NOT a real method on
AwsSecretsManagerBackend (actual method: backend.get(path, required=True)).
The bare except Exception swallowed the AttributeError, _SECRET_CACHE
stayed empty, and both handlers fell through to their "no secret
configured" branch — which accepts the webhook without HMAC verification.

In other words: every Meraki and Catalyst Center webhook received since
these modules shipped has bypassed signature verification. With dev8
publishing :ReflexEvent nodes from those webhooks, any unauthenticated
POST could now produce graph artifacts.

Changes:
- Both handlers now call await backend.get(path, required=False) and
  distinguish "no secret stored" (return None) from "backend error"
  (log loudly + return None).
- New regression tests in tests/webhooks/test_secret_backend_integration.py
  that exercise the actual backend call path. The original bug hid
  because the existing route tests write to _SECRET_CACHE directly via
  fixture, bypassing _get_shared_secret. Tests now use a FakeBackend
  with the real .get(path, required) signature.

Co-authored-by: Cursor <cursoragent@cursor.com>
@stevenca stevenca merged commit 6705b1e into main Jun 5, 2026
8 checks passed
@stevenca stevenca deleted the fix/0.8.0-dev9-webhook-secret-backend-api branch June 5, 2026 21:18
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.

1 participant