diff --git a/src/config.rs b/src/config.rs index ee45a4f..64b0eb4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use crate::{Hook, IpFilter}; +use crate::{filters::IpFilter, hooks::Hook}; use anyhow::{bail, Result}; use log::info; use serde::{Deserialize, Serialize}; diff --git a/src/hooks.rs b/src/hooks.rs new file mode 100644 index 0000000..5840524 --- /dev/null +++ b/src/hooks.rs @@ -0,0 +1,801 @@ +use crate::{ + filters::{FilterType, IpFilter}, + Config, Metrics, WebhookeyError, +}; +use anyhow::{anyhow, bail, Result}; +use hmac::{Hmac, Mac, NewMac}; +use log::{debug, error, info, trace, warn}; +use rocket::{ + data::{FromData, ToByteUnit}, + futures::TryFutureExt, + http::{HeaderMap, Status}, + outcome::Outcome::{self, Failure, Success}, + post, + tokio::io::AsyncReadExt, + Data, Request, State, +}; +use run_script::ScriptOptions; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::{ + collections::BTreeMap, + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::atomic::Ordering, +}; + +fn accept_ip(hook_name: &str, client_ip: &IpAddr, ip: &IpFilter) -> bool { + if ip.validate(client_ip) { + info!("Allow hook `{}` from {}", &hook_name, &client_ip); + return true; + } + + warn!("Deny hook `{}` from {}", &hook_name, &client_ip); + false +} + +fn get_header_field<'a>(headers: &'a HeaderMap, param: &str) -> Result<&'a str> { + headers + .get_one(param) + .ok_or_else(|| anyhow!("Could not extract event parameter from header")) +} + +fn validate_request(secret: &str, signature: &str, data: &[u8]) -> Result<()> { + let mut mac = Hmac::::new_from_slice(secret.as_bytes()) + .map_err(|e| anyhow!("Could not create hasher with secret: {}", e))?; + mac.update(data); + let raw_signature = hex::decode(signature.as_bytes())?; + mac.verify(&raw_signature).map_err(|e| anyhow!("{}", e)) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Hook { + command: String, + signature: String, + ip_filter: Option, + secrets: Vec, + filter: FilterType, +} + +impl Hook { + fn get_command( + &self, + hook_name: &str, + request: &Request, + data: &mut serde_json::Value, + ) -> Result { + debug!("Replacing parameters for command of hook `{}`", hook_name); + + Hook::replace_parameters(&self.command, request.headers(), data) + } + + fn replace_parameters( + input: &str, + headers: &HeaderMap, + data: &serde_json::Value, + ) -> Result { + let mut command = String::new(); + let command_template = &mut input.chars(); + + while let Some(i) = command_template.next() { + if i == '{' { + if let Some('{') = command_template.next() { + let mut token = String::new(); + + while let Some(i) = command_template.next() { + if i == '}' { + if let Some('}') = command_template.next() { + let expr = token.trim().split(' ').collect::>(); + + let replaced = match expr.get(0) { + Some(&"header") => get_header_field( + headers, + expr.get(1).ok_or_else(|| { + anyhow!("Missing parameter for `header` expression") + })?, + )? + .to_string(), + Some(pointer) => crate::get_string( + data.pointer(pointer).ok_or_else(|| { + anyhow!( + "Could not find field refered to in parameter `{}`", + pointer + ) + })?, + )?, + None => bail!("Missing expression in variable `{}`", token), + }; + + command.push_str(&replaced); + + trace!("Replace `{}` with: {}", token, replaced); + + break; + } else { + command.push('}'); + command.push(i); + } + } else { + token.push(i); + } + } + } else { + command.push('{'); + command.push(i); + } + } else { + command.push(i); + } + } + + Ok(command) + } +} + +#[derive(Debug)] +pub struct Hooks { + pub inner: BTreeMap, +} + +impl Hooks { + pub async fn get_commands( + request: &Request<'_>, + data: Data<'_>, + ) -> Result { + let mut buffer = Vec::new(); + let size = data + .open(1_i32.megabytes()) + .read_to_end(&mut buffer) + .map_err(WebhookeyError::Io) + .await?; + info!("Data of size {} received", size); + + let config = request.guard::<&State>().await.unwrap(); // should never fail + let mut valid = false; + let mut result = BTreeMap::new(); + let client_ip = &request + .client_ip() + .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + + let hooks = config.hooks.iter().filter(|(name, hook)| { + if let Some(ip) = &hook.ip_filter { + accept_ip(name, client_ip, ip) + } else { + info!( + "Allow hook `{}` from {}, no IP filter was configured", + &name, &client_ip + ); + true + } + }); + + for (hook_name, hook) in hooks { + let signature = request + .headers() + .get_one(&hook.signature) + .ok_or(WebhookeyError::InvalidSignature)?; + + let secrets = hook + .secrets + .iter() + .map(|secret| validate_request(secret, signature, &buffer)); + + for secret in secrets { + match secret { + Ok(()) => { + trace!("Valid signature found for hook `{}`", hook_name); + + valid = true; + + let mut data: serde_json::Value = + serde_json::from_slice(&buffer).map_err(WebhookeyError::Serde)?; + + match hook.filter.evaluate(request, &data) { + Ok(true) => match hook.get_command(hook_name, request, &mut data) { + Ok(command) => { + info!("Filter for `{}` matched", &hook_name); + result.insert(hook_name.to_string(), command); + break; + } + Err(e) => error!("{}", e), + }, + Ok(false) => info!("Filter for `{}` did not match", &hook_name), + Err(error) => { + error!("Could not match filter for `{}`: {}", &hook_name, error) + } + } + } + Err(e) => trace!("Hook `{}` could not validate request: {}", &hook_name, e), + } + } + } + + if !valid { + return Err(WebhookeyError::Unauthorized(*client_ip)); + } + + Ok(Hooks { inner: result }) + } +} + +#[rocket::async_trait] +impl<'r> FromData<'r> for Hooks { + type Error = WebhookeyError; + + async fn from_data( + request: &'r Request<'_>, + data: Data<'r>, + ) -> Outcome> { + { + request + .guard::<&State>() + .await + .unwrap() // TODO: Check if unwrap need to be fixed + .requests_received + .fetch_add(1, Ordering::Relaxed); + } + + match Hooks::get_commands(request, data).await { + Ok(hooks) => { + if hooks.inner.is_empty() { + let client_ip = &request + .client_ip() + .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + + request + .guard::<&State>() + .await + .unwrap() // TODO: Check if unwrap need to be fixed + .hooks_unmatched + .fetch_add(1, Ordering::Relaxed); + + warn!("Unmatched hook from {}", &client_ip); + return Failure((Status::NotFound, WebhookeyError::UnmatchedHook(*client_ip))); + } + + Success(hooks) + } + Err(WebhookeyError::Unauthorized(e)) => { + error!("{}", WebhookeyError::Unauthorized(e)); + + request + .guard::<&State>() + .await + .unwrap() // TODO: Check if unwrap need to be fixed + .hooks_forbidden + .fetch_add(1, Ordering::Relaxed); + + Failure((Status::Unauthorized, WebhookeyError::Unauthorized(e))) + } + Err(e) => { + error!("{}", e); + + request + .guard::<&State>() + .await + .unwrap() // TODO: Check if unwrap need to be fixed + .requests_invalid + .fetch_add(1, Ordering::Relaxed); + + Failure((Status::BadRequest, e)) + } + } + } +} + +#[post("/", format = "json", data = "")] +pub async fn receive_hook<'a>( + address: SocketAddr, + hooks: Hooks, + metrics: &State, +) -> Status { + info!("Post request received from: {}", address); + + hooks.inner.iter().for_each(|(name, command)| { + info!("Execute `{}` from hook `{}`", &command, &name); + + match run_script::run(command, &vec![], &ScriptOptions::new()) { + Ok((status, stdout, stderr)) => { + info!("Command `{}` exited with return code: {}", &command, status); + trace!("Output of command `{}` on stdout: {:?}", &command, &stdout); + debug!("Output of command `{}` on stderr: {:?}", &command, &stderr); + + metrics.commands_executed.fetch_add(1, Ordering::Relaxed); + + let _ = match status { + 0 => metrics.commands_successful.fetch_add(1, Ordering::Relaxed), + _ => metrics.commands_failed.fetch_add(1, Ordering::Relaxed), + }; + } + Err(e) => { + error!("Execution of `{}` failed: {}", &command, e); + + metrics + .commands_execution_failed + .fetch_add(1, Ordering::Relaxed); + } + } + }); + + Status::Ok +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + config::MetricsConfig, + filters::{AddrType, FilterType, HeaderFilter, JsonFilter}, + hooks::Hook, + Metrics, + }; + use regex::Regex; + use rocket::{ + http::{ContentType, Header, Status}, + local::asynchronous::Client, + routes, + }; + use serde_json::json; + use std::collections::BTreeMap; + + #[rocket::async_test] + async fn secret() { + let mut hooks = BTreeMap::new(); + + hooks.insert( + "test_hook".to_string(), + Hook { + command: "".to_string(), + signature: "X-Gitea-Signature".to_string(), + ip_filter: None, + secrets: vec!["valid".to_string()], + filter: FilterType::JsonFilter(JsonFilter { + pointer: "*".to_string(), + regex: Regex::new(".*").unwrap(), + }), + }, + ); + + let config = Config { + metrics: None, + hooks: hooks, + }; + + let rocket = rocket::build() + .mount("/", routes![receive_hook]) + .manage(config) + .manage(Metrics::default()); + + let client = Client::tracked(rocket).await.unwrap(); + let response = client + .post("/") + .header(Header::new( + "X-Gitea-Signature", + "28175a0035f637f3cbb85afee9f9d319631580e7621cf790cd16ca063a2f820e", + )) + .header(ContentType::JSON) + .remote("127.0.0.1:8000".parse().unwrap()) + .body(&serde_json::to_string(&json!({ "foo": "bar" })).unwrap()) + .dispatch(); + + assert_eq!(response.await.status(), Status::NotFound); + + let response = client + .post("/") + .header(Header::new("X-Gitea-Signature", "beef")) + .header(ContentType::JSON) + .remote("127.0.0.1:8000".parse().unwrap()) + .body(&serde_json::to_string(&json!({ "foo": "bar" })).unwrap()) + .dispatch(); + + assert_eq!(response.await.status(), Status::Unauthorized); + + let response = client + .post("/") + .header(Header::new( + "X-Gitea-Signature", + "c5c315d76318362ec129ca629b50b626bba09ad3d7ba4cc0f4c0afe4a90537a0", + )) + .header(ContentType::JSON) + .remote("127.0.0.1:8000".parse().unwrap()) + .body(r#"{ "not_secret": "invalid" "#) + .dispatch(); + + assert_eq!(response.await.status(), Status::BadRequest); + + let response = client + .post("/") + .header(Header::new("X-Gitea-Signature", "foobar")) + .header(ContentType::JSON) + .remote("127.0.0.1:8000".parse().unwrap()) + .dispatch(); + + assert_eq!(response.await.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn parse_command_request() { + let mut hooks = BTreeMap::new(); + + hooks.insert( + "test_hook0".to_string(), + Hook { + command: + "/usr/bin/echo {{ /repository/full_name }} --foo {{ /pull_request/base/ref }}" + .to_string(), + signature: "X-Gitea-Signature".to_string(), + ip_filter: None, + secrets: vec!["valid".to_string()], + filter: FilterType::JsonFilter(JsonFilter { + pointer: "/foo".to_string(), + regex: Regex::new("bar").unwrap(), + }), + }, + ); + + hooks.insert( + "test_hook2".to_string(), + Hook { + command: "/usr/bin/echo {{ /repository/full_name }} {{ /pull_request/base/ref }}" + .to_string(), + signature: "X-Gitea-Signature".to_string(), + ip_filter: None, + secrets: vec!["valid".to_string()], + filter: FilterType::JsonFilter(JsonFilter { + pointer: "/foo".to_string(), + regex: Regex::new("bar").unwrap(), + }), + }, + ); + + hooks.insert( + "test_hook3".to_string(), + Hook { + command: "/usr/bin/echo {{ /repository/full_name }} {{ /pull_request/base/ref }}" + .to_string(), + signature: "X-Gitea-Signature".to_string(), + ip_filter: None, + secrets: vec!["valid".to_string()], + filter: FilterType::Not(Box::new(FilterType::JsonFilter(JsonFilter { + pointer: "/foobar".to_string(), + regex: Regex::new("bar").unwrap(), + }))), + }, + ); + + let config = Config { + metrics: None, + hooks: hooks, + }; + + let rocket = rocket::build() + .mount("/", routes![receive_hook]) + .manage(config) + .manage(Metrics::default()); + + let client = Client::tracked(rocket).await.unwrap(); + + let response = client + .post("/") + .header(Header::new( + "X-Gitea-Signature", + "693b733871ecb684651a813c82936df683c9e4a816581f385353e06170545400", + )) + .header(ContentType::JSON) + .remote("127.0.0.1:8000".parse().unwrap()) + .body( + &serde_json::to_string(&json!({ + "foo": "bar", + "repository": { + "full_name": "keith" + }, + "pull_request": { + "base": { + "ref": "main" + } + } + })) + .unwrap(), + ) + .dispatch(); + + assert_eq!(response.await.status(), Status::Ok); + } + + #[rocket::async_test] + async fn parse_invalid_command_request() { + let mut hooks = BTreeMap::new(); + + hooks.insert( + "test_hook".to_string(), + Hook { + command: "/usr/bin/echo {{ /repository/full }} {{ /pull_request/base/ref }}" + .to_string(), + signature: "X-Gitea-Signature".to_string(), + ip_filter: None, + secrets: vec!["valid".to_string()], + filter: FilterType::JsonFilter(JsonFilter { + pointer: "/foo".to_string(), + regex: Regex::new("bar").unwrap(), + }), + }, + ); + + let config = Config { + metrics: None, + hooks: hooks, + }; + + let rocket = rocket::build() + .mount("/", routes![receive_hook]) + .manage(config) + .manage(Metrics::default()); + + let client = Client::tracked(rocket).await.unwrap(); + + let response = client + .post("/") + .header(Header::new( + "X-Gitea-Signature", + "693b733871ecb684651a813c82936df683c9e4a816581f385353e06170545400", + )) + .header(ContentType::JSON) + .remote("127.0.0.1:8000".parse().unwrap()) + .body( + &serde_json::to_string(&json!({ + "foo": "bar", + "repository": { + "full_name": "keith" + }, + "pull_request": { + "base": { + "ref": "main" + } + } + })) + .unwrap(), + ) + .dispatch(); + + assert_eq!(response.await.status(), Status::NotFound); + } + + #[test] + fn parse_command() { + let mut headers = HeaderMap::new(); + headers.add_raw("X-Gitea-Event", "something"); + + assert_eq!( + Hook::replace_parameters("command", &headers, &serde_json::Value::Null).unwrap(), + "command" + ); + + assert_eq!( + Hook::replace_parameters(" command", &headers, &serde_json::Value::Null).unwrap(), + " command" + ); + + assert_eq!( + Hook::replace_parameters("command ", &headers, &serde_json::Value::Null).unwrap(), + "command " + ); + + assert_eq!( + Hook::replace_parameters(" command ", &headers, &serde_json::Value::Null) + .unwrap(), + " command " + ); + + assert_eq!( + Hook::replace_parameters("command command ", &headers, &serde_json::Value::Null) + .unwrap(), + "command command " + ); + + assert_eq!( + Hook::replace_parameters("{{ /foo }} command", &headers, &json!({ "foo": "bar" })) + .unwrap(), + "bar command" + ); + + assert_eq!( + Hook::replace_parameters( + " command {{ /foo }} ", + &headers, + &json!({ "foo": "bar" }) + ) + .unwrap(), + " command bar " + ); + + assert_eq!( + Hook::replace_parameters( + "{{ /foo }} command{{/field1/foo}}", + &headers, + &json!({ "foo": "bar", "field1": { "foo": "baz" } }) + ) + .unwrap(), + "bar commandbaz" + ); + + assert_eq!( + Hook::replace_parameters( + " command {{ /foo }} ", + &headers, + &json!({ "foo": "bar" }) + ) + .unwrap(), + " command bar " + ); + + assert_eq!( + Hook::replace_parameters( + " {{ /field1/foo }} command", + &headers, + &json!({ "field1": { "foo": "bar" } }) + ) + .unwrap(), + " bar command" + ); + + assert_eq!( + Hook::replace_parameters( + " {{ header X-Gitea-Event }} command", + &headers, + &json!({ "field1": { "foo": "bar" } }) + ) + .unwrap(), + " something command" + ); + + assert_eq!( + Hook::replace_parameters( + " {{ header X-Gitea-Event }} {{ /field1/foo }} command", + &headers, + &json!({ "field1": { "foo": "bar" } }) + ) + .unwrap(), + " something bar command" + ); + + assert_eq!( + Hook::replace_parameters( + " {{ header X-Gitea-Event }} {{ /field1/foo }} {{ /field1/bar }} {{ /field2/foo }} --command{{ /cmd }}", + &headers, + &json!({ "field1": { "foo": "bar", "bar": "baz" }, "field2": { "foo": "qux" }, "cmd": " else"}) + ) + .unwrap(), + " something bar baz qux --command else" + ); + } + + #[test] + fn parse_config() { + let config: Config = serde_yaml::from_str( + r#"--- +hooks: + hook1: + command: /usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf + signature: X-Gitea-Signature + ip_filter: + allow: + - 127.0.0.1/31 + secrets: + - secret_key_01 + - secret_key_02 + filter: + json: + pointer: /ref + regex: refs/heads/master + hook2: + command: /usr/bin/local/script_xy.sh asdfasdf + signature: X-Gitea-Signature + secrets: + - secret_key_01 + - secret_key_02 + filter: + and: + - json: + pointer: /ref + regex: refs/heads/master + - header: + field: X-Gitea-Signature + regex: f6e5fe4fe37df76629112d55cc210718b6a55e7e"#, + ) + .unwrap(); + + assert_eq!( + serde_yaml::to_string(&config).unwrap(), + serde_yaml::to_string(&Config { + metrics: None, + hooks: BTreeMap::from([ + ( + "hook1".to_string(), + Hook { + command: "/usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf" + .to_string(), + signature: "X-Gitea-Signature".to_string(), + ip_filter: Some(IpFilter::Allow(vec![AddrType::IpNet( + "127.0.0.1/31".parse().unwrap() + )])), + secrets: vec!["secret_key_01".to_string(), "secret_key_02".to_string()], + filter: FilterType::JsonFilter(JsonFilter { + pointer: "/ref".to_string(), + regex: Regex::new("refs/heads/master").unwrap(), + }), + } + ), + ( + "hook2".to_string(), + Hook { + command: "/usr/bin/local/script_xy.sh asdfasdf".to_string(), + signature: "X-Gitea-Signature".to_string(), + ip_filter: None, + secrets: vec!["secret_key_01".to_string(), "secret_key_02".to_string()], + filter: FilterType::And(vec![ + FilterType::JsonFilter(JsonFilter { + pointer: "/ref".to_string(), + regex: Regex::new("refs/heads/master").unwrap(), + }), + FilterType::HeaderFilter(HeaderFilter { + field: "X-Gitea-Signature".to_string(), + regex: Regex::new("f6e5fe4fe37df76629112d55cc210718b6a55e7e") + .unwrap(), + }), + ]), + } + ) + ]) + }) + .unwrap() + ); + + let config: Config = serde_yaml::from_str( + r#"--- +metrics: + enabled: true +hooks: + hook1: + command: /usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf + signature: X-Gitea-Signature + ip_filter: + allow: + - 127.0.0.1/31 + secrets: + - secret_key_01 + - secret_key_02 + filter: + json: + pointer: /ref + regex: refs/heads/master"#, + ) + .unwrap(); + + assert_eq!( + serde_yaml::to_string(&config).unwrap(), + serde_yaml::to_string(&Config { + metrics: Some(MetricsConfig { + enabled: true, + ip_filter: None + }), + hooks: BTreeMap::from([( + "hook1".to_string(), + Hook { + command: "/usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf" + .to_string(), + signature: "X-Gitea-Signature".to_string(), + ip_filter: Some(IpFilter::Allow(vec![AddrType::IpNet( + "127.0.0.1/31".parse().unwrap() + )])), + secrets: vec!["secret_key_01".to_string(), "secret_key_02".to_string()], + filter: FilterType::JsonFilter(JsonFilter { + pointer: "/ref".to_string(), + regex: Regex::new("refs/heads/master").unwrap(), + }), + } + ),]) + }) + .unwrap() + ); + } +} diff --git a/src/main.rs b/src/main.rs index d97f601..65c64c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,37 +1,15 @@ mod cli; mod config; mod filters; +mod hooks; mod metrics; -use crate::{ - cli::Opts, - config::Config, - filters::{FilterType, IpFilter}, - metrics::Metrics, -}; -use anyhow::{anyhow, bail, Result}; +use crate::{cli::Opts, config::Config, metrics::Metrics}; +use anyhow::Result; use clap::Parser; -use hmac::{Hmac, Mac, NewMac}; -use log::{debug, error, info, trace, warn}; -use rocket::{ - data::{FromData, ToByteUnit}, - futures::TryFutureExt, - http::{HeaderMap, Status}, - outcome::Outcome::{self, Failure, Success}, - post, routes, - tokio::io::AsyncReadExt, - Data, Request, State, -}; -use run_script::ScriptOptions; -use serde::{Deserialize, Serialize}; -use sha2::Sha256; -use std::{ - collections::BTreeMap, - fs::File, - io::BufReader, - net::{IpAddr, Ipv4Addr, SocketAddr}, - sync::atomic::Ordering, -}; +use log::{debug, error, trace}; +use rocket::routes; +use std::{fs::File, io::BufReader, net::IpAddr}; use thiserror::Error; #[derive(Debug, Error)] @@ -50,198 +28,6 @@ pub enum WebhookeyError { Serde(serde_json::Error), } -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct Hook { - command: String, - signature: String, - ip_filter: Option, - secrets: Vec, - filter: FilterType, -} - -impl Hook { - fn get_command( - &self, - hook_name: &str, - request: &Request, - data: &mut serde_json::Value, - ) -> Result { - debug!("Replacing parameters for command of hook `{}`", hook_name); - - Hook::replace_parameters(&self.command, request.headers(), data) - } - - fn replace_parameters( - input: &str, - headers: &HeaderMap, - data: &serde_json::Value, - ) -> Result { - let mut command = String::new(); - let command_template = &mut input.chars(); - - while let Some(i) = command_template.next() { - if i == '{' { - if let Some('{') = command_template.next() { - let mut token = String::new(); - - while let Some(i) = command_template.next() { - if i == '}' { - if let Some('}') = command_template.next() { - let expr = token.trim().split(' ').collect::>(); - - let replaced = match expr.get(0) { - Some(&"header") => get_header_field( - headers, - expr.get(1).ok_or_else(|| { - anyhow!("Missing parameter for `header` expression") - })?, - )? - .to_string(), - Some(pointer) => { - get_string(data.pointer(pointer).ok_or_else(|| { - anyhow!( - "Could not find field refered to in parameter `{}`", - pointer - ) - })?)? - } - None => bail!("Missing expression in variable `{}`", token), - }; - - command.push_str(&replaced); - - trace!("Replace `{}` with: {}", token, replaced); - - break; - } else { - command.push('}'); - command.push(i); - } - } else { - token.push(i); - } - } - } else { - command.push('{'); - command.push(i); - } - } else { - command.push(i); - } - } - - Ok(command) - } -} - -#[derive(Debug)] -struct Hooks { - inner: BTreeMap, -} - -impl Hooks { - async fn get_commands(request: &Request<'_>, data: Data<'_>) -> Result { - let mut buffer = Vec::new(); - let size = data - .open(256_i32.kilobytes()) - .read_to_end(&mut buffer) - .map_err(WebhookeyError::Io) - .await?; - info!("Data of size {} received", size); - - let config = request.guard::<&State>().await.unwrap(); // should never fail - let mut valid = false; - let mut result = BTreeMap::new(); - let client_ip = &request - .client_ip() - .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); - - let hooks = config.hooks.iter().filter(|(name, hook)| { - if let Some(ip) = &hook.ip_filter { - accept_ip(name, client_ip, ip) - } else { - info!( - "Allow hook `{}` from {}, no IP filter was configured", - &name, &client_ip - ); - true - } - }); - - for (hook_name, hook) in hooks { - let signature = request - .headers() - .get_one(&hook.signature) - .ok_or(WebhookeyError::InvalidSignature)?; - - let secrets = hook - .secrets - .iter() - .map(|secret| validate_request(secret, signature, &buffer)); - - for secret in secrets { - match secret { - Ok(()) => { - trace!("Valid signature found for hook `{}`", hook_name); - - valid = true; - - let mut data: serde_json::Value = - serde_json::from_slice(&buffer).map_err(WebhookeyError::Serde)?; - - match hook.filter.evaluate(request, &data) { - Ok(true) => match hook.get_command(hook_name, request, &mut data) { - Ok(command) => { - info!("Filter for `{}` matched", &hook_name); - result.insert(hook_name.to_string(), command); - break; - } - Err(e) => error!("{}", e), - }, - Ok(false) => info!("Filter for `{}` did not match", &hook_name), - Err(error) => { - error!("Could not match filter for `{}`: {}", &hook_name, error) - } - } - } - Err(e) => trace!("Hook `{}` could not validate request: {}", &hook_name, e), - } - } - } - - if !valid { - return Err(WebhookeyError::Unauthorized(*client_ip)); - } - - Ok(Hooks { inner: result }) - } -} - -fn accept_ip(hook_name: &str, client_ip: &IpAddr, ip: &IpFilter) -> bool { - if ip.validate(client_ip) { - info!("Allow hook `{}` from {}", &hook_name, &client_ip); - return true; - } - - warn!("Deny hook `{}` from {}", &hook_name, &client_ip); - false -} - -fn validate_request(secret: &str, signature: &str, data: &[u8]) -> Result<()> { - let mut mac = Hmac::::new_from_slice(secret.as_bytes()) - .map_err(|e| anyhow!("Could not create hasher with secret: {}", e))?; - mac.update(data); - let raw_signature = hex::decode(signature.as_bytes())?; - mac.verify(&raw_signature).map_err(|e| anyhow!("{}", e)) -} - -fn get_header_field<'a>(headers: &'a HeaderMap, param: &str) -> Result<&'a str> { - headers - .get_one(param) - .ok_or_else(|| anyhow!("Could not extract event parameter from header")) -} - pub fn get_string(data: &serde_json::Value) -> Result { match &data { serde_json::Value::Bool(bool) => Ok(bool.to_string()), @@ -254,104 +40,6 @@ pub fn get_string(data: &serde_json::Value) -> Result { } } -#[rocket::async_trait] -impl<'r> FromData<'r> for Hooks { - type Error = WebhookeyError; - - async fn from_data( - request: &'r Request<'_>, - data: Data<'r>, - ) -> Outcome> { - { - request - .guard::<&State>() - .await - .unwrap() // TODO: Check if unwrap need to be fixed - .requests_received - .fetch_add(1, Ordering::Relaxed); - } - - match Hooks::get_commands(request, data).await { - Ok(hooks) => { - if hooks.inner.is_empty() { - let client_ip = &request - .client_ip() - .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); - - request - .guard::<&State>() - .await - .unwrap() // TODO: Check if unwrap need to be fixed - .hooks_unmatched - .fetch_add(1, Ordering::Relaxed); - - warn!("Unmatched hook from {}", &client_ip); - return Failure((Status::NotFound, WebhookeyError::UnmatchedHook(*client_ip))); - } - - Success(hooks) - } - Err(WebhookeyError::Unauthorized(e)) => { - error!("{}", WebhookeyError::Unauthorized(e)); - - request - .guard::<&State>() - .await - .unwrap() // TODO: Check if unwrap need to be fixed - .hooks_forbidden - .fetch_add(1, Ordering::Relaxed); - - Failure((Status::Unauthorized, WebhookeyError::Unauthorized(e))) - } - Err(e) => { - error!("{}", e); - - request - .guard::<&State>() - .await - .unwrap() // TODO: Check if unwrap need to be fixed - .requests_invalid - .fetch_add(1, Ordering::Relaxed); - - Failure((Status::BadRequest, e)) - } - } - } -} - -#[post("/", format = "json", data = "")] -async fn receive_hook<'a>(address: SocketAddr, hooks: Hooks, metrics: &State) -> Status { - info!("Post request received from: {}", address); - - hooks.inner.iter().for_each(|(name, command)| { - info!("Execute `{}` from hook `{}`", &command, &name); - - match run_script::run(command, &vec![], &ScriptOptions::new()) { - Ok((status, stdout, stderr)) => { - info!("Command `{}` exited with return code: {}", &command, status); - trace!("Output of command `{}` on stdout: {:?}", &command, &stdout); - debug!("Output of command `{}` on stderr: {:?}", &command, &stderr); - - metrics.commands_executed.fetch_add(1, Ordering::Relaxed); - - let _ = match status { - 0 => metrics.commands_successful.fetch_add(1, Ordering::Relaxed), - _ => metrics.commands_failed.fetch_add(1, Ordering::Relaxed), - }; - } - Err(e) => { - error!("Execution of `{}` failed: {}", &command, e); - - metrics - .commands_execution_failed - .fetch_add(1, Ordering::Relaxed); - } - } - }); - - Status::Ok -} - #[rocket::main] async fn main() -> Result<()> { env_logger::init(); @@ -372,7 +60,7 @@ async fn main() -> Result<()> { } rocket::build() - .mount("/", routes![receive_hook, metrics::metrics]) + .mount("/", routes![hooks::receive_hook, metrics::metrics]) .manage(config) .manage(Metrics::default()) .launch() @@ -380,477 +68,3 @@ async fn main() -> Result<()> { Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::MetricsConfig; - use filters::{AddrType, HeaderFilter, JsonFilter}; - use regex::Regex; - use rocket::{ - http::{ContentType, Header}, - local::asynchronous::Client, - }; - use serde_json::json; - - #[rocket::async_test] - async fn secret() { - let mut hooks = BTreeMap::new(); - - hooks.insert( - "test_hook".to_string(), - Hook { - command: "".to_string(), - signature: "X-Gitea-Signature".to_string(), - ip_filter: None, - secrets: vec!["valid".to_string()], - filter: FilterType::JsonFilter(JsonFilter { - pointer: "*".to_string(), - regex: Regex::new(".*").unwrap(), - }), - }, - ); - - let config = Config { - metrics: None, - hooks: hooks, - }; - - let rocket = rocket::build() - .mount("/", routes![receive_hook]) - .manage(config) - .manage(Metrics::default()); - - let client = Client::tracked(rocket).await.unwrap(); - let response = client - .post("/") - .header(Header::new( - "X-Gitea-Signature", - "28175a0035f637f3cbb85afee9f9d319631580e7621cf790cd16ca063a2f820e", - )) - .header(ContentType::JSON) - .remote("127.0.0.1:8000".parse().unwrap()) - .body(&serde_json::to_string(&json!({ "foo": "bar" })).unwrap()) - .dispatch(); - - assert_eq!(response.await.status(), Status::NotFound); - - let response = client - .post("/") - .header(Header::new("X-Gitea-Signature", "beef")) - .header(ContentType::JSON) - .remote("127.0.0.1:8000".parse().unwrap()) - .body(&serde_json::to_string(&json!({ "foo": "bar" })).unwrap()) - .dispatch(); - - assert_eq!(response.await.status(), Status::Unauthorized); - - let response = client - .post("/") - .header(Header::new( - "X-Gitea-Signature", - "c5c315d76318362ec129ca629b50b626bba09ad3d7ba4cc0f4c0afe4a90537a0", - )) - .header(ContentType::JSON) - .remote("127.0.0.1:8000".parse().unwrap()) - .body(r#"{ "not_secret": "invalid" "#) - .dispatch(); - - assert_eq!(response.await.status(), Status::BadRequest); - - let response = client - .post("/") - .header(Header::new("X-Gitea-Signature", "foobar")) - .header(ContentType::JSON) - .remote("127.0.0.1:8000".parse().unwrap()) - .dispatch(); - - assert_eq!(response.await.status(), Status::Unauthorized); - } - - #[test] - fn parse_command() { - let mut headers = HeaderMap::new(); - headers.add_raw("X-Gitea-Event", "something"); - - assert_eq!( - Hook::replace_parameters("command", &headers, &serde_json::Value::Null).unwrap(), - "command" - ); - - assert_eq!( - Hook::replace_parameters(" command", &headers, &serde_json::Value::Null).unwrap(), - " command" - ); - - assert_eq!( - Hook::replace_parameters("command ", &headers, &serde_json::Value::Null).unwrap(), - "command " - ); - - assert_eq!( - Hook::replace_parameters(" command ", &headers, &serde_json::Value::Null) - .unwrap(), - " command " - ); - - assert_eq!( - Hook::replace_parameters("command command ", &headers, &serde_json::Value::Null) - .unwrap(), - "command command " - ); - - assert_eq!( - Hook::replace_parameters("{{ /foo }} command", &headers, &json!({ "foo": "bar" })) - .unwrap(), - "bar command" - ); - - assert_eq!( - Hook::replace_parameters( - " command {{ /foo }} ", - &headers, - &json!({ "foo": "bar" }) - ) - .unwrap(), - " command bar " - ); - - assert_eq!( - Hook::replace_parameters( - "{{ /foo }} command{{/field1/foo}}", - &headers, - &json!({ "foo": "bar", "field1": { "foo": "baz" } }) - ) - .unwrap(), - "bar commandbaz" - ); - - assert_eq!( - Hook::replace_parameters( - " command {{ /foo }} ", - &headers, - &json!({ "foo": "bar" }) - ) - .unwrap(), - " command bar " - ); - - assert_eq!( - Hook::replace_parameters( - " {{ /field1/foo }} command", - &headers, - &json!({ "field1": { "foo": "bar" } }) - ) - .unwrap(), - " bar command" - ); - - assert_eq!( - Hook::replace_parameters( - " {{ header X-Gitea-Event }} command", - &headers, - &json!({ "field1": { "foo": "bar" } }) - ) - .unwrap(), - " something command" - ); - - assert_eq!( - Hook::replace_parameters( - " {{ header X-Gitea-Event }} {{ /field1/foo }} command", - &headers, - &json!({ "field1": { "foo": "bar" } }) - ) - .unwrap(), - " something bar command" - ); - - assert_eq!( - Hook::replace_parameters( - " {{ header X-Gitea-Event }} {{ /field1/foo }} {{ /field1/bar }} {{ /field2/foo }} --command{{ /cmd }}", - &headers, - &json!({ "field1": { "foo": "bar", "bar": "baz" }, "field2": { "foo": "qux" }, "cmd": " else"}) - ) - .unwrap(), - " something bar baz qux --command else" - ); - } - - #[rocket::async_test] - async fn parse_command_request() { - let mut hooks = BTreeMap::new(); - - hooks.insert( - "test_hook0".to_string(), - Hook { - command: - "/usr/bin/echo {{ /repository/full_name }} --foo {{ /pull_request/base/ref }}" - .to_string(), - signature: "X-Gitea-Signature".to_string(), - ip_filter: None, - secrets: vec!["valid".to_string()], - filter: FilterType::JsonFilter(JsonFilter { - pointer: "/foo".to_string(), - regex: Regex::new("bar").unwrap(), - }), - }, - ); - - hooks.insert( - "test_hook2".to_string(), - Hook { - command: "/usr/bin/echo {{ /repository/full_name }} {{ /pull_request/base/ref }}" - .to_string(), - signature: "X-Gitea-Signature".to_string(), - ip_filter: None, - secrets: vec!["valid".to_string()], - filter: FilterType::JsonFilter(JsonFilter { - pointer: "/foo".to_string(), - regex: Regex::new("bar").unwrap(), - }), - }, - ); - - hooks.insert( - "test_hook3".to_string(), - Hook { - command: "/usr/bin/echo {{ /repository/full_name }} {{ /pull_request/base/ref }}" - .to_string(), - signature: "X-Gitea-Signature".to_string(), - ip_filter: None, - secrets: vec!["valid".to_string()], - filter: FilterType::Not(Box::new(FilterType::JsonFilter(JsonFilter { - pointer: "/foobar".to_string(), - regex: Regex::new("bar").unwrap(), - }))), - }, - ); - - let config = Config { - metrics: None, - hooks: hooks, - }; - - let rocket = rocket::build() - .mount("/", routes![receive_hook]) - .manage(config) - .manage(Metrics::default()); - - let client = Client::tracked(rocket).await.unwrap(); - - let response = client - .post("/") - .header(Header::new( - "X-Gitea-Signature", - "693b733871ecb684651a813c82936df683c9e4a816581f385353e06170545400", - )) - .header(ContentType::JSON) - .remote("127.0.0.1:8000".parse().unwrap()) - .body( - &serde_json::to_string(&json!({ - "foo": "bar", - "repository": { - "full_name": "keith" - }, - "pull_request": { - "base": { - "ref": "main" - } - } - })) - .unwrap(), - ) - .dispatch(); - - assert_eq!(response.await.status(), Status::Ok); - } - - #[rocket::async_test] - async fn parse_invalid_command_request() { - let mut hooks = BTreeMap::new(); - - hooks.insert( - "test_hook".to_string(), - Hook { - command: "/usr/bin/echo {{ /repository/full }} {{ /pull_request/base/ref }}" - .to_string(), - signature: "X-Gitea-Signature".to_string(), - ip_filter: None, - secrets: vec!["valid".to_string()], - filter: FilterType::JsonFilter(JsonFilter { - pointer: "/foo".to_string(), - regex: Regex::new("bar").unwrap(), - }), - }, - ); - - let config = Config { - metrics: None, - hooks: hooks, - }; - - let rocket = rocket::build() - .mount("/", routes![receive_hook]) - .manage(config) - .manage(Metrics::default()); - - let client = Client::tracked(rocket).await.unwrap(); - - let response = client - .post("/") - .header(Header::new( - "X-Gitea-Signature", - "693b733871ecb684651a813c82936df683c9e4a816581f385353e06170545400", - )) - .header(ContentType::JSON) - .remote("127.0.0.1:8000".parse().unwrap()) - .body( - &serde_json::to_string(&json!({ - "foo": "bar", - "repository": { - "full_name": "keith" - }, - "pull_request": { - "base": { - "ref": "main" - } - } - })) - .unwrap(), - ) - .dispatch(); - - assert_eq!(response.await.status(), Status::NotFound); - } - - #[test] - fn parse_config() { - let config: Config = serde_yaml::from_str( - r#"--- -hooks: - hook1: - command: /usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf - signature: X-Gitea-Signature - ip_filter: - allow: - - 127.0.0.1/31 - secrets: - - secret_key_01 - - secret_key_02 - filter: - json: - pointer: /ref - regex: refs/heads/master - hook2: - command: /usr/bin/local/script_xy.sh asdfasdf - signature: X-Gitea-Signature - secrets: - - secret_key_01 - - secret_key_02 - filter: - and: - - json: - pointer: /ref - regex: refs/heads/master - - header: - field: X-Gitea-Signature - regex: f6e5fe4fe37df76629112d55cc210718b6a55e7e"#, - ) - .unwrap(); - - assert_eq!( - serde_yaml::to_string(&config).unwrap(), - serde_yaml::to_string(&Config { - metrics: None, - hooks: BTreeMap::from([ - ( - "hook1".to_string(), - Hook { - command: "/usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf" - .to_string(), - signature: "X-Gitea-Signature".to_string(), - ip_filter: Some(IpFilter::Allow(vec![AddrType::IpNet( - "127.0.0.1/31".parse().unwrap() - )])), - secrets: vec!["secret_key_01".to_string(), "secret_key_02".to_string()], - filter: FilterType::JsonFilter(JsonFilter { - pointer: "/ref".to_string(), - regex: Regex::new("refs/heads/master").unwrap(), - }), - } - ), - ( - "hook2".to_string(), - Hook { - command: "/usr/bin/local/script_xy.sh asdfasdf".to_string(), - signature: "X-Gitea-Signature".to_string(), - ip_filter: None, - secrets: vec!["secret_key_01".to_string(), "secret_key_02".to_string()], - filter: FilterType::And(vec![ - FilterType::JsonFilter(JsonFilter { - pointer: "/ref".to_string(), - regex: Regex::new("refs/heads/master").unwrap(), - }), - FilterType::HeaderFilter(HeaderFilter { - field: "X-Gitea-Signature".to_string(), - regex: Regex::new("f6e5fe4fe37df76629112d55cc210718b6a55e7e") - .unwrap(), - }), - ]), - } - ) - ]) - }) - .unwrap() - ); - - let config: Config = serde_yaml::from_str( - r#"--- -metrics: - enabled: true -hooks: - hook1: - command: /usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf - signature: X-Gitea-Signature - ip_filter: - allow: - - 127.0.0.1/31 - secrets: - - secret_key_01 - - secret_key_02 - filter: - json: - pointer: /ref - regex: refs/heads/master"#, - ) - .unwrap(); - - assert_eq!( - serde_yaml::to_string(&config).unwrap(), - serde_yaml::to_string(&Config { - metrics: Some(MetricsConfig { - enabled: true, - ip_filter: None - }), - hooks: BTreeMap::from([( - "hook1".to_string(), - Hook { - command: "/usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf" - .to_string(), - signature: "X-Gitea-Signature".to_string(), - ip_filter: Some(IpFilter::Allow(vec![AddrType::IpNet( - "127.0.0.1/31".parse().unwrap() - )])), - secrets: vec!["secret_key_01".to_string(), "secret_key_02".to_string()], - filter: FilterType::JsonFilter(JsonFilter { - pointer: "/ref".to_string(), - regex: Regex::new("refs/heads/master").unwrap(), - }), - } - ),]) - }) - .unwrap() - ); - } -}