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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user