county-sprints/src/webhook.rs
Claude 1473e726f3 webhook: compare secret in constant time
payload.secret != expected_secret short-circuits on the first
differing byte, which leaks timing information about how many
leading bytes of a guess are correct. Use subtle::ConstantTimeEq
instead.
2026-08-13 04:12:13 +02:00

74 lines
2.1 KiB
Rust

use axum::{extract::State, http::StatusCode, response::IntoResponse};
use serde::Deserialize;
use subtle::ConstantTimeEq;
use crate::AppState;
#[derive(Debug, Deserialize)]
pub struct WebhookPayload {
pub secret: String,
#[serde(default)]
pub athlete_id: Option<String>,
#[serde(default)]
pub activity_id: Option<String>,
}
pub async fn receive(
State(state): State<AppState>,
axum::Json(payload): axum::Json<WebhookPayload>,
) -> impl IntoResponse {
let Some(expected_secret) = state.config.intervals_webhook_secret.as_deref() else {
tracing::error!("Intervals.icu webhook secret is not configured");
return (
StatusCode::INTERNAL_SERVER_ERROR,
"webhook secret not configured",
)
.into_response();
};
let secret_matches = payload.secret.as_bytes().ct_eq(expected_secret.as_bytes());
if !bool::from(secret_matches) {
tracing::warn!("received webhook with invalid secret");
return (StatusCode::UNAUTHORIZED, "invalid webhook secret").into_response();
}
let Some(activity_id) = payload.activity_id else {
tracing::warn!("received webhook without activity_id");
return (StatusCode::BAD_REQUEST, "missing activity_id").into_response();
};
let athlete_id = payload
.athlete_id
.or_else(|| std::env::var("INTERVALS_ATHLETE_ID").ok());
let Some(athlete_id) = athlete_id else {
tracing::error!(
"webhook has no athlete_id and \
INTERVALS_ATHLETE_ID is not configured"
);
return (StatusCode::BAD_REQUEST, "missing athlete_id").into_response();
};
let state_clone = state.clone();
tokio::spawn(async move {
if let Err(error) =
crate::process_activity(state_clone, athlete_id, activity_id.clone()).await
{
tracing::error!(
%error,
activity_id = %activity_id,
"failed to process webhook activity"
);
}
});
(StatusCode::ACCEPTED, "activity queued").into_response()
}