use crate::{ filters::{FilterType, IpFilter}, Config, Metrics, WebhookeyError, }; use anyhow::{anyhow, bail, Result}; use hmac::{Hmac, Mac}; 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_slice(&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.first() { 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!("Invalid expression `{}`", 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); } } metrics.hooks_successful.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() ); } }