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