fix(audit-2026-06-11/H-B2): inbound_secret mandatory + bad-signature lockout on email triggers

Two gaps:

1. /api/v1/admin/apps/{id}/triggers/email accepted `inbound_secret: null`
   and treated it as "this trigger is unsigned". The runtime then
   skipped HMAC verification entirely. URL discovery (logs, ops
   dashboards, bruteforce on the 122-bit TriggerId space) then fan-out-
   amplified into the outbox for free — anonymous DoS via the
   dispatcher.

2. Even signed triggers had no per-trigger rate limit on signature-
   verify failures, so an attacker who knew the URL could pump unsigned
   POSTs to force Argon2-equivalent work (HMAC verify + nonce-dedup +
   stored-secret decrypt) at line rate.

Fix:

* triggers_api::create_email_trigger now requires a non-empty
  inbound_secret. 400 on missing / empty / whitespace-only.
* email_inbound_api::receive_inbound_email returns 401 immediately when
  the resolved target has no inbound_secret_encrypted column (pre-fix
  rows). The previous `if let Some(ct), Some(nonce)` branch is gone.
* New `BadSignatureLimiter`: per-`(app_id, trigger_id)` sliding-window
  bucket (10 fails / 60 s). On lockout the receiver returns 429 with
  Retry-After instead of 401. record_failure runs on both the
  "no-secret" 401 path and any verify_signature failure.

Test fallout:
* email_inbound integration tests that relied on None secret: removed
  the now-impossible `unsigned_trigger_accepts_without_signature` test,
  replaced with `create_without_inbound_secret_is_rejected` covering
  null / "" / "   "; updated `malformed_body_is_422` and
  `cross_app_path_is_404` to pass + sign with a secret.
* Two new unit tests pin the limiter behavior (burst lockout + per-
  trigger independence).

Audit refs: security_audit/08_dos_resource.md#h-3,
security_audit/09_external_integrations.md#f-ext-m-001.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-11 20:49:31 +02:00
parent 07ffc0b568
commit 8b5a02b3ef
4 changed files with 207 additions and 37 deletions

View File

@@ -583,25 +583,33 @@ async fn create_email_trigger(
.await?;
validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
// Encrypt the inbound HMAC secret at rest (user-approved deviation
// from the brief's plaintext column). An empty/whitespace secret is
// treated as "no secret" (unsigned trigger).
let (inbound_secret_encrypted, inbound_secret_nonce) = match input.inbound_secret {
Some(secret) if !secret.trim().is_empty() => {
// 64 KB cap is irrelevant for a signing secret, but `seal`
// takes one; reuse the secrets default.
let (ct, nonce) = seal(
&s.master_key,
&serde_json::Value::String(secret),
crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES,
)
.map_err(|e| {
TriggersApiError::Invalid(format!("could not seal inbound_secret: {e}"))
})?;
(Some(ct), Some(nonce.to_vec()))
// Audit 2026-06-11 H-B2 — inbound_secret is mandatory on create.
// The previous behavior accepted None / empty and skipped HMAC
// verification at runtime, which left the public webhook URL as the
// only secret; URL discovery (logs, ops dashboards, brute force on
// 122-bit TriggerId space) then fan-out-amplified into the outbox
// for free.
let secret = match input.inbound_secret {
Some(s) if !s.trim().is_empty() => s,
_ => {
return Err(TriggersApiError::Invalid(
"inbound_secret is required (audit 2026-06-11 H-B2): \
an HMAC-signed webhook URL is the only credential separating \
a real provider from an attacker"
.into(),
))
}
_ => (None, None),
};
// 64 KB cap is irrelevant for a signing secret, but `seal` takes one;
// reuse the secrets default.
let (ct, nonce) = seal(
&s.master_key,
&serde_json::Value::String(secret),
crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES,
)
.map_err(|e| TriggersApiError::Invalid(format!("could not seal inbound_secret: {e}")))?;
let inbound_secret_encrypted = Some(ct);
let inbound_secret_nonce = Some(nonce.to_vec());
let req = CreateEmailTrigger {
script_id: input.script_id,