Skip to content

Feat(webhook): route-configurable signature and event type headers - #68791

Open
Naroh091 wants to merge 5 commits into
NousResearch:mainfrom
Naroh091:feat/webhook-configurable-signature-header
Open

Feat(webhook): route-configurable signature and event type headers#68791
Naroh091 wants to merge 5 commits into
NousResearch:mainfrom
Naroh091:feat/webhook-configurable-signature-header

Conversation

@Naroh091

Copy link
Copy Markdown

What does this PR do?

Adds route-level options to the webhook adapter so any provider's headers can be handled through configuration instead of code, making Hermes a truly configurable webhook receiver:

  • signature_header — name of the header carrying the signature/token (e.g. X-Gitea-Signature, X-Hook-Signature)
  • signature_scheme — hmac-sha256 (default: hex HMAC digest of the raw body), hmac-sha1 / hmac-md5 (same, for providers that offer nothing stronger), or token (plain constant-time compare against the secret, GitLab-style)
  • signature_prefix — optional prefix (e.g. sha256=) required and stripped before comparison
  • event_header — name of the header carrying the event type, checked before the built-in X-GitHub-Event / X-GitLab-Event headers and payload fallbacks; the resolved value drives events filtering, filters on event, and the {event_type} template token the agent receives
routes:
  gitea-prs:
    secret: "your-gitea-webhook-secret"
    signature_header: "X-Gitea-Signature"   # raw hex HMAC-SHA256 of the body
    event_header: "X-Gitea-Event"           # where the provider puts the event type
    events: ["pull_request"]
    prompt: "Review this Gitea event: {__raw__}"

Why

In my case, I was trying to integrate Patreon, which uses X-Patreon-Event and X-Patreon-Signature, so the previous PRs wouldn't help as they are platform specific.

Related Issue

Fixes #68768

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

Providers that use the standard HMAC-over-body scheme under their own header name (Gitea's X-Gitea-Signature, Asana's X-Hook-Signature, …) currently fail validation, and their event headers aren't recognized, making the route-level events filter unusable. Instead of hardcoding providers one at a time (#66895, #54697 — this PR supersedes both), the header names become route configuration:

  • gateway/platforms/webhook.py — new per-route options:
    • signature_header: name of the header carrying the signature/token. Exclusive and fail-closed: when set, built-in GitHub/GitLab/Svix/generic detection is skipped and requests missing the header are rejected with 401, so a route pinned to one provider can't be authenticated through a different (possibly weaker) scheme.
    • signature_scheme: hmac-sha256 (default, hex HMAC digest of the raw body), hmac-sha1 / hmac-md5 (same, for providers that offer nothing stronger — HMAC remains a sound authenticator with these digests, but docs steer users to sha256), or token (plain constant-time compare against the secret, GitLab-style). Unknown schemes reject rather than fall back.
    • signature_prefix: optional prefix (e.g. sha256=) required and stripped before comparison.
    • event_header: consulted before the built-in X-GitHub-Event / X-GitLab-Event headers and payload fallbacks; drives events filtering, filters on event, and the {event_type} template token. Falls back to built-in resolution when absent, so mixed senders keep working.
    • connect() validates the config at startup (typo'd scheme, or scheme/prefix without signature_header) matching the existing fail-early pattern; comparisons use the existing hardened constant-time helper.
  • hermes_cli/subcommands/webhook.py, hermes_cli/webhook.pyhermes webhook subscribe accepts --signature-header / --signature-scheme / --signature-prefix / --event-header; list shows the pinned headers; test signs its POST with the route's configured signature header and sends the configured event header; subscribe output reflects the actual scheme instead of always saying HMAC-SHA256.
  • website/docs/user-guide/messaging/webhooks.md — new route properties rows + "Custom signature headers" section with examples and the replay-protection caveat (body-only HMAC, same as generic V1; generic V2 recommended when you control the sender).
  • tests/gateway/test_webhook_adapter.py — 20 new tests: valid/invalid custom-header HMAC across sha256/sha1/md5, exclusivity, digest cross-acceptance rejected, prefix required and stripped, token scheme, unknown scheme fails closed, case-insensitive lookup, hostile non-ASCII values, both startup ValueErrors, and event-header resolution (match/ignore/priority/fallback).

Out of scope: composite schemes that bind a timestamp (Stripe/Svix-style) still need native support, as Svix already has.

How to Test

  1. pytest tests/gateway/test_webhook_adapter.py — 115 tests pass (20 new); all 5 webhook suites pass (149 total).
  2. Create a route pinned to a custom header:
    hermes webhook subscribe demo --signature-header X-Provider-Signature --signature-scheme hmac-md5 --event-header X-Provider-Event
    then hermes webhook test demo with the gateway running → 202 accepted, with the event type resolved from the custom header.
  3. Negative checks against the same route: POST without the header, with a wrong digest, or with a valid HMAC sent under a different provider's header (e.g. X-Hub-Signature-256) → all 401.
  4. Startup validation: set signature_scheme: hmac-sha512 (or a scheme/prefix without signature_header) on a static route → gateway refuses to start with an actionable error.
  5. Verified end-to-end against a live third-party provider whose webhooks sign with HMAC-MD5 under a custom header: real deliveries accepted and delivered; the same negative checks rejected with 401.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Ubuntu 26.04 LTS

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings)
  • I've updated cli-config.yaml.example if I added/changed config keys
  • N/A I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • N/A I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • N/A I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

