Skip to content

Commit 2d151c4

Browse files
committed
fix: x402 replay amount bypass + bounded batch queries
x402: cached verification now re-checks received amount against the current request's expected_amount_zec. Prevents a $5 txid verified once from passing as proof for a $500 resource (CWE-294). Bounded allocations: added LIMIT to webhook retry (200), subscription cancel/renewal/past_due queries (500 each) to prevent OOM under load.
1 parent 218c431 commit 2d151c4

3 files changed

Lines changed: 39 additions & 11 deletions

File tree

src/api/x402.rs

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,40 @@ pub async fn verify(
9696
if let Some(received_zatoshis) =
9797
get_existing_verified(pool.get_ref(), &merchant.id, &body.txid, protocol).await
9898
{
99-
return HttpResponse::Ok().json(VerifyResponse {
100-
valid: true,
101-
received_zec: received_zatoshis as f64 / 100_000_000.0,
102-
received_zatoshis,
103-
previously_verified: true,
104-
reason: None,
105-
});
99+
// Re-check amount against the current request to prevent replay across price tiers:
100+
// a $5 txid verified once must not pass as proof for a $500 resource.
101+
let expected_zatoshis = match zec_to_zatoshis(body.expected_amount_zec) {
102+
Some(amount) => amount,
103+
None => {
104+
return HttpResponse::BadRequest().json(serde_json::json!({
105+
"error": "expected_amount_zec must be representable in zatoshis"
106+
}));
107+
}
108+
};
109+
let min_acceptable = (expected_zatoshis as f64 * SLIPPAGE_TOLERANCE) as u64;
110+
111+
if received_zatoshis >= min_acceptable {
112+
return HttpResponse::Ok().json(VerifyResponse {
113+
valid: true,
114+
received_zec: received_zatoshis as f64 / 100_000_000.0,
115+
received_zatoshis,
116+
previously_verified: true,
117+
reason: None,
118+
});
119+
} else {
120+
let reason = format!(
121+
"Previously verified amount insufficient: received {} ZEC, expected {} ZEC",
122+
received_zatoshis as f64 / 100_000_000.0,
123+
body.expected_amount_zec
124+
);
125+
return HttpResponse::Ok().json(VerifyResponse {
126+
valid: false,
127+
received_zec: received_zatoshis as f64 / 100_000_000.0,
128+
received_zatoshis,
129+
previously_verified: true,
130+
reason: Some(reason),
131+
});
132+
}
106133
}
107134

108135
let previously_verified = false;

src/subscriptions/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ pub async fn process_renewals(
177177
// 1. Cancel subscriptions marked for end-of-period cancellation
178178
// First, select the ones we're about to cancel (before updating) so we dispatch webhooks only for these
179179
let q = format!(
180-
"SELECT {} FROM subscriptions WHERE cancel_at_period_end = 1 AND current_period_end <= ? AND status = 'active'",
180+
"SELECT {} FROM subscriptions WHERE cancel_at_period_end = 1 AND current_period_end <= ? AND status = 'active' LIMIT 500",
181181
SUB_COLS
182182
);
183183
let to_cancel: Vec<Subscription> = sqlx::query_as::<_, Subscription>(&q)
@@ -225,7 +225,7 @@ pub async fn process_renewals(
225225
.to_string();
226226

227227
let q = format!(
228-
"SELECT {} FROM subscriptions WHERE status = 'active' AND current_period_end <= ? AND cancel_at_period_end = 0",
228+
"SELECT {} FROM subscriptions WHERE status = 'active' AND current_period_end <= ? AND cancel_at_period_end = 0 LIMIT 500",
229229
SUB_COLS
230230
);
231231
let due_subs: Vec<Subscription> = sqlx::query_as::<_, Subscription>(&q)
@@ -334,7 +334,7 @@ pub async fn process_renewals(
334334
// Note: subscription.renewed webhook is dispatched by the scanner on payment confirmation.
335335
// This step is a fallback for edge cases (e.g., server restart during scan).
336336
let q = format!(
337-
"SELECT {} FROM subscriptions WHERE status = 'active' AND current_period_end <= ? AND cancel_at_period_end = 0",
337+
"SELECT {} FROM subscriptions WHERE status = 'active' AND current_period_end <= ? AND cancel_at_period_end = 0 LIMIT 500",
338338
SUB_COLS
339339
);
340340
let past_due_candidates: Vec<Subscription> = sqlx::query_as::<_, Subscription>(&q)

src/webhooks/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,8 @@ pub async fn retry_failed(
259259
JOIN merchants m ON wd.merchant_id = m.id
260260
WHERE wd.status = 'pending'
261261
AND wd.attempts < 5
262-
AND (wd.next_retry_at IS NULL OR wd.next_retry_at <= ?)",
262+
AND (wd.next_retry_at IS NULL OR wd.next_retry_at <= ?)
263+
LIMIT 200",
263264
)
264265
.bind(&now)
265266
.fetch_all(pool)

0 commit comments

Comments
 (0)