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

@@ -455,6 +455,9 @@ pub async fn build_app(
outbox: outbox_repo.clone(),
master_key: master_key.clone(),
nonce_dedup: Arc::new(InboundNonceDedup::new()),
bad_sig_limiter: Arc::new(
picloud_manager_core::email_inbound_api::BadSignatureLimiter::new(),
),
};
let dead_letters_state = DeadLettersState {
repo: dl_repo,

View File

@@ -216,19 +216,32 @@ async fn wrong_signature_is_401() {
}
#[tokio::test]
async fn unsigned_trigger_accepts_without_signature() {
async fn create_without_inbound_secret_is_rejected() {
// Audit 2026-06-11 H-B2 — email triggers must be created with an
// HMAC secret. The previous behavior accepted None and silently
// turned off signature verification at runtime.
let Some(pool) = pool_or_skip().await else {
return;
};
let (server, app_id) = server_for(pool, "unsigned").await;
let (server, app_id) = server_for(pool, "no-secret").await;
let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
let trigger = create_email_trigger(&server, &app_id, &handler, None).await;
let resp = server
.post(&format!("/api/v1/admin/apps/{app_id}/triggers/email"))
.json(&json!({ "script_id": handler, "inbound_secret": null }))
.await;
resp.assert_status(axum::http::StatusCode::BAD_REQUEST);
server
.post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
.text(BODY)
.await
.assert_status(axum::http::StatusCode::ACCEPTED);
let resp = server
.post(&format!("/api/v1/admin/apps/{app_id}/triggers/email"))
.json(&json!({ "script_id": handler, "inbound_secret": "" }))
.await;
resp.assert_status(axum::http::StatusCode::BAD_REQUEST);
let resp = server
.post(&format!("/api/v1/admin/apps/{app_id}/triggers/email"))
.json(&json!({ "script_id": handler, "inbound_secret": " " }))
.await;
resp.assert_status(axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
@@ -273,11 +286,17 @@ async fn malformed_body_is_422() {
};
let (server, app_id) = server_for(pool, "malformed").await;
let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
// Unsigned so we reach the parse step.
let trigger = create_email_trigger(&server, &app_id, &handler, None).await;
let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;
// Sign the malformed body so we reach the parse step (audit
// 2026-06-11 H-B2 — HMAC is now mandatory).
let bad_body = "not json at all";
let ts = now_ts();
let sig = sign("topsecret", ts, bad_body);
server
.post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
.text("not json at all")
.add_header("x-picloud-signature", sig)
.add_header("x-picloud-timestamp", ts.to_string())
.text(bad_body)
.await
.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}
@@ -297,7 +316,9 @@ async fn cross_app_path_is_404() {
.json();
let app_b_id = app_b["id"].as_str().unwrap().to_string();
let handler_b = create_script(&server, &app_b_id, "hb", MARKER_HANDLER).await;
let trigger_b = create_email_trigger(&server, &app_b_id, &handler_b, None).await;
// Audit 2026-06-11 H-B2 — secret required on create; the path-
// app-id mismatch fires before HMAC verification anyway.
let trigger_b = create_email_trigger(&server, &app_b_id, &handler_b, Some("s")).await;
// POST to app A's path with app B's trigger id → 404 (path-bound).
server