Problem/Motivation

McpWebhookWorker sends deliveries with Guzzle's default redirect behavior. When a configured endpoint URL answers with a 301/302 (the classic case: apex domain canonicalizing to www at the edge), Guzzle follows the redirect and — per RFC-compliant client behavior — re-issues the request as a GET with no body. The receiver at the redirect target correctly refuses it (405 or similar), the delivery walks the whole retry/backoff ladder, and after MAX_ATTEMPTS the row lands in permanent failed.

Nothing anywhere mentions the redirect: last_response_code records the terminal 405 from the wrong request, and the operator is left staring at a receiver that answers POSTs fine when tested by hand. Observed in production: 1968 of 1968 deliveries over a month failed exactly this way because the endpoint was configured with the apex URL.

A signed webhook cannot survive a redirect hop. The method gets rewritten, the HMAC-signed body is dropped, and the request that arrives is not the request that was signed. Receivers must be addressed by their exact URL.

Steps to reproduce

  1. Configure a webhook endpoint whose URL 301-redirects (e.g. https://example.com/hook → https://www.example.com/hook).
  2. Trigger any subscribed event.
  3. The delivery fails with the target's 405/4xx, retries five times over ~11 hours, then permanently fails. No log or delivery-row field ever mentions the redirect.

Proposed resolution

  • Send with 'allow_redirects' => FALSE. The worker should never follow a redirect for a signed delivery.
  • Treat any 3xx response as a delivery failure and record the Location header in last_response_body, so the delivery log says "endpoint redirects to X — configure the exact receiver URL" instead of a bare 405.

Related hardening noticed while diagnosing (split into separate issues if preferred)

  • A row claimed in_progress by a worker that dies before writing a result is stranded forever — the cron re-enqueue scan only picks up pending. A stale-claim sweep (reset to pending after a claim TTL) would make the pipeline self-healing.
  • Permanent failures are invisible: nothing surfaces the count of failed delivery rows. A hook_requirements() warning (and/or dashboard figure) when permanent failures accumulate would have turned a month of silence into a status-report red flag.

Comments

jmcerda created an issue. See original summary.

jmcerda’s picture

Status: Active » Needs review

Fix implemented: https://github.com/Wilkes-Liberty/mcp_sentinel/pull/53

  • allow_redirects => FALSE on the delivery request; a 3xx answer fails terminally as failed_redirect with the Location recorded in last_response_body, so the delivery log names the misconfigured URL instead of a bare 405 after five useless retries. Dashboard badge, filter form and metrics learn the new status.
  • The related hardening from the summary rides along: cron reclaims rows stranded in_progress by a dead worker (claim TTL 1h, attempt counter bumped so a poisoned row still converges on MAX_ATTEMPTS), and hook_requirements() warns on the status report when permanent failures accumulate — count, newest failure time, delivery-log link.
  • Kernel coverage for all three: the 301 is recorded-not-followed (exactly one request, allow_redirects === false asserted on the captured request; replay is a no-op), stale-claim reclaim respects the TTL, and the requirements warning appears exactly when failure rows exist.

Follow-up observation from the production incident that motivated this: the signing Key resolved to an empty value there, so deliveries were also going out unsigned — the worker only adds X-MCP-Signature when the secret is non-empty. Worth a separate issue on whether an endpoint configured with a signing key whose value is empty should refuse to send (fail closed) rather than silently send unsigned.

jmcerda’s picture

Status: Needs review » Fixed

Merged to 1.x (PR #53, merge commit 90e9d70). All CI legs green (coding standards + PHPUnit on Drupal 10.6/11/11.3). Ships in 1.12.0 together with #3613146; changelog entries are finalized in that release. The unsigned-when-key-empty follow-up noted in #2 remains open for a separate issue.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

jmcerda’s picture

Version: 1.x-dev » 1.13.0
Status: Fixed » Closed (fixed)

Shipped in 1.13.0. Closing after release.