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:
MechaCat02
2026-06-10 21:03:10 +02:00
parent bce44769dd
commit 3c9816daf3
7 changed files with 450 additions and 53 deletions

View File

@@ -112,12 +112,18 @@ async fn create_email_trigger(
created["id"].as_str().expect("trigger id").to_string()
}
fn sign(secret: &str, body: &str) -> String {
fn sign(secret: &str, ts: i64, body: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
mac.update(ts.to_string().as_bytes());
mac.update(b".");
mac.update(body.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
fn now_ts() -> i64 {
chrono::Utc::now().timestamp()
}
async fn poll_marker(pool: &PgPool, app_id: &str) -> Option<Value> {
let app_uuid = Uuid::parse_str(app_id).expect("app uuid");
for _ in 0..100 {
@@ -148,10 +154,12 @@ async fn signed_post_accepts_and_fires_handler() {
let handler = create_script(&server, &app_id, "eml-handler", MARKER_HANDLER).await;
let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;
let sig = sign("topsecret", BODY);
let ts = now_ts();
let sig = sign("topsecret", ts, BODY);
server
.post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
.add_header("x-picloud-signature", sig)
.add_header("x-picloud-timestamp", ts.to_string())
.text(BODY)
.await
.assert_status(axum::http::StatusCode::ACCEPTED);
@@ -197,9 +205,11 @@ async fn wrong_signature_is_401() {
let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;
let ts = now_ts();
server
.post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
.add_header("x-picloud-signature", sign("WRONG", BODY))
.add_header("x-picloud-signature", sign("WRONG", ts, BODY))
.add_header("x-picloud-timestamp", ts.to_string())
.text(BODY)
.await
.assert_status(axum::http::StatusCode::UNAUTHORIZED);