fix(stage-1): audit High-severity backend correctness + CI unblocker
Closes 4 High-severity audit findings:
- describe_event broken for HTTP-async payloads: was reading source/op
JSON fields that HttpDispatchPayload doesn't carry, producing empty
source on DL rows. from_wire("") then fell back to Kv on replay, the
dispatcher tried to resolve a non-existent trigger, and replay no-op'd
silently. Now branches on OutboxSourceKind: HTTP rows emit
"<method> <path>" + source="http"; Invoke rows emit "invoke_async" +
source="invoke"; TriggerEvent payloads keep top-level field extraction.
- HTTP outbound Authorization leaked across cross-origin redirects.
Manual redirect loop (Policy::none) reused header_map on every hop
and only scrubbed Content-Type for POST->GET. Now compares
url::Url::origin() across hops and strips Authorization,
Proxy-Authorization, Cookie when origin changes — matching reqwest's
default policy.
- F-S-010 inbound email HMAC had no replay protection. Signature was
computed over body only with no timestamp or nonce, so captured POSTs
were replayable indefinitely. Adds X-Picloud-Timestamp header bound
into the HMAC input (signed string is ts || "." || body), 300s
tolerance window, and a process-local LRU keyed on
(ts, sha256(body)) with 600s TTL to catch within-window replays.
Breaking change: webhook senders must include the timestamp header.
- expected_schema.txt re-blessed for migrations 0036-0039 (the previous
snapshot stopped at 0035 so CI would have gone red on first run).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,9 @@
|
||||
//! Only the generic provider-agnostic JSON shape is accepted in v1.1.7
|
||||
//! (see [`InboundPayload`]); provider-specific unmarshallers are v1.2.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, State};
|
||||
@@ -31,7 +33,7 @@ use hmac::{Hmac, Mac};
|
||||
use picloud_shared::{AppId, MasterKey, TriggerEvent, TriggerId};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sha2::Sha256;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
|
||||
use crate::secrets_repo::StoredSecret;
|
||||
@@ -40,15 +42,61 @@ use crate::trigger_repo::TriggerRepo;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Header the provider's HMAC signature is read from. The signature is
|
||||
/// the lowercase hex of `HMAC-SHA256(inbound_secret, raw_body)`.
|
||||
/// Header the provider's HMAC signature is read from. Signature is
|
||||
/// the lowercase hex of `HMAC-SHA256(inbound_secret, timestamp || "." || raw_body)`.
|
||||
const SIGNATURE_HEADER: &str = "x-picloud-signature";
|
||||
|
||||
/// Header the provider posts the request unix-seconds timestamp on. The
|
||||
/// timestamp is bound into the signature input (F-S-010) so captured
|
||||
/// POSTs can't be replayed indefinitely.
|
||||
const TIMESTAMP_HEADER: &str = "x-picloud-timestamp";
|
||||
|
||||
/// Reject signatures whose timestamp is further than this from now. 5 min
|
||||
/// gives ample provider+network slack while bounding the replay window.
|
||||
const SIGNATURE_TIMESTAMP_TOLERANCE_SECS: i64 = 300;
|
||||
|
||||
/// How long an accepted (timestamp, body-hash) pair is remembered to
|
||||
/// catch within-window replays. Slightly longer than tolerance so a
|
||||
/// replay near the edge of the window is still seen as duplicate.
|
||||
const NONCE_DEDUP_TTL_SECS: u64 = 600;
|
||||
|
||||
/// Tiny in-memory dedup for accepted `(timestamp, sha256(body))` pairs.
|
||||
/// Bounded by [`NONCE_DEDUP_TTL_SECS`] — entries older than the TTL are
|
||||
/// pruned on each `record_or_reject` call.
|
||||
#[derive(Default)]
|
||||
pub struct InboundNonceDedup {
|
||||
inner: Mutex<HashMap<(i64, [u8; 32]), Instant>>,
|
||||
}
|
||||
|
||||
impl InboundNonceDedup {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Return `Ok(())` if the pair was new; `Err(())` if already seen
|
||||
/// within the TTL window.
|
||||
fn record_or_reject(&self, ts: i64, body_hash: [u8; 32]) -> Result<(), ()> {
|
||||
let now = Instant::now();
|
||||
let ttl = std::time::Duration::from_secs(NONCE_DEDUP_TTL_SECS);
|
||||
let mut map = self.inner.lock().expect("nonce dedup mutex poisoned");
|
||||
map.retain(|_, recorded_at| now.duration_since(*recorded_at) < ttl);
|
||||
if map.insert((ts, body_hash), now).is_some() {
|
||||
return Err(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EmailInboundState {
|
||||
pub triggers: Arc<dyn TriggerRepo>,
|
||||
pub outbox: Arc<dyn OutboxRepo>,
|
||||
pub master_key: MasterKey,
|
||||
/// F-S-010: replay-dedup for accepted signed payloads. Process-local
|
||||
/// — cluster mode (v1.3+) will need a shared store. Per-process is
|
||||
/// fine for single-node MVP and dev.
|
||||
pub nonce_dedup: Arc<InboundNonceDedup>,
|
||||
}
|
||||
|
||||
pub fn email_inbound_router(state: EmailInboundState) -> Router {
|
||||
@@ -104,7 +152,7 @@ async fn receive_inbound_email(
|
||||
target.inbound_secret_nonce.as_ref(),
|
||||
) {
|
||||
let secret = decrypt_secret(&s.master_key, ct, nonce)?;
|
||||
verify_signature(&headers, &body, secret.as_bytes())?;
|
||||
verify_signature(&headers, &body, secret.as_bytes(), &s.nonce_dedup)?;
|
||||
}
|
||||
|
||||
// Parse the generic JSON shape. Malformed → 422.
|
||||
@@ -165,23 +213,51 @@ fn decrypt_secret(
|
||||
.ok_or(EmailInboundError::Unauthorized)
|
||||
}
|
||||
|
||||
/// Constant-time HMAC-SHA256 verification of the body against the
|
||||
/// `X-Picloud-Signature` header (lowercase hex).
|
||||
/// Constant-time HMAC-SHA256 verification of `timestamp || "." || body`
|
||||
/// against the `X-Picloud-Signature` header (lowercase hex), with
|
||||
/// replay protection.
|
||||
///
|
||||
/// F-S-010: binding `X-Picloud-Timestamp` into the signed input and
|
||||
/// rejecting `|now - ts| > SIGNATURE_TIMESTAMP_TOLERANCE_SECS` bounds
|
||||
/// the replay window; an in-process LRU keyed on
|
||||
/// `(ts, sha256(body))` catches within-window replays. This is a
|
||||
/// breaking change versus pre-v1.1.10 — webhook senders must include
|
||||
/// the timestamp header and sign `ts || "." || body`.
|
||||
fn verify_signature(
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
secret: &[u8],
|
||||
nonce_dedup: &InboundNonceDedup,
|
||||
) -> Result<(), EmailInboundError> {
|
||||
let provided_hex = headers
|
||||
.get(SIGNATURE_HEADER)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or(EmailInboundError::Unauthorized)?;
|
||||
let provided = hex::decode(provided_hex.trim()).map_err(|_| EmailInboundError::Unauthorized)?;
|
||||
let ts_raw = headers
|
||||
.get(TIMESTAMP_HEADER)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or(EmailInboundError::Unauthorized)?
|
||||
.trim();
|
||||
let ts: i64 = ts_raw
|
||||
.parse()
|
||||
.map_err(|_| EmailInboundError::Unauthorized)?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if (now - ts).abs() > SIGNATURE_TIMESTAMP_TOLERANCE_SECS {
|
||||
return Err(EmailInboundError::Unauthorized);
|
||||
}
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret).map_err(|_| EmailInboundError::Unauthorized)?;
|
||||
mac.update(ts_raw.as_bytes());
|
||||
mac.update(b".");
|
||||
mac.update(body);
|
||||
mac.verify_slice(&provided)
|
||||
.map_err(|_| EmailInboundError::Unauthorized)
|
||||
.map_err(|_| EmailInboundError::Unauthorized)?;
|
||||
|
||||
let body_hash: [u8; 32] = Sha256::digest(body).into();
|
||||
nonce_dedup
|
||||
.record_or_reject(ts, body_hash)
|
||||
.map_err(|()| EmailInboundError::Unauthorized)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -234,45 +310,96 @@ mod tests {
|
||||
use crate::secrets_service::seal;
|
||||
use crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES;
|
||||
|
||||
fn sign(secret: &[u8], body: &[u8]) -> String {
|
||||
fn sign(secret: &[u8], ts: i64, body: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
||||
mac.update(ts.to_string().as_bytes());
|
||||
mac.update(b".");
|
||||
mac.update(body);
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
fn headers_with_sig(sig: &str) -> HeaderMap {
|
||||
fn headers_with_sig(sig: &str, ts: i64) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(SIGNATURE_HEADER, sig.parse().unwrap());
|
||||
h.insert(TIMESTAMP_HEADER, ts.to_string().parse().unwrap());
|
||||
h
|
||||
}
|
||||
|
||||
fn now_ts() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_signature_verifies() {
|
||||
let secret = b"shhh";
|
||||
let body = br#"{"from":"a@b.com"}"#;
|
||||
let sig = sign(secret, body);
|
||||
assert!(verify_signature(&headers_with_sig(&sig), body, secret).is_ok());
|
||||
let ts = now_ts();
|
||||
let sig = sign(secret, ts, body);
|
||||
let dedup = InboundNonceDedup::new();
|
||||
assert!(verify_signature(&headers_with_sig(&sig, ts), body, secret, &dedup).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_signature_rejected() {
|
||||
let body = br#"{"from":"a@b.com"}"#;
|
||||
let sig = sign(b"shhh", body);
|
||||
let err = verify_signature(&headers_with_sig(&sig), body, b"different").unwrap_err();
|
||||
let ts = now_ts();
|
||||
let sig = sign(b"shhh", ts, body);
|
||||
let dedup = InboundNonceDedup::new();
|
||||
let err =
|
||||
verify_signature(&headers_with_sig(&sig, ts), body, b"different", &dedup).unwrap_err();
|
||||
assert!(matches!(err, EmailInboundError::Unauthorized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_signature_header_rejected() {
|
||||
let err = verify_signature(&HeaderMap::new(), b"body", b"secret").unwrap_err();
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(TIMESTAMP_HEADER, now_ts().to_string().parse().unwrap());
|
||||
let dedup = InboundNonceDedup::new();
|
||||
let err = verify_signature(&h, b"body", b"secret", &dedup).unwrap_err();
|
||||
assert!(matches!(err, EmailInboundError::Unauthorized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_timestamp_header_rejected() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(SIGNATURE_HEADER, "deadbeef".parse().unwrap());
|
||||
let dedup = InboundNonceDedup::new();
|
||||
let err = verify_signature(&h, b"body", b"secret", &dedup).unwrap_err();
|
||||
assert!(matches!(err, EmailInboundError::Unauthorized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_outside_tolerance_rejected() {
|
||||
let secret = b"shhh";
|
||||
let body = br#"{"from":"a@b.com"}"#;
|
||||
let stale_ts = now_ts() - (SIGNATURE_TIMESTAMP_TOLERANCE_SECS + 60);
|
||||
let sig = sign(secret, stale_ts, body);
|
||||
let dedup = InboundNonceDedup::new();
|
||||
let err =
|
||||
verify_signature(&headers_with_sig(&sig, stale_ts), body, secret, &dedup).unwrap_err();
|
||||
assert!(matches!(err, EmailInboundError::Unauthorized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_within_window_rejected() {
|
||||
let secret = b"shhh";
|
||||
let body = br#"{"from":"a@b.com"}"#;
|
||||
let ts = now_ts();
|
||||
let sig = sign(secret, ts, body);
|
||||
let dedup = InboundNonceDedup::new();
|
||||
assert!(verify_signature(&headers_with_sig(&sig, ts), body, secret, &dedup).is_ok());
|
||||
let err = verify_signature(&headers_with_sig(&sig, ts), body, secret, &dedup).unwrap_err();
|
||||
assert!(matches!(err, EmailInboundError::Unauthorized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_body_fails_verification() {
|
||||
let secret = b"shhh";
|
||||
let sig = sign(secret, b"original");
|
||||
let err = verify_signature(&headers_with_sig(&sig), b"tampered", secret).unwrap_err();
|
||||
let ts = now_ts();
|
||||
let sig = sign(secret, ts, b"original");
|
||||
let dedup = InboundNonceDedup::new();
|
||||
let err =
|
||||
verify_signature(&headers_with_sig(&sig, ts), b"tampered", secret, &dedup).unwrap_err();
|
||||
assert!(matches!(err, EmailInboundError::Unauthorized));
|
||||
}
|
||||
|
||||
@@ -287,10 +414,17 @@ mod tests {
|
||||
.unwrap();
|
||||
let recovered = decrypt_secret(&key, &ct, &nonce).unwrap();
|
||||
assert_eq!(recovered, "provider-secret");
|
||||
// And a signature made with the recovered secret verifies.
|
||||
let body = br#"{"from":"x@y.com"}"#;
|
||||
let sig = sign(recovered.as_bytes(), body);
|
||||
assert!(verify_signature(&headers_with_sig(&sig), body, recovered.as_bytes()).is_ok());
|
||||
let ts = now_ts();
|
||||
let sig = sign(recovered.as_bytes(), ts, body);
|
||||
let dedup = InboundNonceDedup::new();
|
||||
assert!(verify_signature(
|
||||
&headers_with_sig(&sig, ts),
|
||||
body,
|
||||
recovered.as_bytes(),
|
||||
&dedup
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user