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:
(removed)POST /api/v2/ack/webhook/pagerduty(removed)POST /api/v2/ack/webhook/opsgenie
The new per-tenant routes are:
POST /api/v2/ack/webhook/pagerduty/{tenantId}POST /api/v2/ack/webhook/opsgenie/{tenantId}
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: ''
- Multi-tenant deployments must use per-tenant URLs and per-tenant secrets — there is no global default secret.
- Single-tenant deployments may opt-in to the
SingleTenantFallbackescape hatch. Every use of the fallback emits aLogLevel.Criticalaudit log line so accidental drift to a multi-tenant deployment without per-tenant configuration is loud.
Failure reasons (stable tokens)
The 401 / 404 problem-detail body’s reason field carries one of:
| Reason | Status |
|---|---|
tenant_unknown | 404 |
tenant_missing | 401 |
signature_missing | 401 |
signature_invalid | 401 |
timestamp_missing | 401 (OpsGenie) |
timestamp_invalid | 401 (OpsGenie) |
timestamp_too_old | 401 (OpsGenie) |
timestamp_in_future | 401 (OpsGenie) |
webhook_not_configured | 401 |
platform_unsupported | 401 |
ip_not_allowed | 401 |
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
- Sprint plan:
docs/implplan/SPRINT_20260430_005_Notify_external_webhook_signature_validation.md - Audit advisory:
docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md§A5 - Module dossier:
docs/modules/notify/architecture.md§8.1 (Inbound external-platform webhooks) - Implementation:
src/Notify/__Libraries/StellaOps.Notify.WebHooks.Inbound/ - Endpoint registration:
src/Notify/StellaOps.Notify.WebService/Endpoints/EscalationEndpoints.cs
