Release Notes — Notify external-platform webhook signature validation

Sprint: SPRINT_20260430_005_Notify_external_webhook_signature_validation Module: Notify (src/Notify/) Audit finding closed: Pass-2 §A5 (anonymous PagerDuty/OpsGenie ack webhooks)

Summary

Closes the Pass-2 audit finding that the inbound PagerDuty and OpsGenie acknowledgement webhooks accepted any payload without verifying the platform-supplied HMAC signature. Anyone who could reach the gateway URL could forge an acknowledgement and silence an active escalation for any tenant.

The fix carries the tenant identity in the URL path so the per-tenant secret is resolvable at signature-check time, validates the platform-supplied signature using IHmacAlgorithm, and rejects unsigned/tampered/replayed requests with a stable problem-detail body that does not leak secret material.

⚠️ Breaking change — operator action required

The previous tenantless routes have been removed:

The new per-tenant routes are:

Operators must reconfigure their PagerDuty / OpsGenie integrations to post to the per-tenant URL. A curl-format migration smoke test:

# PagerDuty (per-tenant)
curl -X POST 'https://notify.stella-ops.example/api/v2/ack/webhook/pagerduty/acme-corp' \
  -H "X-PagerDuty-Signature: v1=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')" \
  -H 'Content-Type: application/json' \
  --data "$BODY"

# OpsGenie (per-tenant; canonical = "${TS}:${BODY}")
curl -X POST 'https://notify.stella-ops.example/api/v2/ack/webhook/opsgenie/acme-corp' \
  -H "X-OpsGenie-Webhook-Timestamp: $TS" \
  -H "X-OpsGenie-Webhook-Signature: $(printf '%s:%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')" \
  -H 'Content-Type: application/json' \
  --data "$BODY"

New configuration

Per-tenant signing secrets:

Notify:
  Inbound:
    ReplayWindow: '00:05:00'   # five minutes; symmetric (past and future)
    Tenants:
      acme-corp:
        PagerDuty:
          Secret: 'secret://vault/notify/inbound/acme-corp/pagerduty'
        OpsGenie:
          Secret: 'secret://vault/notify/inbound/acme-corp/opsgenie'
    PagerDutyAllowedSourceCidrs: []   # empty = allow all (default)
    OpsGenieAllowedSourceCidrs: []
    SingleTenantFallback:
      Enabled: false              # legacy single-tenant deployments only
      TenantId: ''                # the tenant id the fallback applies to
      PagerDuty:
        Secret: ''
      OpsGenie:
        Secret: ''

Failure reasons (stable tokens)

The 401 / 404 problem-detail body’s reason field carries one of:

ReasonStatus
tenant_unknown404
tenant_missing401
signature_missing401
signature_invalid401
timestamp_missing401 (OpsGenie)
timestamp_invalid401 (OpsGenie)
timestamp_too_old401 (OpsGenie)
timestamp_in_future401 (OpsGenie)
webhook_not_configured401
platform_unsupported401
ip_not_allowed401

No secret material is ever surfaced.

Cryptography

All HMAC operations route through StellaOps.Cryptography.IHmacAlgorithm (see release-notes-cryptography-hmac-primitive.md); regional crypto plugins (FIPS / GOST / SM / eIDAS) substitute the implementation transparently.

References