Test webhook from Patreon showing a 202:

Pasted_Image_21_7_26__18_15

Result:

image

Naroh091 and others added 4 commits July 21, 2026 17:21
Providers like Gitea (X-Gitea-Signature) and Asana (X-Hook-Signature)
use the standard HMAC-SHA256-over-body scheme but under their own header
name, so their deliveries failed validation unless the adapter hardcoded
each one (see NousResearch#66895, NousResearch#54697). Instead of growing a per-provider list,
routes can now declare the header to validate:

- signature_header: header name carrying the signature/token
- signature_scheme: hmac-sha256 (default) or token (plain constant-time
  compare, GitLab-style)
- signature_prefix: optional prefix (e.g. "sha256=") required and
  stripped before comparison

When signature_header is set it is exclusive and fail-closed: built-in
GitHub/GitLab/Svix/generic detection is skipped and requests missing the
header are rejected, so a route pinned to one provider cannot be
authenticated through a different scheme. Unknown schemes reject rather
than fall back; connect() validates the config at startup so typos fail
early with an actionable error.

The CLI gains matching support: hermes webhook subscribe accepts
--signature-header / --signature-scheme / --signature-prefix, list shows
the pinned header, and hermes webhook test signs its test POST with the
route's configured header.

Docs updated (route properties table + "Custom signature headers"
section) and 13 tests added covering validation, exclusivity, prefix
handling, token scheme, fail-closed unknown schemes, hostile non-ASCII
values, and startup validation errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Some providers still sign webhooks with a weaker HMAC digest and offer
nothing stronger. Extend signature_scheme with hmac-sha1 and hmac-md5
(hex digest of the raw body, same as hmac-sha256). HMAC remains a sound
authenticator with these digests — collision attacks on the bare hash do
not transfer to HMAC — but hmac-sha256 stays the default and the docs
steer users toward it whenever the provider supports it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The closing tip always said "HMAC-SHA256" even when the subscription was
just created with --signature-scheme hmac-md5 or token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Providers with their own event header (e.g. X-Gitea-Event) previously
resolved every delivery to the payload fields or "unknown", making the
route-level `events` filter useless for them. A route can now declare
`event_header`; it is consulted before the built-in X-GitHub-Event /
X-GitLab-Event headers and payload fallbacks, and the resolved value
drives `events` filtering, `filters` on event, and the {event_type}
template token the agent receives.

`hermes webhook subscribe` gains --event-header, and `hermes webhook
test` sends the route's configured event header in its test POST.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery comp/cli CLI entry point, hermes_cli/, setup wizard area/config Config system, migrations, profiles sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jul 21, 2026
@PRATHAMESH75

Copy link
Copy Markdown
Contributor

Reviewed against #68768 with a focus on the signature-validation security boundary — this is a clean, well-tested implementation that satisfies the issue's fail-closed / exclusive requirements. Ran the suites locally: tests/gateway/test_webhook_adapter.py 115 passed, tests/hermes_cli/test_webhook_cli.py 36 passed; ruff clean on all three changed modules.

Security properties I verified in _validate_signature:

  • Exclusive when signature_header is set — the custom-header branch returns early, so the built-in GitHub/GitLab/Svix/generic probing is fully skipped. A route pinned to Gitea can't be authenticated through GitHub's X-Hub-Signature-256. test_custom_header_missing_rejects_even_with_builtin_header and test_custom_header_wins_over_invalid_builtin pin this.
  • Fail-closed everywhere — missing header → False; digest mismatch → False; unknown scheme → False at request time too (not just the connect() startup ValueError), which correctly covers hot-reloaded dynamic routes that bypass startup validation. Good catch handling that path.
  • Constant-time throughout — both token and the HMAC schemes go through _hmac_str_equal (→ hmac.compare_digest on utf-8 bytes), and test_custom_header_non_ascii_value_rejected confirms a hostile non-ASCII header fails closed instead of 500-ing.
  • Secure defaultsignature_scheme omitted defaults to hmac-sha256, and the SHA-1/MD5 opt-ins are correctly documented as still-sound MACs (collision attacks on the bare hash don't transfer to HMAC).
  • Config hygieneconnect() rejects a typo'd scheme and an orphaned signature_scheme/signature_prefix without a header at startup, surfacing misconfig as a boot error rather than silent per-request 401s.

This is exactly the config-driven approach the issue asked for, and it makes the per-provider PRs (#66895 Gitea, #54697 Asana) one line of config each.

One minor, non-blocking note: the HMAC path compares provided against hexdigest(), which is lowercase. Providers that emit uppercase hex would fail validation. Gitea/GitHub-style emit lowercase so the named targets are fine, but if you want to be maximally provider-agnostic, normalizing the hex case before the compare (HMAC schemes only, never the token scheme) would remove a future foot-gun — case-folding attacker input before a constant-time compare doesn't weaken it. Entirely optional.

LGTM — thorough tests on the exact security-critical paths, fail-closed by construction, and docs updated.

@fabiomsnunes

Copy link
Copy Markdown

Perfect! This would solve ClickUp too. ClickUp webhooks send HMAC-SHA256 (raw hex of the body) in the X-Signature header. With this PR's signature_header: "X-Signature" + signature_scheme: hmac-sha256, ClickUp would work out of the box. Would love to see this merged ASAP :)

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for taking the route-configured approach rather than adding provider-specific branches. The premise remains valid on current main: gateway/platforms/webhook.py:1028-1141 recognizes only fixed signature formats, and gateway/platforms/webhook.py:699-706 resolves events from fixed headers/payload fields.

Problems

  • The new dynamic-subscription CLI behavior is untested. The PR changes persistence in hermes_cli/webhook.py:174-203 and request construction in hermes_cli/webhook.py:329-351, but its added tests are all in tests/gateway/test_webhook_adapter.py:1692+. Existing CLI coverage lives in tests/hermes_cli/test_webhook_cli.py:62-238 and does not exercise the new route properties.

Suggested changes

  • Add CLI tests for persistence, orphan option rejection, and the headers produced by hermes webhook test for HMAC and token routes.

This is an automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 30, 2026
@Naroh091

Naroh091 commented Jul 31, 2026

Copy link
Copy Markdown
Author

@teknium1 I've added the CLI tests.
Thank you.

@alt-glitch alt-glitch added platform/webhook Webhook / API server sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 31, 2026
@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Summary

One PR addresses #68768. #68791 implements provider-independent, route-configurable signature and event headers across gateway validation, dynamic-subscription CLI behavior, tests, and documentation, directly replacing the fixed-header limitation described by the issue.

Related pull requests

  • Feat(webhook): route-configurable signature and event type headers #68791 best fix — (+698/-15) — n/a: The diff adds exclusive, fail-closed validation for configurable HMAC-SHA256/SHA1/MD5 or token headers, configurable prefix handling, prioritized custom event-type headers, and corresponding CLI persistence and test-request construction. Consistent with the automated keep_open review, its requested CLI coverage is now present in tests/hermes_cli/test_webhook_cli.py for persistence, orphan-option rejection, and HMAC/token request headers; the contributor review separately confirms the signature-validation security properties and passing gateway/CLI suites.

Suggested consolidation

Keep #68791 open with a salvage path: retain its provider-independent route configuration, fail-closed validation, event resolution, CLI integration, documentation, and regression tests, and request maintainer re-review now that the automated keep_open review’s concrete CLI-test gap is addressed by the current diff. There are no competing PRs in this complex to close as duplicates.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    I68768(["issue #68768 (open)"])
    P68791["PR #68791 (open)"]
    P68791 -->|best fix| I68768
    class I68768 open
    class P68791 open
    class P68791 best
    class P68791 target
    click I68768 "https://github.com/NousResearch/hermes-agent/issues/68768"
    click P68791 "https://github.com/NousResearch/hermes-agent/pull/68791"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).

Cross-PR triage: Reviewed 1 pull request and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 41 kB of PR diffs, 10 kB of issue/PR text, 3 kB of discussion (3 comments), 2 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have platform/webhook Webhook / API server sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Configurable webhook signature and event type headers per route

6 participants