diff --git a/CLAUDE.md b/CLAUDE.md index e91b8f608..a4658aa02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ The website and app `prepr` commands Each project may have its own `CLAUDE.md` with detailed instructions: -- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API +- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API - [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website ## Code Guidelines diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index 23cab0e42..045cfd800 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -104,6 +104,7 @@ TREMENDOUS_PRIVATE_KEY=none TREMENDOUS_CAMPAIGN_ID=none TILTIFY_CLIENT_ID= TILTIFY_CLIENT_SECRET= +TILTIFY_WEBHOOK_SIGNING_KEY= TILTIFY_PRIDE_26_CAMPAIGN_ID= HCAPTCHA_SECRET=none diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 4a692b8f1..19a7d9528 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -125,6 +125,7 @@ TREMENDOUS_PRIVATE_KEY=none TREMENDOUS_CAMPAIGN_ID=none TILTIFY_CLIENT_ID= TILTIFY_CLIENT_SECRET= +TILTIFY_WEBHOOK_SIGNING_KEY= TILTIFY_PRIDE_26_CAMPAIGN_ID= HCAPTCHA_SECRET=none diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs index 581ed7113..1bba4aaa2 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -298,6 +298,7 @@ vars! { TILTIFY_CLIENT_ID: String = ""; TILTIFY_CLIENT_SECRET: String = ""; TILTIFY_PRIDE_26_CAMPAIGN_ID: String = ""; + TILTIFY_WEBHOOK_SIGNING_KEY: String = ""; // server pinging SERVER_PING_MAX_CONCURRENT: usize = 16usize; diff --git a/apps/labrinth/src/routes/internal/campaign.rs b/apps/labrinth/src/routes/internal/campaign.rs index 4f6e78867..34a6e9fe7 100644 --- a/apps/labrinth/src/routes/internal/campaign.rs +++ b/apps/labrinth/src/routes/internal/campaign.rs @@ -1,9 +1,12 @@ -use actix_web::{get, post, web}; -use chrono::{DateTime, Utc}; +use actix_web::{HttpRequest, get, post, web}; +use base64::Engine; +use chrono::{DateTime, Duration, Utc}; use eyre::eyre; +use hmac::{Hmac, Mac}; use reqwest::Method; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; +use sha2::Sha256; use std::collections::HashSet; use tracing::{info, warn}; use uuid::Uuid; @@ -134,11 +137,17 @@ impl CampaignDonation { #[utoipa::path] #[post("/webhook")] pub async fn tiltify_webhook( + req: HttpRequest, pool: web::Data, redis: web::Data, payouts_queue: web::Data, - web::Json(raw_payload): web::Json, + body: String, ) -> Result<(), ApiError> { + verify_tiltify_webhook_signature(&req, &body)?; + + let raw_payload = serde_json::from_str::(&body) + .wrap_internal_err_with(|| eyre!("invalid Tiltify webhook JSON"))?; + // deserialize the JSON in the request handler, not in the params, // since if the JSON fails to deserialize then it's *our* fault, // not the caller's. @@ -237,6 +246,52 @@ pub async fn tiltify_webhook( Ok(()) } +fn verify_tiltify_webhook_signature( + req: &HttpRequest, + body: &str, +) -> Result<(), ApiError> { + let signature = req + .headers() + .get("X-Tiltify-Signature") + .and_then(|x| x.to_str().ok()) + .wrap_request_err("missing Tiltify webhook signature")?; + let signature = base64::engine::general_purpose::STANDARD + .decode(signature) + .wrap_request_err("invalid Tiltify webhook signature")?; + + let timestamp = req + .headers() + .get("X-Tiltify-Timestamp") + .and_then(|x| x.to_str().ok()) + .wrap_request_err("missing Tiltify webhook timestamp")?; + let parsed_timestamp = DateTime::parse_from_rfc3339(timestamp) + .wrap_request_err("invalid Tiltify webhook timestamp")?; + let parsed_timestamp = parsed_timestamp.with_timezone(&Utc); + let age = Utc::now().signed_duration_since(parsed_timestamp); + if age < -Duration::minutes(1) || age > Duration::minutes(1) { + return Err(ApiError::Request(eyre!( + "expired Tiltify webhook timestamp", + ))); + } + + if ENV.TILTIFY_WEBHOOK_SIGNING_KEY.is_empty() { + return Err(ApiError::Internal(eyre!( + "TILTIFY_WEBHOOK_SIGNING_KEY must be set" + ))); + } + + let mut mac: Hmac = + Hmac::new_from_slice(ENV.TILTIFY_WEBHOOK_SIGNING_KEY.as_bytes()) + .wrap_internal_err("initializing Tiltify webhook HMAC")?; + mac.update(timestamp.as_bytes()); + mac.update(b"."); + mac.update(body.as_bytes()); + mac.verify_slice(&signature) + .wrap_request_err("invalid Tiltify webhook signature")?; + + Ok(()) +} + #[utoipa::path] #[get("/pride-26")] pub async fn pride_26(