From aa746f357d5c67f83f8f2490ceaabc21aaa3397c Mon Sep 17 00:00:00 2001 From: qpismont Date: Sun, 31 May 2026 20:32:49 +0000 Subject: [PATCH] add gitea sig header check, add Json errors, begin AppState --- .devcontainer/devcontainer.json | 2 +- Cargo.lock | 107 ++++++++++++++++++++++++++++++++ Cargo.toml | 6 ++ src/api.rs | 86 ++++++++++++++++++------- src/bot.rs | 14 ++++- src/env.rs | 23 ++++++- src/errors.rs | 40 ++++++++++++ src/gitea.rs | 18 ++++-- src/main.rs | 19 +++++- src/state.rs | 10 +++ 10 files changed, 288 insertions(+), 37 deletions(-) create mode 100644 src/errors.rs create mode 100644 src/state.rs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 745ef73..60f004e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,7 +14,7 @@ }, "customizations": { "vscode": { - "extensions": ["rust-lang.rust-analyzer"], + "extensions": ["rust-lang.rust-analyzer", "tamasfe.even-better-toml", "fill-labs.dependi"], "settings": { "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer", diff --git a/Cargo.lock b/Cargo.lock index e4be325..cc29c98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -145,6 +154,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -155,6 +170,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -181,6 +202,45 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -353,13 +413,34 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "bytes", "dotenvy", + "hex", + "hmac", "reqwest", "serde", "serde_json", + "sha2", + "subtle", + "thiserror", "tokio", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -405,6 +486,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1183,6 +1273,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1469,6 +1570,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 9650694..3893df5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,9 @@ serde = { version = "1.0", features = ["derive"] } dotenvy = "0.15" axum = "0.8" anyhow = "1.0" +thiserror = "2.0" +hmac = "0.13" +sha2 = "0.11" +hex = "0.4" +subtle = "2.6" +bytes = "1.11" \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index a3647a0..14ef828 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,16 +1,28 @@ -use axum::response::{Response, IntoResponse}; -use axum::{Json, Router}; +use anyhow::anyhow; +use axum::body::to_bytes; use axum::extract::FromRequest; -use axum::routing::{post, get}; -use reqwest::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use hmac::{Hmac, KeyInit, Mac}; use serde_json::Value; +use sha2::Sha256; +use subtle::ConstantTimeEq; -use crate::env::{EnvConfig}; +use crate::errors::AppError; use crate::gitea::WebhookType; +use crate::state::AppState; -pub async fn start_api(config: EnvConfig) -> anyhow::Result<()> { - let app = Router::new().route("/", get(root)).route("/webhook", post(webhook)); - let listerner = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.http_port)).await?; +const MAX_WEBHOOK_BODY_SIZE: usize = 1024 * 1024; // 1 Mo + +pub async fn start(app_state: AppState) -> anyhow::Result<()> { + let http_port = app_state.config.http_port; + + let app = Router::new() + .with_state(app_state) + .route("/", get(root)) + .route("/webhook", post(webhook)); + let listerner = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", http_port)).await?; axum::serve(listerner, app) .await @@ -21,11 +33,12 @@ async fn root() -> &'static str { "Hi, i'm Herald :)" } -async fn webhook(WebhookExtract(wb): WebhookExtract) -> Response { - match wb { +async fn webhook(WebhookExtract(wb): WebhookExtract) -> Result { + Ok(match wb { WebhookType::Review(id, _) => format!("Received {} pr id", id), - _ => String::from("Nothing to see :/") - }.into_response() + _ => String::from("Nothing to see :/"), + } + .into_response()) } pub struct WebhookExtract(pub WebhookType); @@ -34,21 +47,48 @@ impl FromRequest for WebhookExtract where S: Send + Sync, { - type Rejection = Response; + type Rejection = AppError; - async fn from_request( - req: axum::extract::Request, - state: &S, - ) -> Result { - let Json(value) = Json::::from_request(req, state) + async fn from_request(req: axum::extract::Request, state: &S) -> Result { + let headers = req.headers(); + let sig_header = headers + .get("x-gitea-signature") + .ok_or(AppError::WebHookSigHeaderNotFoundErr)? + .to_str() + .map_err(|err| anyhow!(err))? + .to_string(); + + let body = req.into_body(); + let body_bytes = to_bytes(body, MAX_WEBHOOK_BODY_SIZE) .await - .map_err(|e| e.into_response())?; + .map_err(|err| anyhow!(err))?; - let webhook = WebhookType::try_from(value) - .map_err(|e| { - (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()).into_response() - })?; + let Json(value) = + Json::::from_bytes(&body_bytes).map_err(|_| AppError::MalformedJsonErr)?; + let webhook = WebhookType::try_from(value)?; + + check_sig_header(sig_header.as_bytes(), &body_bytes)?; Ok(WebhookExtract(webhook)) } } + +fn check_sig_header(sig_header: &[u8], body: &[u8]) -> Result<(), AppError> { + let sig_header_decoded = hex::decode(sig_header).map_err(|err| anyhow!(err))?; + + let webhook_sig_header_secret = + std::env::var("WEBHOOK_SIG_HEADER_SECRET").map_err(|err| anyhow!(err))?; + + let mut mac = Hmac::::new_from_slice(&webhook_sig_header_secret.into_bytes()) + .map_err(|err| anyhow!(err))?; + + mac.update(body); + + let generated_hmac = mac.finalize().into_bytes(); + let check_result: bool = generated_hmac.ct_eq(&sig_header_decoded).into(); + + match check_result { + true => Ok(()), + false => Err(AppError::WebHookSigHeaderInvalidErr), + } +} diff --git a/src/bot.rs b/src/bot.rs index 4e4fa76..6e9ed0f 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,5 +1,13 @@ -use crate::gitea::WebhookType; +use crate::{env::EnvConfig, gitea::WebhookType}; -pub async fn exec(webhook: WebhookType) { +pub struct Bot { + config: EnvConfig, +} -} \ No newline at end of file +impl Bot { + pub fn new(config: EnvConfig) -> Self { + Self { config } + } + + pub async fn exec(&self, webhook: WebhookType) {} +} diff --git a/src/env.rs b/src/env.rs index 16002d7..6298de6 100644 --- a/src/env.rs +++ b/src/env.rs @@ -1,19 +1,36 @@ +use anyhow::anyhow; use dotenvy::dotenv; - +#[derive(Clone)] pub struct EnvConfig { pub http_port: u16, + pub webhook_secret: String, + pub open_router_api_key: String, pub bot_name: String, } pub fn load_config() -> anyhow::Result { dotenv().ok(); - let http_port = std::env::var("HTTP_PORT")?.parse()?; - let bot_name = std::env::var("BOT_NAME")?; + let http_port = try_get_env("HTTP_PORT")?.parse()?; + let bot_name = try_get_env("BOT_NAME")?; + let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?; + let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?; Ok(EnvConfig { http_port, + webhook_secret, bot_name, + open_router_api_key, }) } + +fn try_get_env(key: &str) -> anyhow::Result { + let env = std::env::var(key)?; + + if env.trim().len() == 0 { + return Err(anyhow!(format!("env var {} is empty", env))); + } + + Ok(env) +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..704956f --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,40 @@ +use axum::response::IntoResponse; +use reqwest::StatusCode; + +#[derive(thiserror::Error, Debug)] +pub enum AppError { + #[error("Malformed Json")] + MalformedJsonErr, + + #[error("Json not contains mandatory fields")] + BadJsonStructErr, + + #[error("WebHook sig header not found")] + WebHookSigHeaderNotFoundErr, + + #[error("WebHook sig header is invalid")] + WebHookSigHeaderInvalidErr, + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + match self { + AppError::MalformedJsonErr => (StatusCode::BAD_REQUEST, "Malformed Json"), + AppError::BadJsonStructErr => ( + StatusCode::BAD_REQUEST, + "Json not contains mandatory fields", + ), + AppError::WebHookSigHeaderNotFoundErr => { + (StatusCode::BAD_REQUEST, "WebHook sig header not found") + } + AppError::WebHookSigHeaderInvalidErr => { + (StatusCode::BAD_REQUEST, "WebHook sig header is invalid") + } + AppError::Other(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"), + } + .into_response() + } +} diff --git a/src/gitea.rs b/src/gitea.rs index 113e76d..d148cc9 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -1,17 +1,27 @@ use anyhow::anyhow; use serde_json::Value; +use crate::errors::AppError; pub enum WebhookType { - Review(u64, String) + Review(u64, String), } impl TryFrom for WebhookType { - type Error = anyhow::Error; + type Error = AppError; fn try_from(json: Value) -> Result { let pull_request = json.get("pull_request"); let comment = json.get("comment"); + let action = json + .get("action") + .ok_or(anyhow!("action not found"))? + .as_str() + .ok_or(anyhow!("error while action"))?; + + if action != "created" { + return Err(AppError::BadJsonStructErr); + } if let (Some(pull_request), Some(comment)) = (pull_request, comment) { let comment_body = comment @@ -30,6 +40,6 @@ impl TryFrom for WebhookType { return Ok(WebhookType::Review(pr_id, comment_body)); } - anyhow::bail!("unknow webhook type") + Err(AppError::BadJsonStructErr) } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 1dd3444..6c3db06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,24 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; + +use crate::{bot::Bot, state::AppState}; + mod api; -mod env; -mod gitea; mod bot; +mod env; +mod errors; +mod gitea; +mod state; #[tokio::main] async fn main() -> anyhow::Result<()> { let config = env::load_config()?; - api::start_api(config).await + let app_state = AppState { + bot: Arc::new(Mutex::new(Bot::new(config.clone()))), + config: config, + }; + + api::start(app_state).await } diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..28c3bfe --- /dev/null +++ b/src/state.rs @@ -0,0 +1,10 @@ +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::{bot::Bot, env::EnvConfig}; + +#[derive(Clone)] +pub struct AppState { + pub bot: Arc>, + pub config: EnvConfig, +}