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:
@@ -836,7 +836,7 @@ impl Dispatcher {
|
||||
}
|
||||
|
||||
// Exhausted retries → dead-letter.
|
||||
let (op, source) = describe_event(&row.payload);
|
||||
let (op, source) = describe_event(row.source_kind, &row.payload);
|
||||
let now = Utc::now();
|
||||
let dl_id = match self
|
||||
.dead_letters
|
||||
@@ -1051,18 +1051,57 @@ fn failure_kind_to_status(k: InboxFailureKind) -> u16 {
|
||||
|
||||
/// `(op, source)` extracted from the outbox payload. Used to seed the
|
||||
/// `dead_letters` row when retries exhaust.
|
||||
fn describe_event(payload: &serde_json::Value) -> (String, String) {
|
||||
let source = payload
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let op = payload
|
||||
.get("op")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
(op, source)
|
||||
///
|
||||
/// The shape of the payload depends on `source_kind`:
|
||||
/// * `Http` rows carry an `HttpDispatchPayload` (`method`/`path`).
|
||||
/// * `Invoke` rows carry an ad-hoc invoke payload (`script_id`/`args`).
|
||||
/// * Every other kind carries a serialized [`TriggerEvent`], which has
|
||||
/// `source` and `op` fields at the JSON top level.
|
||||
///
|
||||
/// F-S-009 (audit fix): previously this function unconditionally read
|
||||
/// `source`/`op` from the JSON, which silently produced empty strings
|
||||
/// for HTTP rows. The DL row then got written with `source = ""` and
|
||||
/// `from_wire("")` returned `None` on replay, defaulting to
|
||||
/// `OutboxSourceKind::Kv` — replay rerouted through `resolve_trigger`,
|
||||
/// which failed because HTTP rows carry no `trigger_id`. Net result:
|
||||
/// HTTP-async DL replays no-op'd silently.
|
||||
fn describe_event(source_kind: OutboxSourceKind, payload: &serde_json::Value) -> (String, String) {
|
||||
match source_kind {
|
||||
OutboxSourceKind::Http => {
|
||||
let method = payload
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?");
|
||||
let path = payload.get("path").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
(
|
||||
format!("{method} {path}"),
|
||||
OutboxSourceKind::Http.as_str().to_string(),
|
||||
)
|
||||
}
|
||||
OutboxSourceKind::Invoke => (
|
||||
"invoke_async".to_string(),
|
||||
OutboxSourceKind::Invoke.as_str().to_string(),
|
||||
),
|
||||
OutboxSourceKind::Kv
|
||||
| OutboxSourceKind::Docs
|
||||
| OutboxSourceKind::DeadLetter
|
||||
| OutboxSourceKind::Cron
|
||||
| OutboxSourceKind::Files
|
||||
| OutboxSourceKind::Pubsub
|
||||
| OutboxSourceKind::Email => {
|
||||
let source = payload
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let op = payload
|
||||
.get("op")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
(op, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute backoff (ms) for the given attempt + policy + jitter.
|
||||
@@ -1138,6 +1177,59 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_event_http_uses_method_and_path() {
|
||||
let payload = serde_json::json!({
|
||||
"method": "POST",
|
||||
"path": "/webhook",
|
||||
"headers": {},
|
||||
"body": null,
|
||||
"params": {},
|
||||
"query": {},
|
||||
"rest": "",
|
||||
"script_name": "hook",
|
||||
"timeout_seconds": 30,
|
||||
});
|
||||
let (op, source) = describe_event(OutboxSourceKind::Http, &payload);
|
||||
assert_eq!(op, "POST /webhook");
|
||||
assert_eq!(source, "http");
|
||||
// Round-trip via from_wire so the DL replay path resolves to the
|
||||
// HTTP branch instead of falling back to Kv.
|
||||
assert_eq!(
|
||||
OutboxSourceKind::from_wire(&source),
|
||||
Some(OutboxSourceKind::Http)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_event_invoke_uses_constant_op() {
|
||||
let payload = serde_json::json!({
|
||||
"script_id": "00000000-0000-0000-0000-000000000000",
|
||||
"args": {},
|
||||
});
|
||||
let (op, source) = describe_event(OutboxSourceKind::Invoke, &payload);
|
||||
assert_eq!(op, "invoke_async");
|
||||
assert_eq!(source, "invoke");
|
||||
assert_eq!(
|
||||
OutboxSourceKind::from_wire(&source),
|
||||
Some(OutboxSourceKind::Invoke)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_event_trigger_event_reads_top_level_fields() {
|
||||
let payload = serde_json::json!({
|
||||
"source": "kv",
|
||||
"op": "set",
|
||||
"collection": "users",
|
||||
"key": "alice",
|
||||
"value": {"name": "Alice"},
|
||||
});
|
||||
let (op, source) = describe_event(OutboxSourceKind::Kv, &payload);
|
||||
assert_eq!(op, "set");
|
||||
assert_eq!(source, "kv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_exec_error_covers_every_variant() {
|
||||
let parse = classify_exec_error(&ExecError::Parse("nope".into()));
|
||||
|
||||
Reference in New Issue
Block a user